Multi-Agent Supervisor Pattern
The agents from Chapters “Building a ReAct Agent with LangGraph + Ollama” through “Human-in-the-Loop Patterns” are all single agents with one flat list of tools. That is enough for a large class of applications. Once you have more than five or six tools, or tools that need clearly separate expertise (searching the web is nothing like writing SQL, which is nothing like reviewing legal documents), a single agent starts to feel unfocused. It hesitates, picks the wrong tool, or misinterprets the results of one tool by treating them like the results of another.
The supervisor pattern is the standard response to this set of problems. Instead of one agent with all the tools, you build:
- Several specialists, each a small compiled agent (typically
create_react_agent) with its own focused tool set and system prompt. - One supervisor, a graph node that reads the current conversation and decides which specialist should handle the next step, or that the conversation is complete.
The supervisor is not itself a specialist: it does not have tools of its own. It only routes. After each specialist responds, control returns to the supervisor, which decides whether to route to another specialist or to finish. That last part is what makes the pattern powerful: the supervisor can chain specialists together across a single user query.
LangChain Inc. ships a prebuilt create_supervisor helper (in the separate langgraph-supervisor package) that generates most of this for you, similar to how create_react_agent generates a single-agent ReAct graph. This chapter builds the pattern from scratch using only langgraph core, both because it is short (the whole thing is maybe forty lines) and because seeing the mechanics is the fastest way to understand when the prebuilt is or isn’t the right shape for your problem.
The example
Two specialists:
- research: a ReAct agent with one tool,
web_search(DuckDuckGo, no API key). - math: a ReAct agent with two tools,
addandmultiply.
Three test questions, chosen to exercise every code path:
"What is 137 times 24?": math only."What is the population of Canada?": research only."What is the population of Canada times 2?": research then math. The supervisor chains two specialists in one query. This is the interesting case.
Setup:
1 $ cd source-code/langgraph_supervisor
2 $ uv sync
3 $ ollama pull qwen3.5:4b
The specialists
We start with _specialists.py:
1 from langchain_core.tools import tool
2 from langchain_ollama import ChatOllama
3 from langgraph.prebuilt import create_react_agent
4
5
6 @tool
7 def add(a: int, b: int) -> int:
8 """Add two integers."""
9 return a + b
10
11
12 @tool
13 def multiply(a: int, b: int) -> int:
14 """Multiply two integers."""
15 return a * b
16
17
18 @tool
19 def web_search(query: str) -> str:
20 """Search DuckDuckGo. Returns the top three text results."""
21 from ddgs import DDGS
22 try:
23 results = list(DDGS().text(query, max_results=3))
24 except Exception as exc:
25 return f"Search failed: {exc}"
26 if not results:
27 return "No results."
28 return "\n\n".join(
29 f"- {r.get('title', '')}\n {r.get('body', '')}" for r in results
30 )
31
32
33 _model = ChatOllama(model="qwen3.5:4b", temperature=0, thinking=False)
34
35 research_agent = create_react_agent(_model, [web_search])
36 math_agent = create_react_agent(_model, [add, multiply])
Nothing in this file is new. Each specialist is exactly the single-agent ReAct graph from Chapter “Building a ReAct Agent with LangGraph + Ollama”, built with create_react_agent(model, tools). Both use the same ChatOllama class; you could just as easily give each specialist a different model, which is a common reason to reach for the pattern in the first place (a small, fast model for the research agent that mostly summarizes text; a stronger model for the math agent that has to reason about numbers).
The supervisor and the graph
The code in _supervisor.py is where the actual multi-agent supervisor pattern lives:
1 from typing import Annotated, Literal, TypedDict
2
3 from langchain_core.messages import BaseMessage, SystemMessage
4 from langchain_ollama import ChatOllama
5 from langgraph.graph import END, START, StateGraph
6 from langgraph.graph.message import add_messages
7 from pydantic import BaseModel, Field
8
9 from _specialists import math_agent, research_agent
10
11 Route = Literal["research", "math", "FINISH"]
12
13
14 class RouterDecision(BaseModel):
15 next: Route = Field(
16 description=(
17 "Which specialist should handle the next step, "
18 "or 'FINISH' if the last message already answers the user."
19 )
20 )
21
22
23 class State(TypedDict):
24 messages: Annotated[list[BaseMessage], add_messages]
25 next: str
26
27
28 SUPERVISOR_PROMPT = SystemMessage(
29 content=(
30 "You are the supervisor of a multi-agent system. "
31 "Read the conversation so far and decide which specialist should act next. "
32 "The specialists are:\n"
33 "- 'research': can search the web for factual information.\n"
34 "- 'math': can compute sums and products of integers.\n"
35 "If the last message in the conversation already fully answers the user's "
36 "original request, respond with 'FINISH'."
37 )
38 )
39
40 _supervisor_llm = (
41 ChatOllama(model="qwen3.5:4b", temperature=0, thinking=False).with_structured_output(RouterDecision)
42 )
43
44
45 def supervisor_node(state: State) -> dict:
46 decision = _supervisor_llm.invoke([SUPERVISOR_PROMPT] + list(state["messages"]))
47 return {"next": decision.next}
48
49
50 def research_node(state: State) -> dict:
51 result = research_agent.invoke({"messages": state["messages"]})
52 return {"messages": [result["messages"][-1]]}
53
54
55 def math_node(state: State) -> dict:
56 result = math_agent.invoke({"messages": state["messages"]})
57 return {"messages": [result["messages"][-1]]}
58
59
60 def route_from_supervisor(state: State) -> str:
61 if state["next"] == "FINISH":
62 return END
63 return state["next"]
64
65
66 def build_supervisor():
67 graph = StateGraph(State)
68 graph.add_node("supervisor", supervisor_node)
69 graph.add_node("research", research_node)
70 graph.add_node("math", math_node)
71
72 graph.add_edge(START, "supervisor")
73 graph.add_conditional_edges(
74 "supervisor",
75 route_from_supervisor,
76 {"research": "research", "math": "math", END: END},
77 )
78 graph.add_edge("research", "supervisor")
79 graph.add_edge("math", "supervisor")
80
81 return graph.compile()
Here are the interesting parts of this code:
RouterDecision. A one-field Pydantic model with a Literal["research", "math", "FINISH"] field. This is the schema we hand to .with_structured_output(). Because the field is a Literal, the model is constrained to return exactly one of those three values: no free-form text, no misspellings for the router function to handle.
supervisor_node. Invokes the supervisor LLM on the full transcript (prepended with the supervisor system prompt) and returns the model’s routing decision as {"next": ...}. Nothing gets appended to messages, only the next field is updated. The supervisor stays silent from the user’s point of view.
research_node and math_node. Each one wraps a specialist agent. It invokes the specialist with the shared transcript, then appends only the specialist’s final message to the shared transcript. The specialist’s internal tool-calling turns (its own ToolMessages and intermediate AIMessages) stay inside the specialist and don’t pollute the supervisor’s view. This is a deliberate design choice: the supervisor only needs the specialist’s answer to decide what to do next, not its reasoning.
route_from_supervisor. Reads state["next"] and returns either the specialist name or the END sentinel. This is the function add_conditional_edges calls after the supervisor node returns.
The wiring. Start goes to the supervisor. The supervisor’s conditional edges go to research, math, or end. After research runs, we always go back to the supervisor. Same for math. That is the loop.
Running it
Now we look at the top level example file 01_run_supervisor.py:
1 from langchain_core.messages import HumanMessage
2 from _supervisor import build_supervisor
3
4 app = build_supervisor()
5
6 QUESTIONS = [
7 "What is 137 times 24?",
8 "What is the population of Canada?",
9 "What is the population of Canada times 2?",
10 ]
11
12 for q in QUESTIONS:
13 print(f"USER: {q}")
14 result = app.invoke(
15 {"messages": [HumanMessage(content=q)], "next": ""},
16 config={"recursion_limit": 25},
17 )
18 final = result["messages"][-1]
19 print(f"FINAL: {final.content.strip()[:300]}\n")
Representative output (specialist LLMs will vary in exact wording):
1 $ uv run 01_run_supervisor.py
2 USER: What is 137 times 24?
3 FINAL: 137 times 24 is 3288.
4
5 USER: What is the population of Canada?
6 FINAL: The current population of Canada is approximately 40,528,396.
7
8 USER: What is the population of Canada times 2?
9 FINAL: Doubling Canada's population of about 40,528,396 gives approximately 81,056,792.
The third question required both specialists. The supervisor routed to research first, saw the research agent return the population, decided the answer was not complete, routed to math, saw math return the doubled number, decided the answer was now complete, and finished. No code in the graph knew this specific query needed research first and math second; that was a runtime routing decision made by the supervisor model on each turn.
Watching the routing
The utility method .stream() (initialized in line 1 of the following listing) makes the routing visible. Here we see the second example 02_stream_supervisor.py that streams the questions:
1 for step in app.stream(
2 {"messages": [HumanMessage(content=question)], "next": ""},
3 config={"recursion_limit": 25},
4 ):
5 for node_name, node_output in step.items():
6 print(f"=== node: {node_name} ===")
7 if "next" in node_output:
8 print(f" next -> {node_output['next']!r}")
9 for m in node_output.get("messages", []):
10 snippet = m.content if len(m.content) < 250 else m.content[:250] + "..."
11 print(f" {type(m).__name__}: {snippet}")
12 print()
A representative session:
1 $ uv run 02_stream_supervisor.py
2 USER: What is the population of Canada times 2?
3
4 === node: supervisor ===
5 next -> 'research'
6
7 === node: research ===
8 AIMessage: The current population of Canada is approximately 40,528,396 ...
9
10 === node: supervisor ===
11 next -> 'math'
12
13 === node: math ===
14 AIMessage: 40,528,396 * 2 = 81,056,792.
15
16 === node: supervisor ===
17 next -> 'FINISH'
Five steps: three supervisor calls, one research call, one math call. Every supervisor turn shows its routing decision explicitly. When the routing goes wrong (and with a smaller model it sometimes does), this is where you see it.
When multi-agent is worth it
The multi-agent pattern trades increased routing overhead (one extra LLM call per turn, plus the increased state complexity) for cleaner separation of concerns. Rough guidance from my own experience:
- Skip it if your total tool count is under about six and the tools are all in the same domain. A single ReAct agent handles that fine.
- Reach for it when tools cluster into obviously different domains (search + email + files + database + code execution), when different specialists need different LLMs (a fast small one for one job, a big one for another), or when specialists need different system prompts to behave correctly.
- Skip it if your users’ queries always exercise one specialist. The supervisor’s routing call is pure overhead in that case; just build the one specialist directly.
- Reach for it as soon as a single ReAct agent starts consistently picking the wrong tool or misinterpreting one tool’s output through the lens of another.
You can also add the checkpointer and interrupt machinery from Chapters “Durable, Restart-Safe Agents” and “Human-in-the-Loop Patterns” to a supervisor graph; it is just a StateGraph, and the same .compile(checkpointer=...) and interrupt() mechanisms work identically. A supervisor with checkpointed state and an approval interrupt on every specialist call is a genuinely useful primitive for building semi-autonomous assistants that a human still oversees.
What we covered
- Multi-agent: several small specialist graphs coordinated by a supervisor graph.
- Specialists are ordinary compiled
create_react_agentgraphs; nothing new required. - The supervisor is a single node that uses
.with_structured_output(RouterDecision)to pick the next specialist orFINISH. - Specialists always loop back to the supervisor, which decides what happens next. That is the mechanism that lets a single user query chain multiple specialists.
- Checkpointers and interrupts (Chapters “Durable, Restart-Safe Agents” and “Human-in-the-Loop Patterns”) work exactly the same way on a supervisor graph as on a single agent.
Chapter “Natural-Language SQLite” leaves the pure-mechanics territory behind and applies everything so far to natural-language querying of a real SQLite database.