# Streaming (SSE)

Streaming is what makes an agent UI feel alive: tokens appear as the model writes them, model
**thinking** streams into a collapsible panel, tool calls and their **structured results** show up as
they happen, and a paused run's **interrupt** surfaces for approval. skein-js delivers all of it over
one transport — **Server-Sent Events (SSE)** — so the standard clients ([`useStream`](./react-sdk.md),
the vanilla SDK, Agent Chat UI) render a rich conversation against a skein-js server with only a URL
change. The flagship [`chat-app`](https://github.com/skein-js/skein-js/tree/main/examples/chat-app) example wires the full experience end to end;
[`react-usestream`](https://github.com/skein-js/skein-js/tree/main/examples/react-usestream) is a minimal frontend example.

Under the hood, skein-js maps LangGraph.js **stream modes** onto Agent Protocol SSE. This one
transport powers the `/runs/stream` endpoint, joining an in-flight run (`/runs/{id}/stream`), and
thread-scoped streaming (`/threads/{id}/stream`).

Reference: LangGraph streaming — <https://docs.langchain.com/oss/javascript/langgraph/streaming>

## LangGraph.js stream modes

A `CompiledStateGraph.stream(input, { streamMode })` can emit any combination of:

| Mode             | Emits                                             |
| ---------------- | ------------------------------------------------- |
| `values`         | Full state after each step                        |
| `updates`        | State deltas per node (**default**)               |
| `messages`       | Complete messages                                 |
| `messages-tuple` | Message chunk + metadata tuples (token streaming) |
| `custom`         | User-emitted custom events                        |
| `events`         | Fine-grained execution events                     |
| `debug`          | Detailed debug info                               |

Multiple modes can be requested at once; skein-js preserves that. When `events` is among the
requested modes the run engine drives the graph via LangGraph's `streamEvents` (v2) and emits each
event as an `events` frame (co-requested modes like `values` still stream alongside); otherwise it
uses `graph.stream`.

## Mapping to Agent Protocol SSE

Each LangGraph stream item becomes an SSE frame:

```
event: <mode>            # e.g. messages, updates, values, custom
id: <monotonic-seq>      # per-run sequence for replay/reconnect
data: <json payload>
```

- **Event id sequencing** — each run assigns monotonically increasing ids so a reconnecting
  client can resume via `Last-Event-ID` while the requested frames are retained. See
  [joining and replay](#joining-and-cross-instance-fan-out) for backend limits.
- **Terminal frames** — a final `event: end` (or `error`) closes the stream with the run's
  status. An `error` frame's payload is a `RunError` — `{ error, message, name, cause?, errors? }`,
  plus `stack` when the server sets `exposeErrorStacks`. See
  [errors-and-logging.md](./errors-and-logging.md).
- **Idle heartbeats** — after 15 seconds without a frame, the server sends an ignorable SSE comment
  (`: heartbeat`). Configure your proxy and load-balancer timeouts for the expected run duration;
  heartbeats do not override a platform's total request timeout. Custom adapters can set
  `toSseEvents(..., { heartbeatMs })`; `0` disables heartbeats.

## Subgraphs and subagents

A graph that runs another graph — a compiled subgraph added as a node, or a graph invoked from a
tool, which is how [Deep Agents](./recipes/deep-agents.md) runs a subagent — streams only its own
frames by default. Set `stream_subgraphs: true` on the run (`streamSubgraphs: true` in the JS SDK) to
stream the inner graphs' frames too. Each is named after the mode and the namespace it came from:

```
event: updates                                      # the run's own graph
event: updates|tools:6d1f…                          # a subagent run from a tool call
event: messages|tools:6d1f…|model_request:9a2c…     # that subagent's model tokens
```

These are LangGraph Platform's names; `@langchain/langgraph-sdk` splits on `|` to tell supervisor
and subagent streams apart. Joining a run (`Last-Event-ID` included) replays them unchanged, and a
background or cron run keeps the flag.

Without the flag you are not blind to subgraphs: `messages` mode already carries their model tokens
(each tuple's metadata holds `langgraph_checkpoint_ns`), and `events` mode carries every nested event.
What the flag adds is their `updates`, `values` and `custom` frames, and names a client can route on.
With it on, the run's own model tokens are namespaced too (`messages|model_request:…`), as upstream —
the root graph's frames are the ones with no `|`.

Inside Skein, `RunFrame.event` remains the bare mode and `RunFrame.namespace` carries the optional
subgraph path. Root frames omit the namespace. Event buses preserve both fields; SSE serialization
combines them into the upstream event name shown above. Existing switches over `RunFrame.event`
continue to work.

Declared replies keep their existing last-write-wins behavior, including nested custom writes that
LangGraph propagates. The client's `stream_subgraphs` setting does not change which reply is delivered.

## Slow clients and backpressure

A stream is only as fast as the client reading it, and skein paces itself accordingly. Every adapter's
write loop honors the response stream's backpressure signal: when the socket's buffer is full it waits
for `drain` before pulling the next frame, rather than queueing whatever the graph produces.

That matters because the alternative is unbounded. A client on a bad connection — a phone on mobile
data, a buffering reverse proxy — that reads more slowly than the graph writes would otherwise be
served entirely out of the server's memory, one full copy of the stream per connection.

Measured in [`packages/bench`](https://github.com/skein-js/skein-js/tree/main/packages/bench) on the `slow-client` scenario (clients reading at
~25 fps against a 500 fps graph, ~2 MB per stream):

| Concurrent slow streams | Unflushed server-side buffer | Per streaming connection |
| ----------------------- | ---------------------------- | ------------------------ |
| 50, before              | 62.9 MB                      | ~1.26 MB                 |
| 100, before             | 125.5 MB                     | ~1.26 MB                 |
| 50, after               | 3.3 MB                       | ~67 KB                   |
| 100, after              | 6.5 MB                       | ~65 KB                   |

Before, the per-connection cost was the whole stream, so total memory grew with both the number of
clients **and** the length of each run. After, it is a constant close to the socket's own 64 KB
high-water mark: still linear in connection count, as it must be, but no longer proportional to how
much the graph produces.

Two consequences worth knowing:

- **The graph does not slow down.** The run engine publishes into the event bus and the write loop
  reads from it, so pacing the reader changes how fast frames leave the bus, not how fast they enter
  it. A slow client cannot stall the run, or any other client's stream.
- **Backpressure itself never discards.** It delays delivery; a slow client receives every frame, just
  later. What it does is move the queue from the socket into the event bus — and the bus is bounded, so a
  client slow enough to exceed that bound has its stream **ended** rather than being left hanging.

  What happens next differs by bus. On **Redis** the frames are still in the durable stream, so
  reconnecting with `Last-Event-ID` replays them: nothing is lost. On the **in-memory bus** the stream
  ended precisely _because_ the frames were evicted to stay under
  `SKEIN_MEMORY_BUS_MAX_FRAMES_PER_RUN`, and that buffer is also the replay log — so a reconnect resumes
  from what survives, with a gap. See
  [performance.md](./performance.md#streaming-backpressure-drops-and-recovery) for the bounds, and why
  that asymmetry is a reason to run Redis for unreliable clients.

The Next.js App Router adapter gets this for free: it maps frames onto a `ReadableStream` whose `pull`
is demand-driven by the platform.

## Joining and cross-instance fan-out

- `GET /runs/{run_id}/stream` lets a late client join a run already in progress.
- When a run executes on a **different** worker than the one holding the client connection,
  [`@skein-js/redis`](./runs-and-redis.md) pub/sub fans the frames across instances so the join
  still works. In single-process `skein dev`, an in-memory event bus is used instead.
- The [SQLite local assembly](./local-deployment.md) also uses a process-local event bus. Records
  and checkpoints survive reopening, but live token frames do not. Use persisted state/history to
  restore the UI after restart; SQLite does not provide cross-instance fan-out.

## Why SSE is enough (no WebSocket in v1)

The entire LangChain client surface — the vanilla SDK, the [`useStream`](./react-sdk.md)
React hook, and Agent Chat UI — consumes **SSE**. The protocol's optional WebSocket upgrade
buys bidirectional framing we don't need for v1, so it is deferred (see
[roadmap.md](./roadmap.md)). **Deferring WebSocket does not affect the React SDK.**

## Thread event subscriptions

The thread protocol separates execution from observation. Submit `{ id, method: "run.start",
params: { assistant_id, input } }` to `/threads/{id}/commands`, then observe
`POST /threads/{id}/stream/events` with `{ channels: ["values", "messages", "lifecycle", "input"] }`.
Reopening that subscription executes nothing. Commands return JSON envelopes; legacy command bodies
retain their SSE response. The LangGraph binding uses the public `streamEvents(..., { version: "v3" })`
path for protocol runs, including graph-defined transformers and custom channels.

Subscriptions stay open across turns and receive idle heartbeat comments. Channel, namespace-prefix
and depth filters are reader-specific; static namespace segments also match their runtime `:task-id`
suffixes. The bus assigns sequence numbers before filtering, so reconnect cursors work across runs.
Request `since` to replay after a cursor; an invalid retained-history boundary requires state
restoration and a fresh subscription. With no cursor, the current state is the initial baseline.

`MemoryThreadEventBus` bounds each payload history to 10,000 entries, retaining at most 50 payload
histories. `RedisThreadEventBus` uses capped streams (approximately 10,000 entries) with a one-hour
payload expiry. Sequence and deletion-generation records survive payload expiry. Deleting a thread
clears payloads and closes its readers; execution captures its generation so a late publisher cannot
repopulate history after deletion or ID reuse. Release the Redis bus with `dispose()` when assembling
infrastructure by hand; runtime assemblers own that cleanup automatically.

Legacy `GET /threads/{id}/stream` joins one run by default. Pass explicit `stream_mode` to follow
events across turns. The official categories are `run_modes` (run frames plus completion metadata),
`lifecycle` (start/end `metadata` events), and `state_update` (`{ thread_id, state }` checkpoint
snapshots after execution or direct state updates). Recorded snapshots are replayed without
substituting a later thread state. Individual frame modes and optional `thread_state` thread-row
events remain available. The stream starts with live events unless
`Last-Event-ID` resumes after an earlier one. This default differs from Agent Server;
see the [compatibility matrix](./langgraph-compatibility.md) for the verification scope.

A subscription without `since` replays retained events so late selectors can recover custom
channels and media, then receives a current state baseline and live events. An explicit `since`
resumes after that publication sequence. Sequences span runs; payload expiry does not reset them.
Expired or future explicit cursors return `thread_cursor_expired` and require state restoration.
Deleting and recreating a thread ID does not make old events part of the new conversation. SQLite's event bus is process-local;
its state and run records remain durable across restart.
