LangGraph for Multi-Agent Supervisor Patterns
Why a Single Agent Falls Apart
Every team that builds an agent past the demo stage hits the same wall. You start with one agent, one system prompt, and a handful of tools. It works great for the first use case. Then someone asks it to also handle billing questions. Then research. Then code generation. The system prompt balloons to two thousand words of conditional instructions, the tool list grows past twenty entries, and the model starts picking the wrong tool for the wrong job because it's holding too much context about too many responsibilities at once.
This is not a prompting problem you can fix with a better instruction. It's an architecture problem. A single LLM call with a giant prompt and a giant toolbox degrades in accuracy as both grow — the model has to reason about which of twenty tools applies before it can even start solving the actual task. The fix that keeps showing up in production systems is decomposition: split the giant agent into several small, focused agents, each with a narrow prompt and a small tool list, and put a supervisor in charge of deciding who does what.
LangGraph is built almost specifically for this pattern. It models your application as a graph of nodes and edges over a shared state object, which maps naturally onto "supervisor decides, worker executes, supervisor decides again." In this article we'll build a real supervisor-led multi-agent system step by step — the state schema, the supervisor node, worker nodes, conditional routing, and the failure modes you need to guard against before this goes anywhere near production.
What a Supervisor Pattern Actually Is
The supervisor pattern has one agent — the supervisor — whose only job is routing. It never touches a search index or writes code itself. It reads the conversation state, decides which specialized worker agent should act next, hands off control, and receives the result back. The workers do the actual work: one might be a research agent with web search tools, another a coding agent with a code interpreter, another a writer agent that turns raw findings into prose.
This is different from two patterns people often confuse it with:
- Sequential chains run agents in a fixed order every time. A supervisor decides the order dynamically based on what's actually needed for this specific request.
- Fully decentralized swarms, where any agent can hand off to any other agent directly, give up central control entirely. A supervisor keeps a single point of decision-making, which makes the system much easier to debug and reason about.
The supervisor pattern sits in the middle: dynamic routing, but through one accountable decision-maker. That's the trade-off that makes it popular — you get flexibility without losing the ability to answer "why did the system do that?" by looking at one place.
In LangGraph terms, the supervisor is a node. The workers are nodes. The routing decision is a conditional edge. The shared state is what flows between all of them.
Designing the Shared State
Before writing any node logic, decide what data every agent in the graph needs to see. This is the single most consequential design decision in a LangGraph multi-agent system — get the state schema wrong and every node downstream has to work around it.
A typical supervisor setup needs at minimum: the message history, a field tracking which worker should act next, and a scratchpad for intermediate results that shouldn't necessarily be shown to the end user.
from typing import Annotated, Literal, TypedDict
from langgraph.graph.message import add_messages
class SupervisorState(TypedDict):
messages: Annotated[list, add_messages]
next_agent: str
task_complete: bool
scratchpad: dictadd_messages is a reducer — it tells LangGraph how to merge new messages into the existing list rather than overwriting it. This matters because multiple nodes will append to messages as the graph runs, and without a reducer each node's return value would simply replace the whole list, silently deleting everything a previous agent said.
next_agent is the field the supervisor writes to communicate its routing decision. task_complete is a simple escape hatch so the supervisor can signal "we're done" without needing a separate node just for termination. scratchpad holds working data — retrieved documents, draft code, partial calculations — that workers need to pass to each other but that doesn't belong in the visible chat transcript.
Resist the urge to jam everything into messages. Message lists are for conversational turns. Structured intermediate data belongs in typed fields you can reason about, validate, and inspect without parsing natural language.
Building the Supervisor Node
The supervisor's entire job is: look at the state, pick the next agent, explain the choice for observability. The cleanest way to implement this in LangGraph is structured output — force the LLM to return a value that matches an enum of your worker names, rather than parsing free text and hoping it says "researcher" instead of "the research agent" or "Researcher agent."
from pydantic import BaseModel, Field
from langchain_anthropic import ChatAnthropic
class RouteDecision(BaseModel):
next_agent: Literal["researcher", "coder", "writer", "FINISH"] = Field(
description="Which agent should act next, or FINISH if the task is done"
)
reasoning: str = Field(description="One sentence explaining the routing choice")
llm = ChatAnthropic(model="claude-sonnet-4-5")
router = llm.with_structured_output(RouteDecision)
SUPERVISOR_PROMPT = """You are a supervisor managing three agents:
- researcher: searches the web and gathers facts
- coder: writes and executes code
- writer: turns findings into final prose for the user
Given the conversation so far, decide which agent should act next.
Only choose FINISH when the user's request has been fully answered."""
def supervisor_node(state: SupervisorState) -> dict:
messages = [{"role": "system", "content": SUPERVISOR_PROMPT}] + state["messages"]
decision = router.invoke(messages)
return {
"next_agent": decision.next_agent,
"task_complete": decision.next_agent == "FINISH",
}Notice the supervisor never modifies messages directly — it only sets next_agent and task_complete. That separation matters: the supervisor's routing reasoning is metadata about the process, not content the end user needs to see in the transcript. If you want it visible for debugging, log decision.reasoning separately rather than injecting it into the conversation.
Structured output also gives you something a plain text response doesn't: a hard guarantee that next_agent is one of your four literal values. No regex parsing, no "the model said 'reseracher' with a typo" bugs.
Wiring Worker Nodes
Each worker is a normal LangGraph node — typically a small ReAct-style agent with its own system prompt and a narrow, task-specific tool list. The critical discipline here is that a worker should have as few tools as it needs to do its one job, not the union of every tool in the system.
from langgraph.prebuilt import create_react_agent
researcher_agent = create_react_agent(
model=llm,
tools=[web_search_tool],
prompt="You are a research specialist. Find facts, cite sources, "
"and return concise findings. Do not write final answers for the user.",
)
def researcher_node(state: SupervisorState) -> dict:
result = researcher_agent.invoke({"messages": state["messages"]})
last_message = result["messages"][-1]
return {
"messages": [last_message],
"scratchpad": {**state.get("scratchpad", {}), "research": last_message.content},
}The coder and writer nodes follow the same shape: invoke a specialized sub-agent, extract what changed, return only the delta. Returning only the new message (not the whole history the sub-agent saw) keeps the reducer doing its job correctly and avoids duplicate entries piling up in messages.
One detail worth calling out explicitly in the worker's prompt: tell it what *not* to do. "Do not write final answers for the user" for the researcher prevents scope creep where a worker starts trying to finish the whole task itself instead of staying in its lane and handing back to the supervisor.
Wiring the Graph and Conditional Routing
With the nodes defined, the graph itself is short. The interesting part is the conditional edge that reads next_agent and routes accordingly — this is where the supervisor's decision actually takes effect.
from langgraph.graph import StateGraph, END
def route_from_supervisor(state: SupervisorState) -> str:
if state["task_complete"]:
return END
return state["next_agent"]
builder = StateGraph(SupervisorState)
builder.add_node("supervisor", supervisor_node)
builder.add_node("researcher", researcher_node)
builder.add_node("coder", coder_node)
builder.add_node("writer", writer_node)
builder.set_entry_point("supervisor")
builder.add_conditional_edges(
"supervisor",
route_from_supervisor,
{
"researcher": "researcher",
"coder": "coder",
"writer": "writer",
END: END,
},
)
# Every worker reports back to the supervisor, never to each other directly
for worker in ["researcher", "coder", "writer"]:
builder.add_edge(worker, "supervisor")
graph = builder.compile()That last loop is the architectural signature of the supervisor pattern: every worker edge points back to supervisor, never to another worker. Workers don't hand off to each other directly — that would turn this into a decentralized swarm and you'd lose the single point of control that makes the system debuggable. Every decision about "what happens next" flows through one node, every time.
This also means the supervisor gets invoked once per step in the loop, which costs tokens. For latency-sensitive systems, some teams cache the supervisor's system prompt (most providers support prompt caching) since it's identical on every call and only the conversation tail changes.
Guarding Against Infinite Loops and Runaway Costs
A supervisor that keeps deciding "researcher" is not a hypothetical bug — it's the single most common failure mode reported once teams put multi-agent graphs into production. The model second-guesses itself, decides a piece of research needs to be redone, and loops. Without a hard limit, this silently burns tokens until someone notices the bill.
LangGraph gives you recursion_limit for a blunt global ceiling, but the better practice is to track step count in state and let the supervisor see it, so it can make an informed decision rather than being cut off mid-thought.
class SupervisorState(TypedDict):
messages: Annotated[list, add_messages]
next_agent: str
task_complete: bool
scratchpad: dict
step_count: int
def supervisor_node(state: SupervisorState) -> dict:
step_count = state.get("step_count", 0) + 1
if step_count > 15:
return {"next_agent": "FINISH", "task_complete": True, "step_count": step_count}
messages = [{"role": "system", "content": SUPERVISOR_PROMPT}] + state["messages"]
decision = router.invoke(messages)
return {
"next_agent": decision.next_agent,
"task_complete": decision.next_agent == "FINISH",
"step_count": step_count,
}Set the ceiling meaningfully lower than whatever recursion_limit you configure on graph.invoke(..., config={"recursion_limit": 25}), so your own graceful cutoff fires before LangGraph's harder error does. A graph that hits recursion_limit raises an exception; a graph that hits your step_count check returns a normal, if incomplete, answer.
Also worth handling: a worker agent throwing an exception (a web search API timing out, a code sandbox crashing). Wrap worker invocations so a tool failure produces a message the supervisor can see and route around, instead of crashing the whole graph run.
def researcher_node(state: SupervisorState) -> dict:
try:
result = researcher_agent.invoke({"messages": state["messages"]})
last_message = result["messages"][-1]
except Exception as exc:
last_message = {
"role": "assistant",
"content": f"Research step failed: {exc}. Proceeding without new findings.",
}
return {"messages": [last_message]}This turns a hard crash into information the supervisor can reason about on its next turn — it might retry, route to a different worker, or decide there's enough to answer with anyway.
Streaming, Checkpointing, and Human-in-the-Loop
Production supervisor systems rarely run start-to-finish silently. Two LangGraph features matter once you leave the notebook: streaming intermediate steps to the user, and checkpointing so long-running graphs survive a restart.
Streaming is built in — graph.stream() yields state updates as each node finishes, so a UI can show "researching..." then "writing code..." instead of a blank screen for thirty seconds.
for chunk in graph.stream(
{"messages": [{"role": "user", "content": user_query}]},
config={"recursion_limit": 25},
):
for node_name, update in chunk.items():
print(f"[{node_name}] -> {update.get('next_agent', 'done')}")Checkpointing matters more than it sounds like on paper. If your coder agent is mid-execution and the process restarts, you want to resume from the last completed node, not from scratch. LangGraph's checkpointer (SQLite for local dev, Postgres for production) persists state after every node.
from langgraph.checkpoint.postgres import PostgresSaver
checkpointer = PostgresSaver.from_conn_string(DB_URI)
graph = builder.compile(checkpointer=checkpointer)
result = graph.invoke(
{"messages": [{"role": "user", "content": user_query}]},
config={"configurable": {"thread_id": "session-123"}},
)Checkpointing also unlocks human-in-the-loop review. You can interrupt before a specific node — say, before the coder agent executes anything — and require a human to approve the plan before the graph continues. This is the difference between "an agent that can run arbitrary code" and "an agent that proposes code a human signs off on," which for anything touching production systems is not optional.
Observability: Knowing Why the Supervisor Chose What It Chose
The hardest part of debugging a multi-agent system isn't the code — it's answering "why did it route to the coder agent instead of the researcher on that third turn?" This is exactly why the reasoning field in the RouteDecision schema from earlier isn't decoration. Log every routing decision with its reasoning, the state snapshot at that point, and the step count.
import logging
logger = logging.getLogger("supervisor")
def supervisor_node(state: SupervisorState) -> dict:
messages = [{"role": "system", "content": SUPERVISOR_PROMPT}] + state["messages"]
decision = router.invoke(messages)
logger.info(
"routing_decision",
extra={
"next_agent": decision.next_agent,
"reasoning": decision.reasoning,
"step": state.get("step_count", 0),
},
)
return {
"next_agent": decision.next_agent,
"task_complete": decision.next_agent == "FINISH",
}Tools like LangSmith trace this automatically if you're already using the LangChain ecosystem, giving you a visual graph of every node invocation, its inputs and outputs, and latency per step. But even without a dedicated tracing tool, structured logs on the routing decision alone will save you hours the first time a user reports "it gave a weird answer" and you need to reconstruct exactly what the supervisor was thinking three steps in.
When Not to Use a Supervisor Pattern
It's worth being honest about the cost side of this pattern before reaching for it by default. Every routing decision is an extra LLM call — for a three-worker system doing a five-step task, you're paying for roughly five supervisor calls on top of the actual work. For simple, single-purpose agents, that overhead buys you nothing.
Reach for a supervisor when you genuinely have distinct specialties that benefit from separate prompts and separate tool sets — research versus coding versus writing is a real split, because a system prompt tuned for careful source citation is actively worse at writing terse code, and vice versa. Don't reach for it just because "multi-agent" sounds more sophisticated than "one well-designed agent with a good prompt." A single agent with a clean, focused toolset will outperform a badly-decomposed multi-agent system in both latency and accuracy every time.
The tell that you've decomposed correctly: each worker's system prompt is short, its tool list is small, and you could hand its prompt to someone unfamiliar with the rest of the system and they'd understand exactly what it does. If your "specialized" workers still need thousand-word prompts covering edge cases from other domains, you haven't actually separated concerns — you've just added routing overhead on top of the same confusion.
Putting It Together
The supervisor pattern in LangGraph comes down to four disciplined choices: a shared state schema with typed fields instead of everything crammed into message history, a supervisor node that uses structured output to make routing decisions unambiguous, worker nodes with narrow prompts and narrow toolsets that always report back to the supervisor rather than to each other, and hard guardrails — step limits, exception handling, checkpointing — that keep the system from silently looping or crashing in production.
None of these individually is complicated. What makes multi-agent systems hard is that they compound: a vague state schema makes routing decisions unreliable, unreliable routing makes loops more likely, and loops without a step ceiling turn into runaway API bills. Build the state schema deliberately first, and the rest of the graph tends to fall into place cleanly.
If you want to go from this article to a working, production-grade supervisor system — with real tool integrations, checkpointing wired to Postgres, and the debugging workflow for tracing routing decisions — that's exactly what we cover hands-on in the LangGraph Tutorial course on teachyou.ai, building the graph up node by node until it's something you'd actually ship.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.