Command: Update State and Pick the Next Node in One Return

How a single `Command` object folds a state update and a routing decision together — and the tiny lowering that makes `goto` just another guarded edge.

So far in this series, a node did one job: return a partial state update, and let the graph’s edges decide where to go next. Command collapses those two jobs into one return value. A node returns Command(update={...}, goto="next_node") — it mutates the state and names its own successor, atomically, from inside the node body. This post shows when that’s the right tool, what LangGraph asks of you in exchange, and how goto is implemented as ordinary edge machinery underneath.

The two ways to route

LangGraph gives you two mechanisms for dynamic control flow. The older one is the conditional edge: you attach a router function to a node, and after the node runs, the framework calls the router with the resulting state to pick the destination.

from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END, add_messages

class State(TypedDict):
    messages: Annotated[list, add_messages]
    tier: str

def classify(state: State) -> dict:
    return {"tier": "gold" if is_vip(state) else "standard"}

def route(state: State) -> str:          # a separate router
    return state["tier"]

builder = StateGraph(State)
builder.add_node("classify", classify)
builder.add_conditional_edges("classify", route, {"gold": "gold", "standard": "standard"})

Here the routing decision (tier) is computed in classify, stashed in state, then re-read by route. The logic is split across two functions and the decision has to survive a round-trip through the state dict.

Command lets the node that made the decision act on it directly:

from langgraph.types import Command
from typing import Literal

def classify(state: State) -> Command[Literal["gold", "standard"]]:
    tier = "gold" if is_vip(state) else "standard"
    return Command(
        update={"tier": tier, "messages": [("system", f"classified as {tier}")]},
        goto=tier,          # route to "gold" or "standard"
    )

builder.add_node("classify", classify)   # no conditional edge needed

One function. No router. No intermediate channel whose only purpose is to carry a decision to a router that recomputes what the node already knew.

When Command beats a conditional edge

Reach for Command when:

Stick with conditional edges when routing is a pure function of state that several nodes share, or when you want the branching logic visible and separate from node bodies. Neither is “better”; Command trades a little locality of control for less plumbing.

The one requirement: declare your targets

Because goto is chosen at runtime, the graph compiler can’t statically see where a Command node might send control. LangGraph asks you to declare the possible targets so the graph topology (and its diagram, and its validation) stays complete. In modern LangGraph that’s the return annotation Command[Literal["gold", "standard"]]; you can also pass it explicitly. In a minimal StateGraph you can make this an argument on add_node:

g.add_node("classify", classify, ends=["gold", "standard"])
g.add_node("gold", gold, ends=[END])
g.add_node("standard", standard, ends=[END])

ends is the list of nodes this node’s goto is allowed to reach (including END). If you goto somewhere you didn’t declare, that’s a graph-construction error, not a mysterious runtime dead end.

How goto is lowered

Here’s the satisfying part: Command.goto is not a new execution primitive. It’s conditional edges in disguise. A ~30-line StateGraph makes the trick obvious.

When a node returns a Command, the runner splits it into the update and the target, reduces the update into the state exactly as it would for a plain dict, and then tags a private routing channel with the chosen target:

_GOTO = "__goto__"     # a private channel, hidden from the caller

# inside the node runner:
if isinstance(result, Command):
    update, goto = result.update or {}, result.goto
else:
    update, goto = result or {}, None

new_state = _reduce(channels, state, update)   # normal reduction
if goto is not None:
    new_state[_GOTO] = goto                    # stamp the chosen target

Then, at compile time, for each declared target the compiler emits one ordinary edge guarded by a condition that checks the private channel:

for source, targets in command_ends.items():
    for target in targets:
        builder.add_edge(
            source, target,
            condition=lambda state, t=target: state.get(_GOTO) == t,
        )

So add_node("classify", classify, ends=["gold", "standard"]) compiles into two guarded edges: classify → gold fires only when __goto__ == "gold", and classify → standard fires only when __goto__ == "standard". The node “picks” a successor simply by writing a value that exactly one edge’s guard is watching for. The private channel is stripped before the final state is returned, so callers never see __goto__.

Why it works this way: conditional edges are already the general mechanism for “look at state, choose an edge.” Command.goto is just the same mechanism keyed on a reserved channel that the node itself writes, instead of an external router reading application state. Same runtime, less ceremony — which is exactly why a from-scratch replica needs almost no new code to support it.

A real handoff: supervisor routing to workers

The pattern that makes Command shine is a supervisor dispatching to one of several workers:

from langgraph.types import Command
from typing import Literal

def supervisor(state: State) -> Command[Literal["researcher", "writer", END]]:
    decision = pick_next_worker(state)     # e.g. an LLM returns "researcher"
    if decision == "done":
        return Command(goto=END)
    return Command(
        update={"messages": [("system", f"routing to {decision}")]},
        goto=decision,
    )

def researcher(state: State) -> Command[Literal["supervisor"]]:
    return Command(update={"messages": do_research(state)}, goto="supervisor")

def writer(state: State) -> Command[Literal["supervisor"]]:
    return Command(update={"messages": draft(state)}, goto="supervisor")

Each worker hands control back to the supervisor with goto="supervisor", and the supervisor loops until it decides to goto=END. The entire control graph — dispatch, work, return, terminate — lives inside the node functions as data. No conditional-edge wiring is needed beyond add_edge(START, "supervisor"), because every hop is a Command. That’s the shape of most LangGraph multi-agent systems.

Mental model

Think of goto as the node writing a single word onto a hidden slip of paper, and the graph having pre-posted one guard per possible word. The node doesn’t “call” the next node; it names it, and the machinery you already have — reducers plus guarded edges — carries the flow. Command is a convenience layer over conditional edges, not a parallel universe. Understanding that keeps its behavior predictable: an update reduces like any update, and a goto fires like any conditional edge. See the official LangGraph low-level concepts and multi-agent guides for the full Command API.


Next in the series: streaming — how stream(mode="values" | "updates") turns each node step into an observable event without changing a line of node code.

Sources & References

Command(update=..., goto=...) and declaring node ends
How Command relates to conditional edges