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 devOpen 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:
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:
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
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.