Leader-Agent Orchestration: Coordinating Specialist Sub-Agents in LangGraph
Build the leader (supervisor) pattern as an explicit LangGraph state machine: graph structure, routing, shared vs private state, failure handling and hard stopping conditions for delegated sub-agents.
Published on • October 17, 2026
AI Assistant

Wrap a handful of specialist agents behind one coordinator and you get a system that is easier to reason about - until the coordinator starts looping, a specialist throws, and nobody can say how many steps the run took. Most multi-agent code fails on those two problems: no explicit control flow, and no stopping condition.
The leader pattern fixes both by making the coordinator a node in a graph rather than a Python function that calls other functions. LangGraph is a natural fit here because it already models workflows as state, nodes and edges, and it gives you checkpointing, interrupts and a recursion budget for free. The LangGraph docs have moved from langchain-ai.github.io/langgraph to docs.langchain.com, but the core contract is unchanged.
This post is a practitioner’s walkthrough of the implementation: how to lay out the leader graph, what state the leader sees versus what stays private, how routing actually works in the current API, when delegation pays for itself in model calls, how to contain a specialist that fails or spins, and how the leader differs from agents-as-tools and from swarm-style handoffs.
The leader is a node, not a caller
LangGraph models a workflow with three components: state (a shared data structure), nodes (functions that compute and return updates) and edges (functions that decide what runs next). The docs put it plainly: nodes do the work, edges tell you what to do next. Execution happens in discrete super-steps - nodes that run in parallel belong to the same super-step, and execution halts when no node has anything to process.
That changes the shape of the supervisor. Instead of leader.run() calling researcher.run() in a loop, you get a cycle:
START -> leader --(route: research)--> research --\
leader <--(state update)------------------+
leader --(route: code)----> code ---------/
leader --(route: done)----> synthesizer -> END
The leader reads state and writes a routing decision. Specialists read state and write results. Nothing calls anything directly, so you can inspect, checkpoint and replay any point of the cycle. Nodes are plain Python functions - they may contain an LLM call or just ordinary code - and edges can be static (add_edge) or conditional (add_conditional_edges).
The structural rule worth internalizing: pick one routing mechanism per node. Either the node has static outgoing edges, or it returns dynamic routing. Mixing both means both paths execute and the graph becomes hard to reason about.
Shared state versus private state
State is a schema plus reducers, and the reducers are where leader designs are won or lost. Without a reducer, an update overwrites the previous value. With Annotated[list, add], updates append.
from operator import add
from typing import Annotated, Literal, NotRequired, TypedDict
from langgraph.graph.message import add_messages
from langgraph.managed import RemainingSteps
class LeaderState(TypedDict):
# Shared: everyone reads it, most people write to it
messages: Annotated[list, add_messages]
findings: Annotated[list[str], add]
# Leader-owned control channels: default reducer, last write wins
next: str
task: str
failures: int
last_error: NotRequired[str]
# Managed by LangGraph, not by your nodes
remaining_steps: RemainingSteps
Three habits keep this from roting:
- Control channels use the default reducer.
next,taskandfailuresare scalars that the leader rewrites every turn. Giving them a merging reducer is a classic bug: a retry that returns{"errors": []}does not clear anything, it merges an empty list into the accumulated one. LangGraph ships anOverwritetype for exactly this case. - Accumulators merge.
findingsis written by several specialists and must never be overwritten by the one that ran last. - Nothing sensitive leaks by accident. Input, output and private schemas constrain what a node reads and what
invokereturns - but they do not redact streaming. If you stream withstream_mode="values", private channels are still emitted. Passoutput_keyswhen you need a narrowed stream.
If the leader needs to hand a specialist a filtered view (the task, the constraints, the last three findings) rather than the whole transcript, the clean way is a subgraph with its own input_schema/output_schema, or an explicit projection function in the node that builds the specialist’s prompt. Dumping state["messages"] into every specialist defeats the point of delegating: context isolation is the main reason the pattern exists.
Routing: conditional edges or Command
You have two mechanisms and they are not interchangeable.
Conditional edges route only:
def route(state: LeaderState) -> str:
return state.get("next", END)
builder.add_conditional_edges("leader", route, {
"research": "research",
"code": "code",
"done": "synthesizer",
})
Command routes and updates in one step. Return it from a node when you want to write state and choose the next node atomically:
from langgraph.types import Command
def leader(state: LeaderState) -> Command[Literal["research", "code", "synthesizer"]]:
decision = router_model.invoke(build_prompt(state))
return Command(update={"next": decision.next, "task": decision.task},
goto=decision.next)
Note the return annotation: Command[Literal[...]] listing every node the leader may jump to. It is required for graph rendering and it tells LangGraph the legal destinations. Command also carries graph (navigate to a parent graph from a subgraph) and resume (supply a value that returns from an interrupt), which is what makes human approval and handoff-style flows work on the same primitive.
A worked leader sketch with structured routing:
from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
from langgraph.graph import START, END, StateGraph
from langgraph.checkpoint.memory import InMemorySaver
class Decision(BaseModel):
next: Literal["research", "code", "done"]
task: str = Field(description="Instruction handed to the specialist")
reason: str
router_model = ChatOpenAI(model="gpt-4o-mini").with_structured_output(Decision)
def leader(state: LeaderState) -> Command[Literal["research", "code", "synthesizer"]]:
# Hard stop: not enough steps left to finish cleanly
if state.get("remaining_steps", 0) < 5:
return Command(goto="synthesizer")
# Business stop: the specialist output is already good enough
if len(state.get("findings", [])) >= 3:
return Command(goto="synthesizer")
# Failure budget exhausted: hand back to the human
if state.get("failures", 0) >= 3:
return Command(goto="escalate")
decision = router_model.invoke([
{"role": "system", "content": ROUTER_PROMPT},
{"role": "user", "content": state["messages"][-1].content},
])
return Command(update={"next": decision.next, "task": decision.task},
goto=decision.next)
def research(state: LeaderState) -> dict:
try:
out = research_agent.invoke({"messages": [{"role": "user", "content": state["task"]}]})
except Exception as exc:
return {"failures": state.get("failures", 0) + 1, "last_error": str(exc)}
return {"findings": [out["messages"][-1].content]}
builder = StateGraph(LeaderState)
builder.add_node("leader", leader)
builder.add_node("research", research)
builder.add_node("code", code)
builder.add_node("synthesizer", synthesize)
builder.add_node("escalate", escalate)
builder.add_edge(START, "leader")
builder.add_edge("research", "leader") # static edge back
builder.add_edge("code", "leader") # static edge back
builder.add_edge("synthesizer", END)
builder.add_edge("escalate", END)
graph = builder.compile(checkpointer=InMemorySaver())
result = graph.invoke(
{"messages": [{"role": "user", "content": "Compare the two pricing models and draft the summary."}]},
{"configurable": {"thread_id": "run-42"}, "recursion_limit": 40},
)
The leader routes dynamically and owns no static edges; workers return plain updates and use a static edge back. Each node uses exactly one mechanism.
When delegation actually pays off
Delegation is not free. The LangChain multi-agent docs count the overhead: on a one-shot request, the subagents (leader + specialists) pattern costs 4 model calls while handoffs, skills and router cost 3, because every result travels back through the coordinator. On a repeat request, stateful patterns drop to 2 calls while the subagents pattern still pays 4 - subagents are stateless by design and re-derive context each time.
Where the leader wins is multi-domain work: comparing Python, JavaScript and Rust for web development scores 5 calls and roughly 9K tokens for subagents and router, versus 7+ calls and 14K+ tokens for handoffs, because only the first two fan out in parallel.
| Situation | Best fit | Why |
|---|---|---|
| One narrow task, fixed flow | Single agent or router | The leader adds a classification call for nothing |
| Two or three domains, parallel fan-out | Leader + specialists | Isolated contexts, parallel super-steps |
| Long conversation, repeat requests | Handoffs / skills | State persists, fewer calls per turn |
| Teams own separate specialists | Leader + dispatch tool | Convention over configuration, clean boundaries |
| Sub-agent must talk to the user | Leader + interrupt | Specialists normally return to the leader, not to the user |
A cheap rule: if the leader’s routing decision is more expensive than the work it routes, you are running a router, not a leader - make it a deterministic function.
Containing failure: a specialist that throws or loops
Two distinct failure modes, two distinct mechanisms.
A specialist that throws. Wrap the specialist invocation in the node and record the failure in state instead of letting the exception escape. The graph keeps running, the leader sees failures climbing, and its next routing decision can divert to escalate. LangGraph checkpoints at super-step boundaries, not mid-function, so on resume the node re-runs from the top of its function - design node bodies to be idempotent (idempotency keys, upserts, read-before-write) so a retry does not double-write. For retries and fault tolerance, LangGraph’s fault-tolerance docs cover retryable versus fatal failures; keep the distinction in your node code rather than retrying everything.
A specialist that loops. Two layers:
- The recursion limit. It caps super-steps for a single execution and raises
GraphRecursionErrorwhen exceeded. Since version 1.0.6 the default is 1000 steps, and it is a standalone config key -{"recursion_limit": 40}at the top level of the config, not insideconfigurable. - Proactive checks. The current step is available as
config["metadata"]["langgraph_step"], and LangGraph ships a managedRemainingStepschannel that counts down to the limit. Reading it in the leader lets you wrap up gracefully instead of crashing at the boundary.
from langgraph.managed import RemainingSteps
from langgraph.errors import GraphRecursionError
class LeaderState(TypedDict):
remaining_steps: RemainingSteps
...
# Reactive fallback around the run
try:
result = graph.invoke(inputs, {"recursion_limit": 40})
except GraphRecursionError:
result = {"messages": ["Step budget exhausted; returning best effort."]}
Proactive handling (route to synthesizer when remaining_steps drops below a threshold) beats reactive handling (catch the error outside) because the graph still reaches a completion node and you still have a coherent answer. Use both: proactive for quality, reactive as the safety net.
For a run that needs a human, interrupt("Approve spending $400 on ads?") pauses the graph inside a node, and you resume with graph.invoke(Command(resume="yes"), config) on the same thread_id. Side effects executed before an interrupt must be idempotent, because the node runs again on resume.
Leader versus agents-as-tools versus swarm handoffs
The three styles differ in who holds control and where state lives:
| Leader graph | Agents-as-tools | Swarm / handoff | |
|---|---|---|---|
| Control | Explicit edges in a graph | One main agent calls others as tool calls | Control transfers between agents |
| State visibility | Shared schema, reducers, private channels | Main agent’s context; subagents stateless by default | Conversation follows the active agent |
| Stopping | Recursion limit, RemainingSteps, explicit END nodes | Tool-call loop of the main agent | Whoever stops talking to the user |
| Failure containment | Failure counter, escalate node, checkpoint resume | Tool error returned to the main agent | Depends on the receiving agent |
| Parallelism | Multiple outgoing edges in one super-step | Parallel tool calls in one turn | Sequential unless explicitly fanned out |
| Inspectability | get_state, stream_mode="updates", checkpoint replay | Trace of tool calls | Trace of handoffs |
Pick the leader when you need the route itself to be auditable and resumable: you want to see that the graph went leader -> research -> leader -> code -> synthesizer, replay it from a checkpoint, and be able to prove the run stopped because a budget was exhausted rather than because the model felt done. Pick agents-as-tools when the main agent is already good at choosing and you just want context isolation. Pick handoffs when the agent that should talk to the user changes as the conversation evolves.
Checklist
- Model the leader as a node with a
Commandreturn type annotated with its legal destinations; never mix static and dynamic routing from the same node. - Separate shared channels (merging reducers) from leader-owned control channels (default reducer, use
Overwriteto clear). - Keep specialist prompts lean: project the state the specialist needs instead of forwarding the whole transcript.
- Give the leader three exits: business stop (enough findings), budget stop (
RemainingSteps), failure stop (escalation counter). - Set
recursion_limitexplicitly as a top-level config key and catchGraphRecursionErroras a last resort. - Wrap specialist calls in try/except inside the node, record
failures, and make node bodies idempotent for checkpoint resume. - Compile with a checkpointer and pass a stable
thread_idso failed runs resume instead of restarting. - Measure model calls per turn before adding a leader; if routing costs more than the work, use a deterministic router.
Sources: LangGraph overview, LangGraph Graph API, Multi-agent patterns, Subagents pattern, LangGraph persistence, LangGraph docs (moved)