Cereal docs
Build a research agent

Add a streaming chat

Start a session through your server and read its events in the browser.

Complete the TypeScript quickstart first. Set different ADA_PASSWORD and GRACE_PASSWORD values in the starter's .env, then run:

pnpm dev

Open localhost:3000. Your browser asks for a username and password; use ada and the password you set. Ask “What are the Orion launch risks? Cite your sources.”

This command builds the browser assets and starts the application plus its development channel in one process. Stop the terminal quickstart first. Restart pnpm dev after editing source; this starter does not add a separate file watcher.

Start the conversation on your server

The browser posts a message and a request ID to /api/sessions. The server has already authenticated the member. It supplies identity and metadata itself:

src/app.ts
      if (url.pathname === "/api/sessions" && request.method === "POST") {
        const body = (await request.json()) as { message?: unknown; requestId?: unknown }
        if (
          typeof body.message !== "string" ||
          !body.message.trim() ||
          typeof body.requestId !== "string"
        ) {
          return json({ error: "A message and requestId are required." }, 400)
        }
        return json(
          await agent.sessions.start({
            message: body.message,
            idempotencyKey: `${member.id}:${body.requestId}`,
            identity: { scope: "user", id: member.id },
            metadata: { workspaceId: member.workspaceId }
          })
        )
      }

The response contains a session ID. The browser stores it under the signed-in member's name so a refresh can reopen the conversation. Retrying the same logical request uses the same idempotency key. Sending another message uses /api/sessions/:id/message.

Issue a scoped token

The server checks session ownership before this branch runs:

src/app.ts
        if (action === "token" && request.method === "GET") {
          return json(await agent.sessions.streamToken({ id, format: "cereal", ttlSeconds: 300 }))
        }

The response is sent with Cache-Control: no-store. This token reads one session and cannot send a message or answer an approval. Your Cereal API key stays on the server.

Subscribe in the browser

src/browser.ts
function watch(sessionId: string) {
  subscription?.close()
  transcript.replaceChildren()
  approvals.replaceChildren()
  approvalsSnapshot = ""
  const seen = new Set<number>()
  subscription = subscribe({
    format: "cereal",
    tokenSource: () => api(`/api/sessions/${sessionId}/token`),
    onChunk(event) {
      if (id !== sessionId) return
      if (event.type === "user.message" || event.type === "agent.message") {
        if (seen.has(event.seq)) return
        seen.add(event.seq)
        const item = document.createElement("li")
        item.textContent = `${event.type === "user.message" ? "You" : "Agent"}: ${event.text}`
        transcript.append(item)
      }
      if (event.type === "session.status") {
        status.textContent = `${event.status}${event.stopReason ? `: ${event.stopReason}` : ""}`
        void refreshApprovals(sessionId).catch((error) => {
          status.textContent = String(error)
        })
      }
    },
    onError: () => {
      status.textContent = "Stream interrupted. Reconnecting…"
    },
    onReconnect: () => {
      status.textContent = "Stream reconnected."
    }
  })
  // On a reload, start at the beginning to rebuild the transcript. The subscriber
  // owns its cursor while connected; persisting only a cursor would lose old messages.
  void refreshApprovals(sessionId).catch((error) => {
    status.textContent = String(error)
  })
}

api() is the starter's fetch helper: it parses JSON and throws on a failed response. subscribe reconnects and asks tokenSource for fresh credentials when necessary. The UI renders durable messages and deduplicates them by seq. A page reload rebuilds the transcript from the beginning; it does not save a cursor without also saving messages.

Check the result

Send a follow-up, refresh the page, and confirm both replies remain. The subscription is read-only; the message route is where your application authorizes spending another turn.

Next: verify workspace access.

On this page