Essay· 6 min read

Multi-Agent Orchestration: Supervisor and Subagent Patterns

How to split complex tasks across specialised agents without losing control of state, context, or your sanity.

Multi-Agent Orchestration: Supervisor and Subagent Patterns

A single agent handling everything is the monolith of the agentic world. It works until the task gets complex enough that one context window can't hold the full problem — and then it collapses quietly, hallucinating instead of admitting it is lost.

Multi-agent systems solve this by giving each agent a narrow, well-defined job. The hard part is the orchestration: who decides what each agent does, how state flows between them, and what happens when one fails.

The core architecture

There are two dominant patterns for multi-agent systems in LangGraph.

Network topology: Every agent can call every other agent. Flexible, but state management becomes a graph traversal problem.

Supervisor topology: One agent receives the task, decides which worker agents to invoke, and synthesises their outputs. Predictable, debuggable, easier to test.

This post focuses on the supervisor pattern because it maps cleanly to real product requirements.

          ┌──────────────────┐
          │    Supervisor    │
          │  (orchestrator)  │
          └────────┬─────────┘
                   │ routes to one of:
       ┌───────────┼───────────┐
       │           │           │
       ▼           ▼           ▼
  ┌──────────┐ ┌─────────┐ ┌──────────┐
  │ Research │ │ Writer  │ │Reviewer  │
  │  Agent   │ │  Agent  │ │  Agent   │
  └────┬─────┘ └────┬────┘ └────┬─────┘
       │             │           │
       └─────────────┴───────────┘
                     │ returns to Supervisor

             ┌───────────────┐
             │  Shared State │
             │  (TypedDict)  │
             └───────────────┘

Shared state schema

Every agent reads from and writes to a single shared state object. Design it up front — adding fields later is easy, removing them is not.

from typing import Annotated, Literal, TypedDict
from langgraph.graph.message import add_messages
 
class MultiAgentState(TypedDict):
    messages: Annotated[list, add_messages]
    task: str
    research_output: str
    draft: str
    review_notes: str
    next_agent: str
    iteration: int
    final_output: str | None

next_agent is what the supervisor writes to tell the graph which worker to call next. It is the key to conditional routing without hard-coded logic.

Building the supervisor

The supervisor is an LLM call with a structured output schema. It reads the current state and decides which worker to dispatch to next.

from langchain_anthropic import ChatAnthropic
from langchain_core.prompts import ChatPromptTemplate
from pydantic import BaseModel
 
class SupervisorDecision(BaseModel):
    next: Literal["researcher", "writer", "reviewer", "FINISH"]
    reasoning: str
 
supervisor_prompt = ChatPromptTemplate.from_messages([
    ("system", """You are a supervisor managing a team of AI agents.
Your team:
- researcher: gathers facts and data on the topic
- writer: drafts content based on research
- reviewer: checks the draft for accuracy and clarity
 
Current state:
- Research: {research_output}
- Draft: {draft}
- Review notes: {review_notes}
- Iteration: {iteration}
 
Decide which agent should act next, or FINISH if the output is ready.
Never call the same agent twice in a row. Maximum 3 iterations."""),
    ("human", "Task: {task}"),
])
 
llm = ChatAnthropic(model="claude-sonnet-4-6")
supervisor_chain = supervisor_prompt | llm.with_structured_output(SupervisorDecision)
 
def supervisor_node(state: MultiAgentState) -> MultiAgentState:
    decision = supervisor_chain.invoke({
        "task": state["task"],
        "research_output": state.get("research_output", "none yet"),
        "draft": state.get("draft", "none yet"),
        "review_notes": state.get("review_notes", "none yet"),
        "iteration": state.get("iteration", 0),
    })
    return {
        "next_agent": decision.next,
        "iteration": state.get("iteration", 0) + 1,
    }

Using with_structured_output forces the LLM to return a valid SupervisorDecision object. No string parsing, no hallucinated agent names.

Worker agents

Each worker agent is a focused LLM call. Keep them narrow — a researcher that also writes is a researcher that does both badly.

researcher_prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a research specialist. Given a task, gather key facts, statistics, and context. Be concise and factual."),
    ("human", "{task}"),
])
 
