What a run emits while it runs
Stream modes are a taxonomy of intermediate truth — updates, values, messages, custom, checkpoints, tasks — and picking the wrong one is why an agent UI either shows nothing for forty seconds or floods the screen with data no user asked for.
The flight recommender waits and then answers. That is defensible for a product where the answer arrives in four seconds. Run the same design against a document-extraction graph that takes forty and you have built a screen whose only content, for the entire time anyone is looking at it, is a claim that something is happening.
The interesting part is that the graph is not silent during those forty seconds. It is emitting continuously, in several different shapes at once, and the design question is not “can I show progress” but “which of these several truths am I subscribing to.” Picking wrong in one direction gives you the spinner back. Picking wrong in the other floods a broker with the agent’s internal monologue.
The modes, and what each one is actually good for
The JavaScript streaming documentation lists six modes. Verbatim descriptions, with the surface each one is for:
| Mode | Emits | Use it for |
|---|---|---|
values | “Full state after each step.” | A view that always reflects the whole run. Simplest to render, heaviest on the wire. |
updates | “State updates after each step. Multiple updates in the same step are streamed separately.” | Step-by-step progress keyed by which node produced it. The default choice for an activity view. |
messages | “2-tuples of (LLM token, metadata) from LLM calls.” | Token-by-token text. Chat surfaces, drafting surfaces. |
custom | “Custom data emitted from nodes via the writer config parameter.” | Domain progress. The one that carries “page 3 of 14.” |
tools | “Tool-call lifecycle events (on_tool_start, on_tool_event, on_tool_end, on_tool_error).” | Showing what the agent is reaching for, and when a tool failed. |
debug | “All available info throughout graph execution.” | Your own debugging. Not a user-facing surface. |
Source: LangChain, Streaming (JavaScript) — vendor documenting its own product, fetched 2026-09-02.
One caution that matters more than the table does: this list is language-specific and it moves. tools is present in the JavaScript documentation and the Python side has modes the JavaScript side does not. Worse, LangGraph’s older generated reference site still publishes a different mode union — one that includes modes the current documentation does not list, and omits tools. Both pages are live. Only one is current. Check the version you have installed rather than the first search result, and treat this as the standing condition of the field rather than an annoyance: on the Vista job you will be reading docs for framework versions nobody has written a blog post about yet.
The chunk shapes, which is where the work is
updates yields an object keyed by node name, whose value is that node’s state update:
for await (const chunk of await graph.stream(inputs, { streamMode: "updates" })) {
for (const [nodeName, state] of Object.entries(chunk)) {
console.log(`Node ${nodeName} updated:`, state);
}
}Two things to notice. The node name arrives with the data, which is what lets you drive a per-node activity list without inventing a parallel step registry. And the docs are explicit that multiple updates in the same step stream separately — a fanned-out superstep does not arrive pre-merged, so if your UI wants one row per node, this shape already gives it to you, and if it wants one row per superstep, you are doing the grouping.
Also: these are updates, in the sense the state lesson drew a line under. On an accumulating channel the chunk carries the increment, not the accumulated value. Assign it into local state and the transcript collapses to one entry.
messages yields a two-tuple, destructured directly:
for await (const [messageChunk, metadata] of await graph.stream(
{ topic: "ice cream" },
{ streamMode: "messages" }
)) {
if (messageChunk.content) {
console.log(messageChunk.content + "|");
}
}The metadata half is the useful one and the half people drop. It carries langgraph_node and tags, which is how you show tokens from the drafting node and not from the classifier that happens to use the same model. A graph with three model calls streaming into one undifferentiated text area is a real bug that looks like a design choice.
And to take more than one at a time, pass an array — each yielded value becomes a [mode, chunk] tuple:
for await (const [mode, chunk] of await graph.stream(inputs, {
streamMode: ["updates", "custom"],
})) {
console.log(mode, chunk);
}Nothing tells you “page 3 of 14” unless a node says so
This is the finding worth taking to a backend engineer. None of the built-in modes know anything about your domain. updates knows a node called extractFields ran. It does not know that node is on its third page of fourteen, because that fact exists only inside the node’s loop and nothing exports it.
The mechanism for exporting it is the writer on the config passed as a node’s second argument — typed as (chunk: unknown) => void, described in the reference as the callback for sending custom data chunks via the custom stream mode:
const node: GraphNode<typeof State> = async (state, config) => {
config.writer({ custom_key: "Generating custom data inside node" });
return { answer: "some data" };
};So “show the broker which page we are on” is not a UI ticket you can complete alone. It is a request for a config.writer call inside a node, plus an agreed payload shape, plus a client subscribed to custom. Recognizing that early — and arriving with the payload shape already drafted rather than with a Figma frame implying it — is most of what makes a UI architect useful on an agent team rather than downstream of one.
Where people get burned
The custom channel is unstructured by design, which means it rots by default. Two nodes will invent two payload shapes, a third will send a bare string, and six months later the client has a switch nobody can safely change. If you are the person who cares what the UI renders, own the schema for this channel on day one. Nobody else will.
Interrupts arrive in the same stream
A pause is not a separate transport. When a graph hits an interrupt, the payload comes back to the caller under an __interrupt__ key, as an array — the docs’ printed shape is entries of { id, value }, where value is exactly what the node passed to interrupt():
{
vals: [],
__interrupt__: [
{ id: '...', value: 'question_a' },
{ id: '...', value: 'question_b' }
]
}Note the plural. Parallel branches can each be waiting on their own question at the same time, which means “the run is paused” is not necessarily one decision to render. Hold that thought; it is the interrupt module’s problem, and it is a harder design problem than it first looks.
One more thing to know exists rather than to learn now: alongside the mode-based API there is a newer event-streaming interface, streamEvents at version: "v3", which the LangChain streaming documentation currently recommends for new applications. Instead of parsing mode tuples it hands back typed projections you iterate independently — stream.messages, stream.values, stream.toolCalls, and for our purposes stream.interrupted and stream.interrupts. Same underlying events, a shape closer to how a client actually wants to consume them. The modes are still documented and still work; know that both exist so you can tell which one a codebase is using.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
Three surfaces: a per-node activity list, a live draft of a summary, and “page 3 of 14”. Which mode does each one, and why is it not one mode for all three?
Check your answer
Activity list: updates. The chunk is keyed by node name, which is precisely the row identity the list needs, and it fires once per node update rather than continuously.
Live draft: messages, filtered by metadata.langgraph_node so only the drafting model’s tokens land in that panel. Without the filter every model in the graph types into the same box.
Page 3 of 14: custom, and only if a node emits it via config.writer. This one is not available to you by choosing a subscription; it has to be produced.
They are three modes because they are three different kinds of fact: graph mechanics, model output, and domain progress. Passing an array of modes gets you all three on one stream as [mode, chunk] tuples — which is the normal shape of a real agent client, not an exotic case.
Hands on
Watch a run instead of imagining it
Done when: ARTIFACT.md’s “what a run emits” section contains real captured output from at least two stream modes on a graph you ran, plus one sentence per surface in your inventory naming the mode that feeds it — and at least one surface marked as needing a node-side change.
- Build the smallest graph that is not a toy: three nodes, one conditional edge, one node that loops over a list. It can be the parser skeleton with the model calls stubbed out — the point is the shape of the emission, not the quality of the extraction.
- Run it with
streamMode: "updates"and paste the raw chunks intoARTIFACT.mdunedited. Do not tidy them. The gap between what you expected and what arrived is the finding, and tidying erases it. - Run it again with
["updates", "custom"]after adding oneconfig.writercall inside the looping node that reports its position. Paste that output too. Note what the tuple shape did to your consuming code. - Go back to the surface inventory table from the nodes-and-edges lesson. Write the mode that feeds each row. Mark every row that cannot be fed by any built-in mode — those are the ones requiring a node-side change, and that list is a backend conversation, not a design task.
- Draft the payload schema for your
customchannel. One discriminated union, written down, before there are three of them. - Bring the captured output into the chat, especially anything that surprised you. That is where the real lesson is, and it is usually about ordering or about an update you assumed was merged.
What this does not cover
Everything here has assumed the run finishes on its own. The __interrupt__ shape appeared only as a chunk in a stream, which badly undersells it — a pause is a durable state the run sits in, addressable by anyone who has the thread, not an event that flew past. That is the whole interrupt module, starting with the interrupt-and-resume lesson, and it is where your review gate finally becomes an agent primitive.
Nothing here covers reading a run that is not currently streaming to you either: a second reviewer opening a link, a page refreshed after a crash, a run started yesterday. That reads from checkpoints rather than from a stream, and it is the checkpointers-and-threads lesson. This is also the end of module one, which means you now have the four things the reading pass produces — execution model, routing, state contract, emission — and the primitives map in the reference section is the one-page version to keep open while you use them.
Read this next — primary source
Streaming (LangGraph, JavaScript)LangChain — docs.langchain.com, JavaScript docs, fetched 2026-09-02. Vendor documenting its own product
This lesson takes the mode taxonomy and the chunk shapes. The page carries the filtering machinery that makes any of it usable at scale — selecting model output by node name or by tag, suppressing a model entirely, and the subgraph namespacing that decides whether a nested graph’s output shows up in your stream at all. On a real product, filtering is most of the work, and the page is where the available hooks are enumerated.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.