Introduction
I learned about running Claude Code on Amazon Bedrock AgentCore from this article.
After experimenting with it, I found that by using the AppSync Event API, you can have conversations while incorporating processes like database operations. In this article, I'll explain how to set this up. Note that this focuses specifically on the AppSync Event API and Claude Code — for database operations and other extensions, please refer to articles about AppSync Event API usage.
What is the AppSync Event API?
The AppSync Event API is a WebSocket-based real-time Pub/Sub service provided by AWS AppSync. It's a relatively new feature added in 2024, offered as a separate service from the traditional AppSync GraphQL Subscriptions.
The basic mechanism is simple:
Publisher Subscriber
│ │
│ HTTP POST /event │ WebSocket wss://.../event/realtime
│ (Send events to channel) │ (Subscribe to channel)
▼ ▼
┌─────────────────────────────────────────┐
│ AppSync Event API │
│ │
│ /channel/a ──→ Subscriber A, B │
│ /channel/b ──→ Subscriber C │
└─────────────────────────────────────────┘
- Publishers send events to channels via HTTP POST
- Subscribers subscribe to channels via WebSocket and receive events in real time
- Channels are managed with hierarchical namespaces (e.g.,
/dialog/job-123)
No GraphQL schema definitions or resolvers are required — just POST JSON to a channel and it gets delivered to WebSocket-connected clients.
Additionally, Event API allows you to configure event handlers (Lambda) for each channel. By inserting handlers during publish or subscribe operations, you can incorporate authorization checks, event transformation, external service notifications, and data persistence. In other words, it goes beyond simple Pub/Sub, enabling backend processing to run simultaneously with real-time notifications.
Furthermore, multiple clients can subscribe to the same channel simultaneously. For example, if multiple people subscribe to /dialog/job-123, they all receive the same events in real time. This naturally supports use cases like team members monitoring the same agent's execution status simultaneously.
In this use case, we're simply streaming text without any handler-based transformation or persistence, so we're using the simplest possible configuration.
Overall Flow
Browser (React)
│
│ ① User enters and submits a prompt
│ POST /trigger (Lambda Function URL)
▼
Trigger Lambda
│
│ ② Invoke AgentCore Runtime
│ InvokeAgentRuntimeCommand
▼
AgentCore Runtime
│
│ ③ Claude Code processes the prompt
│ claude-agent-sdk → Claude Code CLI
│ Each time a TextBlock is output ↓
│
│ ④ HTTP POST (SigV4 auth)
▼
AppSync Event API
│ Channel: /dialog/{jobId}
│
│ ⑤ Deliver via WebSocket
▼
Browser (React)
│ Receive text chunks → Append to screen
- The user submits a prompt from the browser
- The Trigger Lambda invokes the AgentCore Runtime
- Claude Code on AgentCore receives the prompt and begins processing
- Each time Claude Code outputs text, the agent publishes to the
/dialog/{jobId}channel on the Event API - The browser subscribes to the same channel via WebSocket, and text is displayed on screen in real time
The browser establishes the Event API WebSocket connection before sending the request in step ①, so it's ready to receive streaming data without waiting for a response.
Why We Chose the AppSync Event API
There are several ways to deliver text from an agent on AgentCore to the browser in real time. Here's why we chose the Event API.
Requirement: High-Frequency, Low-Latency Text Chunk Delivery
Claude Code outputs text intermittently in chunks of several dozen characters. To display this output in the browser "as if text were flowing in a terminal," the following is required:
- Ability to handle several events per second
- Low delivery latency (within a few hundred milliseconds)
- Simple publisher implementation (ability to send directly from the agent's container)
Why the Event API is a Good Fit
| Feature | Event API Behavior |
|---|---|
| Delivery method | Completes in a single HTTP POST |
| Intermediate processing | No Lambda invocation when no handler is configured |
| Schema definition | Not required (arbitrary JSON can be sent) |
| WebSocket management | Automatically managed by AWS |
| Authentication | Supports multiple methods: IAM / Cognito / API Key |
Trying to achieve the same thing with GraphQL Subscriptions would require mutation definitions → Lambda resolver implementation → Subscription filtering configuration, which adds significant overhead for a use case that just needs to stream text chunks.
The Event API follows a simple model of "POST to a channel → deliver via WebSocket", making it well-suited for high-frequency, lightweight use cases like streaming delivery.
CDK: Event API Definition
// streaming-sample-stack.ts
const eventApi = new appsync.EventApi(this, 'DialogEventApi', {
apiName: `${prefix}-dialog-event-api`,
authorizationConfig: {
authProviders: [
{
authorizationType: appsync.AppSyncAuthorizationType.USER_POOL,
cognitoConfig: { userPool },
},
{ authorizationType: appsync.AppSyncAuthorizationType.IAM },
],
connectionAuthModeTypes: [appsync.AppSyncAuthorizationType.USER_POOL],
defaultPublishAuthModeTypes: [appsync.AppSyncAuthorizationType.IAM],
defaultSubscribeAuthModeTypes: [appsync.AppSyncAuthorizationType.USER_POOL],
},
});
eventApi.addChannelNamespace('dialog');
Publishing uses IAM authentication, and subscribing uses Cognito User Pool authentication. This ensures that only the agent can publish, and only authenticated users can subscribe.
Channels use the /dialog/{jobId} format, isolated per job. You can optionally add a Lambda handler during subscription for authorization checks.
Granting IAM Permissions to AgentCore Runtime
// AgentCoreStack.ts
// Grant publish permission to Event API
eventApi.grantPublish(this.runtime);
This grants appsync:EventPublish permission to the AgentCore Runtime role. With CDK's grantPublish(), it takes just one line.
Agent Side: Publishing to Event API with SigV4
# agent.py
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
def _publish_event(channel: str, events: list[dict]) -> None:
"""Publish events to AppSync Event API (SigV4 auth)"""
credentials = boto3.Session().get_credentials().get_frozen_credentials()
payload = json.dumps({
"channel": channel,
"events": [json.dumps(e) for e in events],
}).encode("utf-8")
# Generate SigV4 signature
request = AWSRequest(
method="POST",
url=EVENT_API_ENDPOINT,
data=payload,
headers={"Content-Type": "application/json"},
)
SigV4Auth(credentials, "appsync", AWS_REGION).add_auth(request)
req = urllib.request.Request(
EVENT_API_ENDPOINT,
data=payload,
headers=dict(request.headers),
method="POST",
)
urllib.request.urlopen(req, timeout=10)
The signature is applied using botocore's SigV4Auth. No external libraries are needed — it works entirely with modules bundled in boto3. The Event API HTTP endpoint is https://{host}/event.
Calling from the claude-agent-sdk Streaming Loop
async for message in query(prompt=_prompt_stream(), options=options):
if isinstance(message, AssistantMessage):
for block in message.content:
if hasattr(block, "text"):
# Stream delivery in a background thread
threading.Thread(
target=lambda jid=job_id, txt=block.text: _publish_event(
f"/dialog/{jid}",
[{"type": "stream", "text": txt}]
),
daemon=True,
).start()
Each time Claude Code outputs text, _publish_event() is called in a background thread. By using threading.Thread for asynchronous execution, the HTTP POST latency doesn't block Claude Code's processing (details in Gotcha 3 below).
Frontend Side: Subscribing via WebSocket
// EventApiClient.ts
// Authentication via subprotocol at connection time
const httpHost = REALTIME_ENDPOINT
.replace("wss://", "").replace("/event/realtime", "")
.replace("appsync-realtime-api", "appsync-api");
const authProtocol = "header-" + btoa(
JSON.stringify({ Authorization: idToken, host: httpHost })
).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
this.ws = new WebSocket(REALTIME_ENDPOINT, [
"aws-appsync-event-ws",
authProtocol,
]);
// Send connection_init
this.ws.onopen = () => {
this.ws.send(JSON.stringify({ type: "connection_init" }));
};
// Authorization object required when subscribing
this.ws.send(JSON.stringify({
type: "subscribe",
id: crypto.randomUUID(),
channel: `/dialog/${jobId}`,
authorization: {
Authorization: idToken,
host: httpHost,
},
}));
// Receiving data
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "data") {
const payload = JSON.parse(data.event);
if (payload.type === "stream") {
onMessage(payload.text);
}
}
};
There are two important notes:
-
Base64 must be URL-safe. Replace
+→-,/→_, and remove trailing=. Standardbtoa()alone produces invalid WebSocket subprotocol values. -
The subscribe message requires an
authorizationobject. In addition to the subprotocol at connection time, authentication credentials must also be provided when subscribing to a channel.
Gotcha 1: WebSocket Authentication Headers
Getting the Cognito ID token passed during the AppSync Event API WebSocket connection was tricky.
For regular HTTP requests, you can simply attach an Authorization header, but the browser WebSocket API does not support custom headers. The AppSync Event API uses WebSocket subprotocols to pass authentication information.
// ❌ Cannot pass auth credentials this way
new WebSocket(url, { headers: { Authorization: token } });
// ✅ Pass URL-safe Base64 encoded headers as a subprotocol
const encoded = btoa(JSON.stringify({
host: EVENT_API_HOST,
Authorization: idToken,
})).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
new WebSocket(url, ["aws-appsync-event-ws", `header-${encoded}`]);
Standard btoa() alone produces invalid WebSocket subprotocol values. Characters like +, /, and = are not allowed in subprotocol names, so URL-safe Base64 conversion is required. This specification is completely different from regular AppSync GraphQL Subscriptions (where Amplify handles it automatically), making it an easy detail to overlook.
Gotcha 2: Event API Hostname Differs from GraphQL API
The AppSync Event API endpoint uses a different domain from the regular GraphQL API.
GraphQL API: https://xxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql
Event API: https://xxxxx.appsync-eventapi.ap-northeast-1.amazonaws.com/event
Initially, I was sending Event API requests to the GraphQL API domain, which kept returning 404 errors. When you create an EventApi with CDK, a separate domain is provisioned, so we pass it to both the frontend and agent via SSM Parameter Store.
// ApiStack.ts
new ssm.StringParameter(this, 'EventApiEndpoint', {
parameterName: `/${props.stage}/appsync/event-api-endpoint`,
stringValue: this.eventApi.httpDns, // ← Different from the GraphQL API's dns
});
Gotcha 3: Always Use Background Threads for Streaming Delivery
_publish_event() sends an HTTP POST to AppSync, which incurs latency of several tens of milliseconds. Calling this synchronously within the Claude Code message loop slows down Claude Code's processing itself.
# ❌ Synchronous calls block Claude Code's processing
for block in message.content:
_publish_event(f"/dialog/{job_id}", [{"type": "stream", "text": block.text}])
# ✅ Send asynchronously in background threads
for block in message.content:
threading.Thread(
target=lambda jid=job_id, txt=block.text: _publish_event(
f"/dialog/{jid}", [{"type": "stream", "text": txt}]
),
daemon=True,
).start()
Setting daemon=True ensures threads terminate automatically when the main process exits. A delay of several tens of milliseconds in delivery is barely noticeable in streaming display.
However, when delivering via background threads, you need to wait for all threads to complete before sending the completion event. Otherwise, the complete event may arrive at the client before the TextBlocks, causing the client to disconnect prematurely.
publish_threads: list[threading.Thread] = []
for block in message.content:
t = threading.Thread(
target=lambda jid=job_id, txt=block.text: _publish_event(...),
daemon=True,
)
publish_threads.append(t)
t.start()
# Wait for all publishes to complete before sending complete
for t in publish_threads:
t.join(timeout=10)
_publish_event(f"/dialog/{job_id}", [{"type": "complete", ...}])
Summary
Here's a summary of the implementation for delivering Claude Code output on AgentCore to the browser in real time using the AppSync Event API.
| Item | Details |
|---|---|
| Delivery method | AppSync Event API (HTTP POST → WebSocket) |
| Channel design |
/dialog/{jobId} — isolated per job |
| Auth (publisher) | IAM (SigV4) — only the agent can publish |
| Auth (subscriber) | Cognito User Pool — authorization check via Lambda on subscribe |
| Delivery timing | Per AssistantMessage.TextBlock from claude-agent-sdk
|
| Async handling |
threading.Thread to avoid blocking the main loop |
The reason we chose the Event API is its simplicity: "just POST to a channel and it's instantly delivered to WebSocket clients." No GraphQL schema definitions or resolvers are needed, and authentication/authorization is managed by AppSync, so the agent implementation only needs to HTTP POST text.
Furthermore, since the Event API supports extensions like log persistence and external notifications through event handlers, if storing streaming text or notification integration becomes necessary in the future, functionality can be added on top of the same delivery pipeline.
Top comments (0)