@plane/sdk/browser
A resuming SSE reader, and one layer per stream format.
No Effect in its public API, and no dependency on ai or a CopilotKit package either
— both are peers, so a customer who wants raw parts does not pull in a client
library for a format they are not using.
subscribe
The primitive: a resuming SSE reader over the projected stream, typed by format.
import { subscribe } from "@plane/sdk/browser"
const sub = subscribe({
format: "ai-sdk", // "ai-sdk" | "ag-ui" | "plane" — required
// A function, not a string: it is called again when a token expires.
tokenSource: () => fetch(`/api/watch/${id}`).then((r) => r.json()),
lastEventId, // optional, to resume from a cursor you held
onChunk: (chunk) => apply(chunk),
onError: (error) => console.warn(error),
signal: controller.signal
})
sub.close()format is required rather than defaulted, because subscribe is the one place that
has to agree with the token about what it is reading: a tokenSource returning a
token pinned to a different format is refused before a connection is opened,
rather than handing you frames you cannot decode.
It 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 — for
ai-sdk and ag-ui only — coalesces delta parts once per animation frame. plane
format skips coalescing: it is the raw log, and a customer piping it through their own
TransformStream owns that decision.
AI SDK
import { PlaneChatTransport, toUIMessageStream } from "@plane/sdk/browser"
import { useChat } from "@ai-sdk/react"
const { messages } = useChat({
transport: new PlaneChatTransport({ tokenSource, sendUrl: "/api/send" })
})sendMessages posts the user's text to your endpoint — which calls
sessions.send with a real credential — and then returns the Plane stream.
reconnectToStream returns the same stream with the cursor it holds, which is exactly
the ChatTransport contract. Resumable streaming with no resumable-stream, no
Redis, and no activeStreamId column.
toUIMessageStream(options) is the same thing as a plain
ReadableStream<UIMessageChunk>, if you are not using useChat.
AG-UI
import { AgUiSubscriber } from "@plane/sdk/browser"
const agent = new AgUiSubscriber({
tokenSource,
onDecide: (toolCallId, approved) => fetch("/api/decide", { method: "POST", … })
})
const off = agent.on("tool", (event) => { … })
agent.close()A wrapper rather than a full CopilotKit adapter: it re-emits AG-UI's own event shape,
so you have something to construct an HttpAgent or your own runtime from without
hand-rolling the reconnect logic.
Framework-free
import { usePlaneSession } from "@plane/sdk/browser"
const { messages, drafts, pendingApprovals, status, usage } = usePlaneSession({
tokenSource,
format: "plane" // optional; the fold works identically whichever format
})It folds the client-safe session events, not the wire chunks — which is why it gives
the same answer for all three formats, and why re-deriving a SessionEvent from a
lossy wire shape has exactly one implementation rather than being copy-pasted into
every app.
Answering an approval
The stream is read-only and the token cannot make it otherwise. An approval reaches
the browser; the answer goes to your endpoint, which calls sessions.decide.