TL;DR
Command(update={...}, goto="node") lets a node return its state change and its next destination in one object, replacing the pair of "node returns a dict" plus "separate router function reads that dict and decides". You annotate the return type as Command[Literal["a", "b"]] so the drawn graph and static checks still know the reachable destinations, and you drop add_conditional_edges entirely. This is the natural shape for agent handoffs — a node that decides "this isn't mine, send it to billing" says so directly instead of encoding a hint into state for a router to re-interpret. The tradeoff is real and worth stating up front: routers keep control flow in one readable place, while Command scatters it across node bodies. Reach for Command when the routing decision and the state update are genuinely the same decision; keep routers when several nodes share one routing policy.
Simple explanation + analogy
With a conditional edge, a node and its router are two employees with an awkward division of labour. The node does the work and writes a note — {"request_type": "billing"}. The router reads the note and decides where the ticket goes. The node knew perfectly well where it should go; it was not allowed to say so, so it left a hint and hoped the router agreed.
Command is letting the node write the address on the envelope. It does the work, and in the same breath says "and this goes to billing."
That framing predicts the tradeoff exactly:
- Routers are a switchboard. Everything goes through one operator, so you read one function to know every path out of a node. Change the policy once, and it changes for everyone. But the operator has to reconstruct intent from whatever the node left behind, and the node's real reason is often richer than the hint it can encode.
Commandis a node with the address book. Direct, no translation loss, norequest_typefield existing solely for the router's benefit. But now the routing logic lives in twelve node bodies, and answering "what can reachescalate?" means reading all of them.
Neither is the correct answer in general. The useful test: is the destination a property of the decision this node just made, or a policy that applies to several nodes? A triage node that classified a ticket already knows the destination — that is one decision, and splitting it across two functions creates the request_type field as pure overhead. A retry policy that says "any node whose confidence is below 0.6 goes back to refine" is a policy, and belongs in one router rather than copied into every node.
Diagram
WITH add_conditional_edges — two steps, and a state field that exists for the router
──────────────────────────────────────────────────────────────────────────────────
[triage] ──returns──▶ {"request_type": "billing"} ◀── a hint, not a destination
│ │
│ ▼
└──────────────▶ route(state) reads request_type
│ returns "billing"
▼
mapping {"billing": "billing", ...}
│
▼
[billing]
3 places to read: the node, the router, the mapping.
'request_type' lives in state forever, and is checkpointed forever.
WITH Command — one step, no intermediate field
──────────────────────────────────────────────────────────────────────────────────
[triage] ──returns──▶ Command(update={"trail": ["triage"]}, goto="billing")
│ │
state change destination
└────────┬─────────────────┘
▼
[billing]
1 place to read: the node. Nothing extra in state.
A HANDOFF CHAIN — each node decides its own next hop
──────────────────────────────────────────────────────────────────────────────────
"please refund the double charge"
│
▼
[triage] ──Command(goto="billing")──▶ [billing]
│
│ "I can see a refund; not my authority"
│
└──Command(goto="escalate")──▶ [escalate]
│
▼
END
trail: triage -> billing -> escalate
"my invoice is wrong"
│
▼
[triage] ──Command(goto="billing")──▶ [billing] ──Command(goto=END)──▶ END
trail: triage -> billing
How it works (deep)
1. Command carries two things at once
from langgraph.types import Command
return Command(update={"trail": ["triage"]}, goto="billing")
update is exactly what you would have returned as a dict, merged through reducers as normal. goto is the next node's name, END to finish, or a list of names to fan out. Either field is optional: Command(goto=...) routes without touching state, Command(update=...) updates and follows the normal edge.
2. Annotate the return type, or lose the graph drawing
def triage(state: Ticket) -> Command[Literal["billing", "technical"]]:
At runtime this hint is ignored — goto works without it. What it buys you is that LangGraph reads the Literal to discover the edges for get_graph(), so diagrams, visualisations, and static validation stay accurate. Skip it and your graph still runs but draws as a set of disconnected nodes, which quietly destroys the thing people most rely on for understanding a graph they did not write.
In the runnable example this is verifiable: get_graph().edges reports six conditional edges, all inferred from the Literal hints, with no add_conditional_edges call anywhere.
3. Wiring changes: declare nodes, mostly skip edges
You still add_node everything and still need an entry point. You do not add conditional edges for Command-routed hops:
g.add_edge(START, "triage")
g.add_edge("escalate", END) # escalate returns a plain dict, so it needs a real edge
# no add_conditional_edges anywhere
Nodes returning Command(goto=...) route themselves; nodes returning plain dicts follow their declared edges. Mixing both in one graph is normal and often right — use Command where the decision is local and plain edges where the path is fixed.
4. Why this is the handoff primitive
Multi-agent handoff is the case where the router shape actively fights you. A specialist agent that determines the request is outside its scope has made a rich decision: what it found, why it cannot proceed, and who should take it. With a router it must flatten all of that into a state field, and the router then re-derives the destination from the flattened version — logic in two places, drifting apart as cases accumulate.
With Command the handoff is one statement:
return Command(update={"trail": ["billing"], "note": "needs refund authority"}, goto="escalate")
The decision and its consequence stay together. This is why Command shows up throughout LangGraph's multi-agent documentation, and why prebuilt handoff tools are built on it.
5. Command(graph=Command.PARENT) — routing out of a subgraph
By default goto names a node in the current graph. A subgraph node that needs to redirect the parent uses:
return Command(goto="escalate", graph=Command.PARENT)
Without graph=Command.PARENT, goto="escalate" looks for escalate inside the subgraph and fails. This is the mechanism for a nested specialist to hand control back up, and it is the single most common source of confusing "node not found" errors in composed graphs.
6. Combining with Send
goto accepts a list, including a list of Send objects, so one return can both update state and fan out:
return Command(update={"stage": "scoring"}, goto=[Send("score", {"item": i}) for i in items])
That combination is occasionally exactly right and easy to overuse — it puts state changes, fan-out width, and destinations in a single expression, which becomes hard to read quickly.
7. Where Command stops being the right call
Two clear cases. When several nodes share one routing policy, Command duplicates that policy into each of them and you will eventually update four of five copies. And when the routing decision is not a property of the node's own work — a global retry or budget rule — the node has no business knowing it. Both belong in a router.
There is also a plain readability limit. Past roughly a dozen Command-routed nodes, "what reaches escalate?" needs a grep across the codebase rather than a glance at one function. The type hints and generated diagram mitigate this, and only if you actually wrote the hints.
The math
The interesting quantity is how many places you must read to understand or safely change the routing.
n = nodes that make routing decisions
d = average destinations per node
m = nodes sharing one routing policy
places to read to trace all routing:
conditional edges L_r = n * (node + router + mapping) = 3n reading sites
Command L_c = n * (node) = n reading sites
places to change when ONE shared policy changes:
conditional edges 1 (the shared router)
Command m (every node embedding it)
extra state keys existing only to carry a routing hint:
conditional edges up to n
Command 0
So Command wins on reading sites and state cleanliness by a factor of about three, and loses on shared-policy edits by a factor of m.
Worked example
A support graph with n = 5 routing nodes, d = 2 destinations each, and a retry policy shared by m = 4 of them.
reading sites:
routers L_r = 3 * 5 = 15
Command L_c = 1 * 5 = 5 -> 3x fewer
changing the shared retry threshold:
routers 1 edit
Command 4 edits -> 4x more, and 4 chances to miss one
routing-only state keys (e.g. request_type):
routers up to 5
Command 0
The honest conclusion is the mixed design: Command for the five local classification decisions, one shared router for the retry policy. That reads as 5 sites plus 3 for the router, still well under 15, and the shared threshold stays a single edit. The framework does not force a choice between them, and treating it as an either/or is the actual mistake.
Real code
Ticket triage with handoffs, and not one add_conditional_edges call.
import operator
from typing import Annotated, Literal, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command
class Ticket(TypedDict):
text: str
trail: Annotated[list[str], operator.add]
resolution: str
# Each node returns ONE object carrying both the state update and the next hop.
def triage(state: Ticket) -> Command[Literal["billing", "technical"]]:
t = state["text"].lower()
dest = "billing" if any(w in t for w in ("invoice", "charge", "refund")) else "technical"
return Command(update={"trail": ["triage"]}, goto=dest)
def billing(state: Ticket) -> Command[Literal["escalate", "__end__"]]:
if "refund" in state["text"].lower():
# A handoff: billing decides it cannot settle this and passes the ticket on.
return Command(update={"trail": ["billing"]}, goto="escalate")
return Command(update={"trail": ["billing"], "resolution": "invoice re-sent"}, goto=END)
def technical(state: Ticket) -> Command[Literal["escalate", "__end__"]]:
if "data loss" in state["text"].lower():
return Command(update={"trail": ["technical"]}, goto="escalate")
return Command(update={"trail": ["technical"], "resolution": "restart guide sent"}, goto=END)
def escalate(state: Ticket) -> dict:
return {"trail": ["escalate"], "resolution": "human owner assigned"}
g = StateGraph(Ticket)
for n, f in [("triage", triage), ("billing", billing), ("technical", technical), ("escalate", escalate)]:
g.add_node(n, f)
g.add_edge(START, "triage")
g.add_edge("escalate", END)
# NOTE: no add_conditional_edges anywhere. Command(goto=...) carries the routing.
app = g.compile()
for text in ["my invoice is wrong", "please refund the double charge",
"app crashes on launch", "upgrade caused data loss"]:
out = app.invoke({"text": text, "trail": [], "resolution": ""})
print(f' {" -> ".join(out["trail"]):38s} {out["resolution"]}')
a = app.invoke({"text": "please refund the double charge", "trail": [], "resolution": ""})
b = app.invoke({"text": "my invoice is wrong", "trail": [], "resolution": ""})
assert a["trail"] == ["triage", "billing", "escalate"], a["trail"]
assert b["trail"] == ["triage", "billing"], b["trail"]
assert a["resolution"] == "human owner assigned"
# The routing map is inferred from the Literal type hints, so the drawn graph stays accurate.
edges = app.get_graph().edges
print("edges discovered from type hints:", len([e for e in edges if e.conditional]))
print("OK: state update and control flow travelled together, no router functions")
# Output:
# triage -> billing invoice re-sent
# triage -> billing -> escalate human owner assigned
# triage -> technical restart guide sent
# triage -> technical -> escalate human owner assigned
# edges discovered from type hints: 6
# OK: state update and control flow travelled together, no router functions
Two things to notice. The trail column shows four different paths through the graph produced entirely by node-local decisions — nothing outside the nodes chose any of it, and there is no request_type field anywhere in Ticket because no router needed a hint.
And edges discovered from type hints: 6 is the payoff for annotating returns. Those six conditional edges were never declared; LangGraph read them from Command[Literal[...]]. Delete the annotations and the graph runs identically while get_graph() reports no edges at all — your diagram silently becomes a lie, which is a bad trade for four saved keystrokes.
Real-world example
A clinical retrieval system routes a question to specialist pipelines — breast reconstruction, wound care, oncology — and specialists sometimes discover that a question is not really theirs, or is also someone else's.
The first design used a router. Each specialist wrote {"needs_referral": "wound_care", "reason": "..."} into state, and a router after every specialist read needs_referral and dispatched. It worked for one referral. Then the requirements grew: a specialist that wanted to refer to two others, a specialist that could handle the question but wanted a second opinion recorded, a case where the referral target depended on evidence quality the specialist had just assessed. Each addition put a little more logic into the router, which now needed to re-derive from state facts the specialist had already established, and the two drifted. The bug that finally forced the rewrite was a referral loop — two specialists each writing needs_referral pointing at the other, because neither could see what the other had decided and the router had no memory of the path.
Moving to Command shrank it. A specialist that wants to hand off returns Command(update={"trail": [...], "findings": ...}, goto="wound_care"). Wanting two became goto=["wound_care", "oncology"]. The referral loop was fixed by the specialist itself checking state["trail"] before choosing a destination — trivially expressible in the node, awkward in a router that only saw the latest hint. And needs_referral left the state schema entirely, which mattered more than expected: it had been persisted in every checkpoint of every run, and a stale value from an earlier step had caused its own class of confusing bug.
What did not move to Command was the depth limit. "No more than three specialist hops" applies to all of them, so it stayed one shared check — copying it into five nodes would have guaranteed that a sixth specialist, added later, forgot it.
Interview questions companies actually ask
1. What does Command let a node do that returning a dict does not? [easy]
Specify its own next destination. A dict is only a state update, so the next node comes from a declared edge or a separate router function. Command(update=..., goto=...) carries the state change and the routing decision in one return value, so the node that made the decision expresses it directly.
2. Why annotate the return type as Command[Literal["a","b"]] if it is ignored at runtime? [medium]
LangGraph reads the Literal to infer edges for get_graph(), so diagrams and static validation stay correct. Routing works without it, but the drawn graph loses its edges and appears as disconnected nodes — which breaks the main tool anyone uses to understand an unfamiliar graph.
3. When is a conditional edge still the better choice? [hard]
When one routing policy is shared by several nodes, or when the decision is not a property of the node's own work — a global retry rule, a budget cap, a depth limit. Command would duplicate that policy into every node, so changing it means m edits with m chances to miss one. A router keeps it as a single edit.
4. Can you mix Command and conditional edges in one graph? [medium]
Yes, and it is usually the right design. Nodes returning Command(goto=...) route themselves; nodes returning plain dicts follow their declared edges. Use Command for local decisions and a router for shared policy — treating it as an either/or is the common mistake.
5. How does Command make multi-agent handoff cleaner? [medium]
A handoff is one decision containing what was found, why the agent cannot proceed, and who should take over. A router forces that to be flattened into a state field and then re-derived, putting the logic in two places that drift. Command keeps the update and the destination together, which is why the prebuilt handoff tools are built on it.
6. What does graph=Command.PARENT do? [hard]
Makes goto resolve against the parent graph instead of the current one, so a subgraph node can redirect control in the graph that contains it. Without it, goto="escalate" from inside a subgraph looks for escalate within the subgraph and fails — the usual cause of "node not found" errors in composed graphs.
7. Can one Command update state and fan out at the same time? [hard]
Yes — goto accepts a list, including a list of Send objects, so Command(update={...}, goto=[Send(...), Send(...)]) is valid. It is occasionally exactly right and easy to overuse, since it packs state changes, fan-out width, and destinations into one expression that is hard to read at a glance.
8. What is the maintainability cost of Command-based routing? [medium]
Control flow moves from one readable router into every node body, so answering "what can reach node X?" becomes a search across the codebase rather than reading one function. Roughly three times fewer reading sites to trace a single path, but the global picture is harder — which is what the type annotations and generated diagram are there to recover.
9. Does Command remove anything from your state schema? [medium]
Usually yes: the fields that existed only so a router could read a hint, like request_type or needs_referral. Those are checkpointed on every step and can go stale and cause their own bugs, so deleting them is a real gain beyond tidiness.
When to use / tradeoffs
Reach for Command when:
- The destination is a property of the decision the node just made — classification, triage, handoff.
- You are building multi-agent handoffs where agents pass control to each other.
- A state field exists only to carry a routing hint to a router.
- A subgraph node must redirect control in the parent, via
graph=Command.PARENT.
Do NOT use when:
| Situation | Why it breaks | Use instead |
|---|---|---|
| One policy shared by many nodes | Duplicated into each; m edits to change one rule | A single conditional-edge router |
| Routing depends on global concerns (budget, depth, retries) | The node should not know about them | Router, or a wrapper node |
| The path is fixed | goto adds indirection for no gain | add_edge |
You skip the Literal annotation | Graph draws with no edges; diagrams mislead | Annotate, or use conditional edges |
Many Command nodes and no diagram discipline | "What reaches X?" needs a codebase grep | Routers for the shared parts |
Honest limits. The "3x fewer reading sites" figure counts sites to trace one path and is therefore flattering — it says nothing about the harder question, which is enumerating all paths into a node, and that gets strictly worse with Command. The comparison also assumes routers carry no logic beyond dispatch, whereas real routers accumulate validation and fallbacks that Command would scatter across nodes, so the multiplier is workload-dependent rather than a property of the API. Two limits are not about counting at all. Distributed routing makes cycles easy to create and hard to see — each node decides locally, so no single place can tell you a loop exists, and the referral-loop failure above is the normal outcome rather than an unlucky one; recursion_limit catches it at runtime instead of review time. And the type annotations that recover your diagram are unenforced documentation: nothing fails if a node's goto targets a node absent from its own Literal, so the annotation drifts out of date exactly when the routing is changing fastest, and the diagram is then confidently wrong. Keep the annotations honest or do not trust the picture.
Summary + related articles
Command(update=..., goto=...)returns a state change and a destination together, replacing node-plus-router.- Annotate
Command[Literal[...]]soget_graph()can infer edges — runtime ignores it, your diagram does not. - It is the natural primitive for agent handoffs, and what the prebuilt handoff tools use.
graph=Command.PARENTroutes in the enclosing graph; omitting it is the usual "node not found" in subgraphs.gototakes a list, includingSendobjects, so one return can update state and fan out.- It removes state fields that existed only as routing hints — fields that get checkpointed and go stale.
- Cost: control flow spreads across node bodies, shared policies get duplicated, and cycles become easy to create and hard to see.
- Mixing
Commandwith routers is normal and usually correct.
Related articles in this module (6-6):
- Dynamic Fan-Out with Send — the other way a node influences control flow, for runtime-sized parallelism.
- Subgraphs and Graph Composition — where
Command.PARENTbecomes necessary. - Human-in-the-Loop with interrupt() — the other half of
Command, resuming a paused graph. - Checkpointers, Threads, and Durable Execution — why a stale routing field in state is a real cost.
Related elsewhere:
- LangGraph — conditional edges and routers, the baseline this replaces.
- Multi-Agent Patterns — supervisor and network topologies that handoffs implement.
- Agent Communication — what agents pass to each other when control changes hands.
Sources:
LangGraph reference: Command ·
LangGraph how-to: command ·
LangGraph docs: multi-agent handoffs ·
Verified against langgraph 1.2.8; all output above is from an actual run.