Streaming to a browser
A token, an SSE route, three wire formats, and resume that is a cursor.
Plane serves a session's log straight to a page. There is no SSE proxy to write, no
Redis, no activeStreamId column, and resume is a cursor rather than an apparatus.
The token exchange
Your server mints a short-lived, session-scoped, read-only token. An API key is deliberately not accepted on the stream route, because the URL is meant to be handed to a browser.
// On your server — the same request that already created the session.
const { url, token, expiresAt, format } = await client.sessions.streamToken({
id: sessionId,
format: "ai-sdk", // optional; omit to leave the token unpinned
ttlSeconds: 300, // default 300, max 3600
include: ["cost"] // widen the projection; the allowlist is signed
})It costs data, not read. Minting a credential that will be handed to a browser is
an act of delegation, not a read, and it is the key your application server already
holds on this path.
There is no revocation list. A token lives at most its TTL and grants read of one session; that is why the default is five minutes and not an hour.
The route
GET /sessions/{id}/stream?token= for EventSource, which cannot set a header; Authorization: Bearer for a
fetch reader, which keeps the URL clean. ?format= picks the vocabulary when the
token did not pin one; a token pinned to one and presented with another is a 400,
not a silent override.
curl -N "$PLANE_URL/sessions/ses_…/stream?format=plane" \
-H "Authorization: Bearer $STREAM_TOKEN"In the page
import { subscribe } from "@plane/sdk/browser"
const sub = subscribe({
format: "ai-sdk",
// A function, not a string: it is called again when the token expires.
tokenSource: () => fetch(`/api/watch/${sessionId}`).then((r) => r.json()),
onChunk: (chunk) => apply(chunk),
onError: (error) => console.warn(error)
})
// sub.close()subscribe holds the last id: it saw, reconnects with exponential backoff plus
jitter, sends Last-Event-ID itself, re-mints through tokenSource on a 401 or at
expiry, and coalesces delta parts once per animation frame — a catch-up replay of a
thousand deltas makes React run far faster than a browser can paint.
Over it, one layer per format:
// AI SDK: a drop-in ChatTransport. `sendMessages` posts to YOUR endpoint.
import { PlaneChatTransport } from "@plane/sdk/browser"
useChat({ transport: new PlaneChatTransport({ tokenSource, sendUrl: "/api/send" }) })
// Framework-free: the folded transcript, drafts, approvals, status and usage.
import { usePlaneSession } from "@plane/sdk/browser"
const { messages, drafts, pendingApprovals, status, usage } = usePlaneSession({ tokenSource })ai and the AG-UI packages are peer dependencies, so a customer who wants raw
parts does not pull in a client library for a format they are not using.
The three formats
format | What it emits | For |
|---|---|---|
ai-sdk | Vercel AI SDK UIMessageChunks. The default. | useChat, and anything already speaking that shape. |
ag-ui | AG-UI events. | CopilotKit, or your own AG-UI runtime. |
plane | The client-safe SessionEvent, verbatim. | Your own fold, or a TransformStream into a shape you already have. |
One projection decides what a browser may see, and the three encoders are handed the result and never the original — so an encoder bug can misrender a field and cannot leak one.
Resume
Every durable frame's id: is the log cursor, <seq> or <seq>.<frame>. Reconnect
with Last-Event-ID and you lose nothing.
An ephemeral frame carries no id: at all, and the SSE specification makes that
load-bearing rather than tidy: a typing preview can never become a resume point, and
the durable agent.message closes the gap. A connection that already carried an
agent.message's deltas is sent only its close; a replay of the same event is sent the
text in full.
Answering an approval
The stream is read-only and the token cannot make it otherwise. An approval request
reaches the browser; the answer goes to your endpoint, which calls
sessions.decide with a real credential. See approvals.
Server-side
For a worker, a job or a CLI, use the typed client instead — no token, no SSE:
for await (const event of client.sessions.stream(id, { after: lastSeq })) { /* … */ }