If you have written a LangGraph agent, you have written this loop:
for chunk in graph.stream(inputs, stream_mode="updates"):
node, update = next(iter(chunk.items()))
... # now what type is `update`? whatever the node returned.
It works, and it is also where a lot of streaming code quietly rots. The chunk is a dict; the payload is whatever the node happened to write; and if you asked for more than one mode you got (mode, chunk) tuples you had to branch on by hand. LangGraph 1.2 (May 2026) leaves that API alone and adds two opt-in upgrades on top of it: version="v2" and version="v3". Both are backwards compatible. They solve different problems, and the fastest way to pick is to know what each one changes.
Short version: use version="v2" to keep your current loop and get real types and namespaces with a one-word change; use version="v3" when you are building a UI that has to route tokens, reasoning, and tool calls to different places.
The classic API, and why it frays#
graph.stream(inputs, stream_mode=...) has always been the workhorse. The modes are stable and worth remembering: values (the full state after each step), updates (just the per-node diff), messages (LLM tokens as (message_chunk, metadata) tuples), custom (whatever you emit), and debug. Pass a list and you get a merged stream of (mode, chunk) tuples.
None of that is wrong. The friction is that everything comes back loosely typed. Your editor can't tell you what's in a chunk, subgraph output arrives with no obvious label, and a stream that mixes modes turns your consumer into a pile of if mode == ... branches. That is exactly the surface 1.2 tightens.
v2: same loop, typed parts#
The smallest useful upgrade. Add version="v2" and every item becomes a StreamPart with three attributes: .type (the mode), .ns (the namespace, i.e. which subgraph path it came from), and .data (the payload).
from langgraph.types import UpdatesStreamPart
for part in graph.stream(inputs, stream_mode="updates", version="v2"):
part: UpdatesStreamPart
print(part.type, part.ns, part.data)
The win is the TypedDicts. Each mode ships its own — ValuesStreamPart, UpdatesStreamPart, MessagesStreamPart, CustomStreamPart, CheckpointStreamPart, TasksStreamPart, DebugStreamPart — all importable from langgraph.types. Now your type-checker knows what .data holds, and .ns gives you the subgraph path for free instead of you reconstructing it. Because it is opt-in and GraphOutput still supports the old dict-style access, you can flip one call at a time. If your streaming code already works and you just want it to stop lying to your IDE, this is the entire migration.
v2 doesn't change what you stream — it changes what your type-checker knows about it. That is a one-line diff with real payoff.
v3: projections, and the caller is the pump#
version="v3" is the interesting one. Instead of a single event firehose, graph.stream(inputs, version="v3") returns a GraphRunStream handle (async: AsyncGraphRunStream) that exposes typed projections — separate, per-channel streams you iterate independently:
stream = graph.stream(inputs, version="v3")
for msg in stream.messages: # token/message content blocks
render(msg)
# stream.values -> full state snapshots
# stream.lifecycle -> run/node lifecycle events
# stream.subgraphs -> nested-graph activity
Two things make v3 different in kind, not degree. First, it rides a content-block protocol: token text, model reasoning, and tool-call arguments arrive as distinct, labeled boundaries instead of one blob you have to sniff. If you have ever tried to separate a model's visible answer from its reasoning tokens mid-stream, that boundary being explicit is the feature.
Second, iterating a projection is what drives the graph forward — there is no background thread; your for loop is the pump. That has a sharp consequence: projections are single-consumer. Loop over run.messages twice and it raises, because a second iterator would be fighting the first for the same underlying run. When you genuinely need fan-out, split it explicitly with projection.tee(n); when you want several projections merged in true arrival order, use stream.interleave(...). It is a stricter contract than the old re-iterable dict stream, and it is stricter on purpose: it makes the ordering and ownership of a live agent stream something the type system can hold.
Which one to ship#
- Staying on your current design, want types?
version="v2". One word, backwards compatible, keeps your loop, hands your type-checker the shapes and the subgraph namespace. Do this now; there is almost no reason not to. - Building a streaming UI or agent front end?
version="v3". When tokens, reasoning, and tool calls need to go to three different sinks — a chat bubble, a debug pane, a tool-call inspector — the projection model is the API that was designed for that routing. It is beta, and the single-consumer rule will bite you once before it clicks, but it is the right shape for the job.
None of this forces your hand: the classic stream_mode API is not going anywhere. But the direction of travel is clear. LangGraph spent 1.2 making the runtime more honest — per-node timeouts, node-level error handlers, delta-based checkpoint channels — and typed streaming is the same instinct pointed at the wire. If you picked LangGraph precisely because you wanted to own the graph rather than inherit a loop, typing the stream is the part of that graph you had been leaving untyped. Fix it while it is a one-line change.



