Plane docs
SDK and CLI

@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.

On this page