Build along

Add chat to your app

Three steps and about 40 lines of code. By the end you’ll have a streaming assistant in your app that knows who it’s talking to and remembers them next time.

Before we start
  1. Create a session

    A session is a row in a table, and it decides how the agent behaves: the system prompt, the model, which tools it gets. The runtime is a generic host, so swapping any of that is a data change. No deploy required.

    Your signed-in user calls POST /agent/sessions and becomes the session’s owner. Hold on to the sessionId it returns. Happy with every default? Send an empty body.

    Every field a session takes
    FieldTypeMeaning
    systemPromptstringThe persona and instructions. Defaults to the package prompt.
    modelIdstringBedrock model id (cross-region profile prefix included). Defaults to the deploy’s chat model.
    temperature, maxTokensnumberSampling controls; package defaults when unset.
    titlestringDisplay name for the conversation.
    toolsstring[]Names from the host’s first-party tool registry to enable for this session.
    mcpServersrecordExternal MCP servers by label: url, headers, optional authHeader. Hosts must be allowlisted.
    userIdstringSet by the host from the verified caller. The session’s owner; never client-supplied.
    import { useAuth } from '@readysetcloud/ui/auth';
    
    const { user, getToken } = useAuth();
    const authed = async (path, body) =>
      (await fetch(`${CORE_API_URL}${path}`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${await getToken()}`, 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      })).json();
    
    const { sessionId } = await authed('/agent/sessions', {
      systemPrompt: 'You are a terse code reviewer.',
      tools: ['http_request'],
    });
    import { createSession } from '@readysetcloud/agent/memory';
    
    // The verified user becomes the OWNER. Nobody can overwrite it later:
    // createSession only writes when the session doesn't exist yet.
    const { sessionId } = await createSession({
      userId,
      systemPrompt: 'You are a terse code reviewer.',
      modelId: 'us.anthropic.claude-sonnet-4-...',
      tools: ['http_request'],
    });
  2. Get a socket URL

    Next, the browser trades its sessionId for a wss:// URL with POST /agent/connect. On custom-domain deploys that’s wss://chat.<domain>/ws, a CloudFront proxy that keeps the account id in the runtime ARN out of sight.

    Here’s the one quirk. Browsers can’t set headers on a WebSocket, so the Cognito token rides in the Sec-WebSocket-Protocol header as a base64url bearer. Wrap that up in a function and the chat component will call it on every connect and every reconnect, so the token is always fresh.

    // base64url without padding, which is how AgentCore expects the bearer.
    const b64url = (s) => btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
    
    const getConnectionUrl = async (sid?: string) => {
      const { wsUrl } = await authed('/agent/connect', { sessionId: sid });
      const token = await getToken();
      return {
        url: wsUrl,
        protocols: [`base64UrlBearerAuthorization.${b64url(token)}`, 'base64UrlBearerAuthorization'],
      };
    };
  3. Render it

    That’s the hard part done. Hand both to Chat and you’ve got a streaming assistant with a message list, a composer, and reconnects built in.

    Your app owns auth and the chat just calls the function you gave it. The chat components never import your auth code, which keeps them reusable across every app.

    Want a custom layout? Use the pieces
    ExportUse
    ChatThe complete surface: message list, composer, streaming state, reconnect.
    useAgentChatThe hook behind it, for a custom layout: messages, status, send.
    WebSocketChatClientThe bare client: connect with a URL and protocols, listen for ServerMessages.
    ChatMessageOne rendered message (markdown-aware).
    import { Chat } from '@readysetcloud/ui/chat';
    
    <Chat
      sessionId={sessionId}
      userId={user.sub}
      getConnectionUrl={getConnectionUrl}
      title="Assistant"
    />
  4. What just happened?

    Send a message and a lot happens in a few hundred milliseconds. Identity gets checked twice: once on your HTTP calls by the CoreApi authorizer, and again on the socket by the runtime’s JWT authorizer. Then the runtime loads the session and makes sure the verified sub actually owns it. Nobody talks to a model until all of that checks out.

    Sequence of a chat: configure, connect, open, and one streamed turn CONFIGURECONNECTOPENTURNBrowserCognito userCoreApiCognito authorizerAgentCore RuntimeNODE_22DynamoDBagent tableBedrock · Memorymodel · AgentCorePOST /agent/sessionsput CONFIG · owner = sub{ sessionId }POST /agent/connect{ wsUrl }open wss:// · bearer subprotocolJWT authorizer validates the tokenload CONFIG · owner == sub?{ request, session_id }recall · invoke · toolsstream_event × ncompletesave snapshotcreateEvent
    Identity gets checked twice: once by the CoreApi authorizer on the HTTP calls, and again by the runtime’s inbound JWT authorizer on the socket. After that, the runtime makes sure the verified sub actually owns the session. Hit replay to watch the messages arrive in order.
Good to know

A few things you’ll want later

How it remembers

Picking up where you left off and remembering who you are sound like the same problem. They aren’t, so they live in different places.

Snapshots keep each conversation in the agent table (pk=SESSION#{sessionId}, 30-day TTL), so a reconnect picks up mid-thought. AgentCore Memory pulls facts (/facts/{sub}) and preferences (/users/{sub}/preferences) out of every turn for the verified caller and brings the relevant ones back next time. The model also gets a search_memory tool for when it wants to look something up on purpose.

Tasks travel light: snapshots only. If a task really needs long-term recall, its host passes a memoryManager to createAssistant.

What goes over the socket

These shapes came over verbatim from the original Python agent, and they’re staying put. A turn is a run of stream_event frames followed by one complete.

→ { "request": "What shipped this week?", "session_id": "..." }
← { "type": "stream_event", "event": { "data": "Here" } }
← { "type": "stream_event", "event": { "current_tool_use": { "name": "http_request" } } }
← { "type": "stream_event", "event": { "data": " is what I found." } }
← { "type": "complete", "session_id": "..." }
← { "type": "error", "error": "...", "message": "..." }

When the token is bad

A rejected token closes the socket with 4401 or 1008. The UI client treats both as an auth error instead of retrying, so you don’t get stuck in a reconnect loop hammering the runtime.

And a leaked or guessed sessionId is useless on its own. The runtime compares the session’s owner to the verified token on every connect.

Two memory planes: per-session snapshots and per-user AgentCore Memory CONVERSATIONSPLANE 1 · CONTINUITYPLANE 2 · RECALLSession AMonday · 4 turnsSession BThursday · same person, new sessionSESSION#ASNAPSHOT · LATEST · MANIFESTSESSION#BSNAPSHOT · LATEST · MANIFESTsave · resume on reconnectAgentCore Memory · actor = verified sub/facts/{sub}semantic facts/users/{sub}/preferencesdurable preferences and stylesearch_memoryexplicit lookup toolcreateEventeach turnrelevantrecordsinjected
Snapshots let a reconnect pick up exactly where it left off. AgentCore Memory carries what it learned about the person into the next conversation. Tasks only get the first one unless their host passes a memory manager.