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, the vanilla SDK, Agent Chat UI) render a rich conversation against a skein-js server with only a URL change. The flagship chat-app example wires the full experience end to end; 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-IDwhile the requested frames are retained. See joining and replay for backend limits. - Terminal frames — a final
event: end(orerror) closes the stream with the run's status. Anerrorframe's payload is aRunError—{ error, message, name, cause?, errors? }, plusstackwhen the server setsexposeErrorStacks. See 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 settoSseEvents(..., { heartbeatMs });0disables 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 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 tokensThese 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 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-IDreplays them: nothing is lost. On the in-memory bus the stream ended precisely because the frames were evicted to stay underSKEIN_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 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}/streamlets 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/redispub/sub fans the frames across instances so the join still works. In single-processskein dev, an in-memory event bus is used instead. - The SQLite local assembly 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 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). 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 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.