agentsope/agentsop-streaming-output
| Enhancement-overlay decision protocol for STREAMING the output of long-running LLM / agent runs from the *backend*, not just wiring a typing animation in the UI. Activates when a coder agent must stream final tokens to a chat client, surface intermediate agent steps (which tool, which node, partial reasoning), emit custom tool-progress events, choose a transport (SSE vs WebSocket), or decide what to do when the client disconnects mid-stream. The langchain / langgraph skills mention stream modes but stop at "you can stream"; this skill encodes *what to stream, over what transport, and how to fail safely*.
npx skills add https://github.com/agentsope/SkillAlchemy --skill agentsop-streaming-output
> Source posture: every non-trivial claim is cited inline. Short tags like
> [lg/stream], [lc/astream-events], [oai/stream], [anthropic/stream],
> [mdn/sse] resolve against references/R1-source-evidence.md.
>
> This is an ENHANCE overlay: it sits on top of [[agentsop-langgraph]] (which
> names the four stream modes but treats streaming as one of ten operations) and
> [[langchain]]. Read those for the orchestration; read this for the
> streaming SOP. Cross-link: [[agentsop-langgraph]] OP-8.
Activate when any of these fire:
docs, multi-tool chain) and the user is waiting — perceived latency, not
total latency, is the product metric.
"show which tool the agent is running", or "show the chain of thought".
(stream intermediate steps: node entered, tool called, partial state) OR a
long task surface (stream custom progress like "embedded 40/200 docs").
chunked HTTP, and handle client disconnect / cancellation cleanly.
graph.stream(...) / astream_events / OpenAI stream=True /Anthropic client.messages.stream and need to know *which mode* and *what to
forward to the client*.
Do not activate for: a single fast (<1s) completion, a batch/offline job with
no waiting human, or a pure front-end animation question (that's CSS, not a backend
SOP). Streaming a 300ms call adds protocol overhead for zero UX gain — see
*反模式*.
Stream what the user needs to *see*, not everything the engine *emits*. A
backend stream is a curated projection of the run's internal event firehose onto
exactly three audiences:
character output of the *final* assistant message. In LangGraph this is
stream_mode="messages" (LLM tokens + metadata); in raw SDKs it's
stream=True / .messages.stream [lg/stream] [oai/stream]
[anthropic/stream]. They do not want to see tool JSON or scratch nodes.
watching an agent work wants "entered node planner", "calling tool
search", "got 5 results" — the state diffs *between* steps. LangGraph:
stream_mode="updates" (per-node diffs) [lg/stream]. LangChain LCEL:
astream_events (a typed event stream: on_chat_model_stream,
on_tool_start, on_tool_end) [lc/astream-events].
(a loop, a long embed, a download) is invisible to the framework's automatic
events. You must emit progress yourself: LangGraph stream_mode="custom"
via get_stream_writer() [lg/stream]; LCEL via custom callback / dispatched
events [lc/astream-events].
The load-bearing insight from the LangGraph docs: **stream modes are composable —
pass a list** (stream_mode=["messages","updates","custom"]) and demultiplex on
the client by the tuple tag [lg/stream]. So the real design question is never
"can I stream" but **"which projection(s) does this surface need, and how do I tag
them on one wire?"**
Second axiom: a stream is a contract with a client that can vanish. Networks
drop, users close tabs, browsers cap connections. The backend must decide, *up
front*, whether a disconnect should cancel the run (stop burning tokens) or
detach and let it finish (so a reconnect can replay). That decision is part of
the design, not an afterthought — see *困境 Case 2*.
Walk top-down. Each step has a gate.
Gate: is a human waiting on a run that takes >~1–2s? If no (batch job, sub-
second call), don't stream — return the whole payload. Streaming a fast call
adds SSE/WebSocket framing, reconnect logic, and partial-parse bugs for no UX win
[mdn/sse]. Exit here for fast paths.
Map the surface to one (or more) of the three audiences:
| Surface | Primary projection | LangGraph mode | LangChain |
|---|---|---|---|
| Chat / prose | final tokens | messages | astream_events → on_chat_model_stream |
| Agent inspector / dev UI | step updates | updates | astream_events (on_tool_*, on_chain_*) |
| Full-state replay / resume | snapshots | values | n/a (rebuild from events) |
| Long in-tool work | custom progress | custom | dispatched custom events |
| Debug everything | raw firehose | debug | astream_events (all) |
values emits the full state after each step (heavy, good for resume);
updates emits only the diff (light, good for live UI) [lg/stream]. Default
a chat agent to ["messages","updates"] and add "custom" only when a tool has
internal progress worth surfacing [lg/stream] (= [[agentsop-langgraph]] OP-8).
Gate questions: does the client only *receive* (server→client), or also need to
*send* mid-stream (interrupt, steer)?
text/event-stream, auto-reconnect + Last-Event-ID built into the browser
EventSource [mdn/sse]. This is what most "stream the agent" use cases need.
(live cancel, mid-run user input, collaborative). Costs you reconnect logic you
get free with SSE.
are yours, skip HTTP framing and yield the tuples directly.
Use the multi-mode form so one connection carries everything; tag each chunk
so the client routes it:
async for mode, chunk in graph.astream(
inp, stream_mode=["messages", "updates", "custom"], config=cfg):
if mode == "messages":
token, meta = chunk
yield sse("token", token.content) # → append to bubble
elif mode == "updates":
yield sse("step", chunk) # → "running tool X"
elif mode == "custom":
yield sse("progress", chunk) # → progress bar
[lg/stream]. SSE event: field is exactly the demux key; the browser's
EventSource.addEventListener("token"|"step"|"progress", …) splits it client-side
[mdn/sse]. Never interleave two semantic streams on one untagged channel — the
client can't tell a token from a tool name.
For each surface answer: on client disconnect, cancel or detach?
without the client, no resume planned. Wire it to the request's cancellation
signal so the generator is closed and the LLM call aborted [oai/stream].
user may reconnect and wants the result. Pair with a checkpointer
([[agentsop-langgraph]] Step 6) and a resumable event log so reconnect replays via
Last-Event-ID [mdn/sse].
Default for a chat agent: cancel (cheap, stateless). Default for a long
side-effecting pipeline: detach + persist.
(: keep-alive\n\n) every ~15s during long quiet stretches [mdn/sse].
grows. Bound the queue; on overflow either drop intermediate updates (keep
messages) or apply flow control. Tokens are the audience-critical stream;
progress events are droppable.
X-Accel-Buffering: no for nginx) or theproxy batches your tokens and kills the "streaming" feel.
Format: Trigger → Action → Output → Evidence.
graph.astream(inp, stream_mode="messages") → yield each(token, metadata)'s token.content; filter by metadata so you only stream
the *final* node's LLM, not sub-agent chatter. Raw: OpenAI stream=True iterate
chunk.choices[0].delta.content; Anthropic `with client.messages.stream(...) as
s: for t in s.text_stream`.
[lg/stream] messages mode; [oai/stream]; [anthropic/stream].stream_mode="updates" → each chunk is `{node_name:state_diff}; render as a step log. LCEL: astream_events(version="v2")` and
switch on event["event"] (on_tool_start/on_tool_end/on_chain_*).
values.[lg/stream] updates mode; [lc/astream-events].API) the framework can't see.
i/n}); consume on stream_mode="custom"`. LCEL — dispatch a custom event /
callback that astream_events surfaces.
[lg/stream] custom mode + stream writer.stream_mode=["messages","updates","custom"]; map each `(mode,chunk) tuple to a distinct SSE event: name; client addEventListener` per
name (Step 4 snippet).
[lg/stream] (list form returns (mode, chunk) tuples);[mdn/sse] (named events).
Last-Event-ID). Client must push mid-stream (cancel, steer, collaborate) →
WebSocket. Both ends yours / non-HTTP → async generator.
[mdn/sse] (EventSource auto-reconnect, server-push only).generator → upstream LLM/agent call aborts; release resources [oai/stream].
Detach: keep running under a checkpointer, log events with monotonic IDs so a
reconnect replays from Last-Event-ID.
[oai/stream] cancellation; [mdn/sse] Last-Event-ID;[[agentsop-langgraph]] Step 6 (checkpointer for durability).
tokens arrive in clumps not smoothly.
: ping\n\n comments every ~15s; set X-Accel-Buffering: no /disable proxy buffering; flush after each event.
[mdn/sse] (comment lines ignored by client, keep socket warm).fills with sub-agent noise.
messages mode, inspect metadata (langgraph_node, tags) andforward only tokens whose node is the user-facing responder; route the rest to
updates (dev view) or drop.
[lg/stream] (messages chunks carry node metadata for filtering).answer. If you stream messages only, the user stares at a frozen spinner for
40s, then sees text. If you stream updates only, they see "calling tool X" but
the final answer dumps all at once, losing the typing feel.
part for the user; the final prose is the payoff.
(progress during work, prose at the end) [lg/stream].
stream_mode=["updates","messages"]. During tool work, updates chunksdrive a live step list ("Searching… Reading 5 docs… Synthesizing"). When the
final responder node starts emitting, messages tokens stream into the
bubble (OP-4 demux).
messages to the final node only (OP-8) so the tool-call LLMs don'tleak into the answer.
custom progress from inside it (OP-3)so the step list isn't itself frozen.
one connection, no extra round-trips.
"both, tagged, on one wire" — the question is which is primary *when*.**
then closes the tab at second 30. The stream's consumer is gone. Do you kill the
run (and maybe leave a half-booking) or let it complete (burning tokens for a
client that may never return)?
reopen the tab.
by *where in the run* the disconnect happens.
— that's the half-booking risk. Detach: let the current durable step finish
under a checkpointer ([[agentsop-langgraph]] Step 6 / HITL ordering — side
effects in their own committed step).
Last-Event-ID so the user sees the outcome [mdn/sse].
opposite: cancel immediately on disconnect to stop burning tokens
[oai/stream] — that's the cheaper, correct default for chat.
cancel. The policy is chosen by *reversibility and cost*, not by reflex.
reversibility and per-step cost — decide it per surface, before shipping, never
let it default to "whatever the framework does on socket close".**
debug/values firehose to achat UI floods the client with full-state snapshots and sub-agent tokens. Project
to the audience (Step 2); values is heavy by design [lg/stream].
parse bugs for zero perceived-latency gain. Return the whole payload [mdn/sse].
zombie runs that burn tokens after the client is gone, or half-completes side
effects. Decide in Step 5 [oai/stream].
names on the same unnamed wire are unparseable client-side. Tag with SSE event:
/ the (mode, chunk) tuple (OP-4) [lg/stream] [mdn/sse].
messages by nodemetadata (OP-8) [lg/stream].
connection; the user sees a hang, not an error. Ping every ~15s [mdn/sse].
destroys the streaming feel; disable it explicitly (OP-7).
simpler and gives reconnect for free [mdn/sse]. Reserve WS for true
bidirectional needs.
Hard boundaries (streaming is the wrong tool when):
it (structured JSON you parse server-side, content that needs a safety pass) —
stream nothing until validated, or stream into a parser, never raw to the user.
whole response; partial JSON tokens are a parsing hazard, not a feature.
| Concern | LangGraph | LangChain (LCEL) | OpenAI SDK | Anthropic SDK |
|---|---|---|---|---|
| Final tokens | stream_mode="messages" → (token, meta) [lg/stream] | astream_events → on_chat_model_stream [lc/astream-events] | stream=True, iterate delta.content [oai/stream] | client.messages.stream(...), text_stream [anthropic/stream] |
| Intermediate steps | stream_mode="updates" (per-node diff) [lg/stream] | astream_events (on_tool_*, on_chain_*) [lc/astream-events] | manual: detect tool_calls deltas [oai/stream] | manual: handle content_block_* / tool-use events [anthropic/stream] |
| Full state | stream_mode="values" (snapshot) [lg/stream] | rebuild from events | n/a | n/a |
| Custom progress | stream_mode="custom" + get_stream_writer() [lg/stream] | dispatch custom event / callback [lc/astream-events] | hand-rolled out-of-band | hand-rolled out-of-band |
| Mix modes | list form → tagged (mode, chunk) tuples [lg/stream] | one typed event stream, switch on event [lc/astream-events] | one delta stream, branch on field | one event stream, branch on type |
| Granularity | node-level + token-level + custom | event-level (richest typed taxonomy) | token + tool-call deltas | event + token (typed blocks) |
Heuristics:
one demuxable wire; [lg/stream] modes are the cleanest projection model. See
[[agentsop-langgraph]] OP-8 for the orchestration side.
astream_events gives the richest typed event taxonomy(every on_* lifecycle hook); reach for it when you need fine-grained event
routing without a full graph [lc/astream-events].
"steps" yourself from tool-call deltas / content-block events. Choose when you
have no orchestration layer and want zero framework weight [oai/stream]
[anthropic/stream].
Transport is orthogonal to all four: SSE (default, receive-only), WebSocket
(bidirectional), or async generator (internal) wraps any of them. Pick the SDK for
*what to stream*, the transport for *how the client consumes it* [mdn/sse].
Short tags → full sources in references/R1-source-evidence.md:
[lg/stream] = LangGraph streaming concept (values / updates / messages / custom/ debug; multi-mode list; get_stream_writer) — distilled in
[[agentsop-langgraph]] OP-8 + references/R1.
[lc/astream-events] = LangChain LCEL astream_events typed event stream.[oai/stream] = OpenAI streaming (stream=True, deltas, cancellation).[anthropic/stream] = Anthropic Messages streaming (client.messages.stream,text_stream, content-block events).
[mdn/sse] = MDN Server-Sent Events (EventSource, named events, auto-reconnect,Last-Event-ID, comment heartbeats).
Take agentsope/agentsop-streaming-output from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.