Plane docs
Guides

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

formatWhat it emitsFor
ai-sdkVercel AI SDK UIMessageChunks. The default.useChat, and anything already speaking that shape.
ag-uiAG-UI events.CopilotKit, or your own AG-UI runtime.
planeThe 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 })) { /* … */ }

On this page