def researcher_node(state: MultiAgentState) -> MultiAgentState:
    response = (researcher_prompt | llm).invoke({"task": state["task"]})
    return {"research_output": response.content}
 
 
writer_prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a technical writer. Use the research provided to write a clear, well-structured draft."),
    ("human", "Task: {task}\n\nResearch:\n{research_output}"),
])
 
def writer_node(state: MultiAgentState) -> MultiAgentState:
    response = (writer_prompt | llm).invoke({
        "task": state["task"],
        "research_output": state.get("research_output", ""),
    })
    return {"draft": response.content}
 
 
reviewer_prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a critical reviewer. Check the draft for factual accuracy, clarity, and completeness. List specific improvements needed, or say APPROVED if it is ready."),
    ("human", "Draft:\n{draft}\n\nOriginal research:\n{research_output}"),
])
 
def reviewer_node(state: MultiAgentState) -> MultiAgentState:
    response = (reviewer_prompt | llm).invoke({
        "draft": state.get("draft", ""),
        "research_output": state.get("research_output", ""),
    })
    notes = response.content
    final = state.get("draft") if "APPROVED" in notes else None
    return {"review_notes": notes, "final_output": final}

Routing with Command objects

LangGraph 0.2+ introduced Command — a first-class way for nodes to both update state and direct the next node. This keeps routing logic inside the node rather than in a separate edge function.

from langgraph.types import Command
 
def supervisor_node(state: MultiAgentState) -> Command[Literal["researcher", "writer", "reviewer", "__end__"]]:
    decision = supervisor_chain.invoke({...})
 
    goto = "__end__" if decision.next == "FINISH" else decision.next
 
    return Command(
        update={"next_agent": decision.next, "iteration": state.get("iteration", 0) + 1},
        goto=goto,
    )

With Command, the supervisor node is the routing function. No separate add_conditional_edges call needed.

Assembling the graph

from langgraph.graph import StateGraph, END
 
builder = StateGraph(MultiAgentState)
 
builder.add_node("supervisor", supervisor_node)
builder.add_node("researcher", researcher_node)
builder.add_node("writer", writer_node)
builder.add_node("reviewer", reviewer_node)
 
builder.set_entry_point("supervisor")
 
# Each worker returns to the supervisor after finishing
builder.add_edge("researcher", "supervisor")
builder.add_edge("writer", "supervisor")
builder.add_edge("reviewer", "supervisor")
 
graph = builder.compile()

If you used the Command approach, skip add_conditional_edges — routing is embedded in the supervisor node itself.

Running the system

initial_state = {
    "task": "Write a technical summary of how transformer attention scales with context length",
    "messages": [],
    "research_output": "",
    "draft": "",
    "review_notes": "",
    "next_agent": "",
    "iteration": 0,
    "final_output": None,
}
 
result = graph.invoke(initial_state)
print(result["final_output"] or result["draft"])

Handoff patterns

Sometimes a worker agent needs to invoke its own sub-tools before returning to the supervisor. Use as_node() to embed a compiled sub-graph as a node in the parent graph.

# researcher_graph is its own StateGraph with web search tools
researcher_subgraph = researcher_builder.compile()
 
# Embed it as a node in the parent
builder.add_node("researcher", researcher_subgraph.as_node())

The parent graph treats the entire subgraph as a black box node. State flows in, state flows out, and the subgraph can run however many internal steps it needs.

What breaks at scale

Supervisor hallucinating agent names. Use Literal types in your structured output schema. The LLM cannot return an agent name that is not in the enum.

Agents writing to the same state key. If the writer and reviewer both try to update draft, one update is lost. Assign ownership: writer owns draft, reviewer owns review_notes.

No loop termination. A supervisor that never decides FINISH will run until you exhaust your token budget. Always track iteration and add a hard cutoff.

Context pollution between agents. Passing the full message history to every agent bloats the context and confuses specialised agents with irrelevant history. Pass only the state fields each agent needs.

The mental model

Think of the supervisor as a product manager and the workers as engineers. The PM does not write code — it decides priorities and sequences work. The engineers do not decide what to build — they execute their slice well. The system works because the boundaries are clear.

Clear boundaries in multi-agent systems are not a design nicety. They are what makes the system debuggable when something goes wrong at 2am.

Share: