Multi-Agent & Deep Agents

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against langchain 1.4.3, langchain-core 1.6.5, langgraph 1.2.12 and deepagents 0.7.19 on Python 3.12, as verified on 2026-09-29. The running example is DocsAssistant, which answers questions over this site’s documentation using Ollama’s llama3.1:8b chat model and the nomic-embed-text embedding model over the pgvector store built in Integrating with LangChain. Ollama was not available while the snippets were checked, so every output shown here was produced by running the code against the real packages with a scripted fake chat model (the helper listed below) and one-line stubs in place of the pgvector retriever; no output comes from a live model. The fake replays the messages it was given, so the outputs prove the wiring (which tools were offered, which state key changed, which message crossed a boundary), not the quality of any model’s answers.

Most tasks do not need more than one agent. A single agent with the right tools and prompt is simpler to build, cheaper to run and easier to debug. The official guidance names three reasons to go multi-agent anyway: context management (specialised knowledge that would not fit in one prompt), distributed development (teams own components independently) and parallelisation (workers run concurrently). The framework-neutral theory — topologies, shared versus isolated state, when coordination costs more than it saves — is in Multi-Agent Systems and is not repeated here. This page is the LangChain and LangGraph implementation: five patterns from the official multi-agent guide, the subgraph mechanics underneath them, and the Deep Agents harness that packages several of them.

The helper and the choice of pattern

Every example below runs against the same scripted fake. bind_tools returns the model itself because agents call it to register tools, and call builds an AIMessage that requests one tool call. The helper is kept in a module called fake.py.

The scripted fake chat model used by every example on this page
# fake.py
import itertools

from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage


class ScriptedChat(GenericFakeChatModel):
    """Replays scripted AIMessages and tolerates bind_tools()."""

    def bind_tools(self, tools, **kwargs):
        return self


def scripted(*messages):
    """Replay these messages once, one per model call."""
    return ScriptedChat(messages=iter(messages))


def always(text):
    """Answer the same text on every call (for agents invoked more than once)."""
    return ScriptedChat(messages=itertools.cycle([AIMessage(content=text)]))


def call(name, _id, **args):
    """An AIMessage that asks for one tool call."""
    return AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": _id, "type": "tool_call"}])

The official multi-agent overview lists four patterns and a fifth, custom workflows built directly on LangGraph. They differ in who decides the next step and whether the sub-component talks to the user:

flowchart TD Q{One agent with the right tools enough?} -->|yes| S[create_agent] Q -->|no: clear input categories| R[Router] Q -->|no: workers with their own context| U[Subagents / supervisor] Q -->|no: stages or state-driven behaviour| H[Handoffs] Q -->|no: one agent, many specialisations| K[Skills] Q -->|no: long, open-ended tasks| D[Deep Agents] Q -->|no: bespoke control flow| C[Custom LangGraph workflow]
Table 1. Choosing a multi-agent pattern (after the official guide)
Pattern Who decides the next step Strength Talks to the user

Subagents (supervisor)

A main agent, by calling subagents as tools

Context isolation, parallel workers, distributed development

Only the main agent

Handoffs

Tools that update a state variable

Sequential constraints, multi-stage conversations

Yes, whichever agent is active

Skills

One agent, by loading prompts on demand

Many specialisations without many agents

Yes

Router

A classification step, before the agents run

Parallel queries over distinct verticals, then synthesis

Through the router or a wrapping agent

Custom workflow

Your LangGraph code

Deterministic control mixed with agentic steps

As designed

The rest of this page walks the patterns in that order after the mechanics they all share: subgraphs.

Subgraphs

A subgraph is a compiled graph used as a node of another graph. Every multi-agent pattern that keeps agents inside one LangGraph graph is built from this, and an agent made by create_agent is itself a compiled graph, so it can be a node without any adapter (see LangGraph Fundamentals for graphs, state and reducers). How the parent and the subgraph communicate depends on their state schemas.

A compiled subgraph as a node, when parent and subgraph share state keys
import operator
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END


class Shared(TypedDict):
    question: str
    notes: Annotated[list[str], operator.add]


def rewrite(state: Shared):
    return {"notes": [f"rewritten: {state['question'].lower()}"]}


def retrieve(state: Shared):
    return {"notes": ["retrieved: ann-indexes.adoc"]}


retrieval = (StateGraph(Shared)
             .add_node("rewrite", rewrite).add_node("retrieve", retrieve)
             .add_edge(START, "rewrite").add_edge("rewrite", "retrieve")
             .compile())


def answer(state: Shared):
    return {"notes": [f"answer from {len(state['notes'])} notes"]}


parent = (StateGraph(Shared)
          .add_node("retrieval", retrieval)          # the compiled graph itself is the node
          .add_node("answer", answer)
          .add_edge(START, "retrieval").add_edge("retrieval", "answer")
          .compile())
print(parent.invoke({"question": "What is HNSW?", "notes": []})["notes"])

Output:

['rewritten: what is hnsw?', 'retrieved: ann-indexes.adoc', 'answer from 2 notes']

The subgraph reads and writes the parent’s channels directly, which is how agents usually share a messages key. When the schemas do not share keys — typically because each agent should keep a private message history — call the subgraph from inside a node and translate the state in both directions:

A subgraph with a private schema, called inside a node
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START


class Private(TypedDict):
    query: str
    hits: list[str]


def search(state: Private):
    return {"hits": [f"hit for {state['query']}"]}


inner = (StateGraph(Private).add_node("search", search)
         .add_edge(START, "search").compile())


class Outer(TypedDict):
    question: str
    context: list[str]


def call_inner(state: Outer):
    out = inner.invoke({"query": state["question"], "hits": []})   # parent state -> subgraph input
    return {"context": out["hits"]}                                # subgraph output -> parent state


outer = (StateGraph(Outer).add_node("call_inner", call_inner)
         .add_edge(START, "call_inner").compile())
print(outer.invoke({"question": "ef_search", "context": []}))

Output:

{'question': 'ef_search', 'context': ['hit for ef_search']}

Distributed development is the third reason to use subgraphs: as long as a team keeps the input and output schemas stable, the parent graph can be built without knowing what happens inside. See LangGraph — Subgraphs for the full guide, including viewing subgraph state and streaming subgraph output.

Subgraph persistence

What a subgraph remembers between calls is controlled by the checkpointer argument of its compile(). The parent must be compiled with a checkpointer for any of the modes to matter (see Short-Term Memory & Checkpointers).

The three subgraph persistence modes, three calls on one thread
import operator
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, START


class Sub(TypedDict):
    question: str
    asked: Annotated[list[str], operator.add]


def remember(state: Sub):
    return {"asked": [state["question"]]}


class Parent(TypedDict):
    question: str
    count: int


def build(sub_checkpointer):
    sub = (StateGraph(Sub).add_node("remember", remember)
           .add_edge(START, "remember").compile(checkpointer=sub_checkpointer))

    def call_sub(state: Parent):
        out = sub.invoke({"question": state["question"]})
        return {"count": len(out["asked"])}

    return (StateGraph(Parent).add_node("call_sub", call_sub)
            .add_edge(START, "call_sub").compile(checkpointer=InMemorySaver()))


for label, mode in [("per-invocation (None)", None), ("per-thread (True)", True), ("stateless (False)", False)]:
    graph = build(mode)
    config = {"configurable": {"thread_id": "alice-1"}}
    counts = [graph.invoke({"question": q}, config)["count"] for q in ("one", "two", "three")]
    print(f"{label:22} -> {counts}")

Output:

per-invocation (None)  -> [1, 1, 1]
per-thread (True)      -> [1, 2, 3]
stateless (False)      -> [1, 1, 1]
Table 2. Subgraph persistence modes
Mode checkpointer= Behaviour

Per-invocation

None (default)

Each call starts fresh, but inherits the parent’s checkpointer so interrupts and durable execution work inside one call.

Per-thread

True

State accumulates across calls on the same thread; the subgraph resumes where it left off.

Stateless

False

No checkpointing at all, like a plain function call. No interrupts or durable execution inside it.

Per-invocation is the right default for subagents that handle independent requests. Choose per-thread only when a subagent needs its own multi-turn memory, and avoid parallel calls to the same per-thread subagent because they would write conflicting checkpoints. See LangGraph — Subgraphs.

Subagents as tools: the supervisor pattern

In the subagents pattern a main agent (often called the supervisor) calls specialised agents as if they were tools. The main agent decides which subagent to invoke, with what input, and how to combine the results; subagents return to the main agent, never to the user. By default they are stateless, so the main agent holds all conversation memory and each subagent invocation starts in a clean context window. That isolation is the point: a research subagent can burn thirty tool calls and only its final message crosses back.

The mechanism is a @tool whose body invokes another agent:

DocsAssistant’s supervisor with one research subagent
from langchain.agents import create_agent
from langchain.tools import tool
from fake import scripted, call, AIMessage


@tool
def search_docs(query: str) -> str:
    """Search the documentation site and return matching sections."""
    return "ann-indexes.adoc :: HNSW :: ef_search trades recall for latency."   # pgvector in DocsAssistant


# The worker: its own model, tools and prompt, and its own clean context window
researcher = create_agent(
    scripted(call("search_docs", "r1", query="HNSW ef_search"),
             AIMessage(content="ef_search sets the candidate list size (ann-indexes.adoc).")),
    tools=[search_docs],
    system_prompt="You research the docs. Reply in at most three lines and name the page.",
)


@tool("research", description="Research a documentation question in depth and return a short, cited summary.")
def call_researcher(query: str) -> str:
    result = researcher.invoke({"messages": [{"role": "user", "content": query}]})
    return result["messages"][-1].content     # only the final message crosses back


supervisor = create_agent(
    scripted(call("research", "s1", query="What does HNSW ef_search do?"),
             AIMessage(content="ef_search is the candidate list size; higher means better recall, slower queries.")),
    tools=[call_researcher],
    system_prompt="You are DocsAssistant. Delegate documentation research to the research tool.",
)
out = supervisor.invoke({"messages": [{"role": "user", "content": "Explain ef_search"}]})
for m in out["messages"]:
    print(type(m).__name__, m.tool_calls[0]["name"] if getattr(m, "tool_calls", None) else m.content)

Output:

HumanMessage Explain ef_search
AIMessage research
ToolMessage ef_search sets the candidate list size (ann-indexes.adoc).
AIMessage ef_search is the candidate list size; higher means better recall, slower queries.

The supervisor’s history holds four messages; the researcher’s search call and result never appear in it. The subagent’s name and description are the only thing the supervisor sees, so they are prompting levers: make the description say what tasks the subagent handles and when to use it.

See LangChain — Subagents for the pattern, its sync-versus-async trade-off (a background job started by one tool and polled by two more, for work the user should not wait on) and the tutorial that builds a personal assistant this way.

One dispatch tool for many subagents

Wrapping every subagent in its own tool gives the most control over each one’s input and output. When there are many agents, or teams add agents without touching the coordinator, use a single parameterised task tool that looks the subagent up by name. Constraining the name with a Literal puts the valid values into the tool’s JSON Schema as an enum, which is the type-safe way to enumerate a small, static registry.

A single task tool dispatching to a registry, with a parallel fan-out
from typing import Literal
from langchain.agents import create_agent
from langchain.tools import tool
from fake import scripted, AIMessage


def make_agent(name, reply):
    return create_agent(scripted(AIMessage(content=reply)), tools=[], system_prompt=f"You are the {name} specialist.")


SUBAGENTS = {
    "research": make_agent("research", "ef_search: candidate list size (ann-indexes.adoc)."),
    "writer": make_agent("writer", "Draft: ef_search trades recall for latency."),
}


@tool
def task(agent_name: Literal["research", "writer"], description: str) -> str:
    """Launch an ephemeral subagent for one self-contained task.

    Available agents:
    - research: documentation fact-finding, returns cited findings
    - writer: turns findings into a short draft answer
    """
    result = SUBAGENTS[agent_name].invoke({"messages": [{"role": "user", "content": description}]})
    return result["messages"][-1].content


print(task.args_schema.model_json_schema()["properties"]["agent_name"])

both = AIMessage(content="", tool_calls=[
    {"name": "task", "args": {"agent_name": "research", "description": "Find ef_search"}, "id": "a", "type": "tool_call"},
    {"name": "task", "args": {"agent_name": "writer", "description": "Draft a 1-line answer about ef_search"}, "id": "b", "type": "tool_call"},
])
coordinator = create_agent(scripted(both, AIMessage(content="Done: research and draft collected.")),
                           tools=[task], system_prompt="You coordinate subagents through the task tool.")
out = coordinator.invoke({"messages": [{"role": "user", "content": "Answer: what is ef_search?"}]})
for m in out["messages"]:
    print(type(m).__name__, [c["args"]["agent_name"] for c in m.tool_calls] if getattr(m, "tool_calls", None) else m.content)

Output:

{'enum': ['research', 'writer'], 'title': 'Agent Name', 'type': 'string'}
HumanMessage Answer: what is ef_search?
AIMessage ['research', 'writer']
ToolMessage ef_search: candidate list size (ann-indexes.adoc).
ToolMessage Draft: ef_search trades recall for latency.
AIMessage Done: research and draft collected.

The model asked for both subagents in one turn, which is the parallel-execution characteristic of the pattern: the main agent can invoke several subagents in a single step. For larger or dynamic registries, list the agents through a discovery tool instead of the system prompt or an enum. Note that the description string of task doubles as the agent list the model reads, so keep it in step with the registry.

Two design choices shape how well the pattern behaves. Inputs: by default a subagent gets only the query the supervisor wrote; pass more of the conversation when the subagent genuinely needs it, at the cost of a larger context. Outputs: return the subagent’s result rather than its whole conversation, so the supervisor’s context stays small (see Context Engineering for Agents). A supervisor whose subagents are themselves supervisors gives the hierarchical topology described in Multi-Agent Systems; nothing in the mechanism changes.

Least privilege applies per subagent: give each one only the tools its job needs. DocsAssistant’s destructive tool, opening a GitHub issue, belongs on exactly one agent behind an approval gate (see Human-in-the-Loop & Time Travel), not on every worker that happens to share a tool list. See LangChain — Subagents.

Handoffs

In the handoffs pattern behaviour changes according to state. A tool updates a state variable (current_step, active_agent) that persists across turns, and the system reads the variable to apply a different configuration or route to a different agent. Unlike a subagent, the active component talks to the user directly. The pattern suits conversations that must collect information in a fixed order and support tickets that change hands.

The transition is a tool that returns a Command. The ToolMessage in the update is not optional: the model that requested the tool call expects a matching response, and without one the history is malformed.

There are two ways to build it. The simpler one is a single agent with middleware: a wrap_model_call hook (see Middleware) reads the step and overrides the system prompt and the tool list for each model call.

A handoff between two steps of one agent, driven by state and middleware
from typing import Callable

from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import ModelRequest, ModelResponse, wrap_model_call
from langchain.messages import ToolMessage
from langchain.tools import ToolRuntime, tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from typing_extensions import NotRequired

from fake import ScriptedChat, call, AIMessage


class SupportState(AgentState):
    current_step: NotRequired[str]           # absent until the first handoff; the hook defaults it to "triage"
    component: NotRequired[str | None]


@tool
def record_component(component: str, runtime: ToolRuntime) -> Command:
    """Record which documentation component the question is about, then move to the answer step."""
    return Command(update={
        "messages": [ToolMessage(content=f"Component recorded: {component}", tool_call_id=runtime.tool_call_id)],
        "component": component,
        "current_step": "answer",
    })


@tool
def search_docs(query: str) -> str:
    """Search the documentation site and return matching sections."""
    return "ann-indexes.adoc :: HNSW :: ef_search trades recall for latency."


STEPS = {
    "triage": {"prompt": "Ask which documentation component the user means, then record it.", "tools": [record_component]},
    "answer": {"prompt": "Answer from search_docs for the {component} component.", "tools": [search_docs]},
}


@wrap_model_call
def apply_step(request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]) -> ModelResponse:
    step = STEPS[request.state.get("current_step", "triage")]
    request = request.override(
        system_prompt=step["prompt"].format(component=request.state.get("component")),
        tools=step["tools"],
    )
    return handler(request)


seen = []   # what the model was offered on each call


class Recorder(ScriptedChat):
    def bind_tools(self, tools, **kwargs):
        seen.append([t.name for t in tools])
        return self


agent = create_agent(
    Recorder(messages=iter([
        call("record_component", "h1", component="vector-rag"),
        call("search_docs", "h2", query="ef_search"),
        AIMessage(content="ef_search trades recall for latency (ann-indexes.adoc)."),
    ])),
    tools=[record_component, search_docs],
    state_schema=SupportState,
    middleware=[apply_step],
    checkpointer=InMemorySaver(),
)
out = agent.invoke({"messages": [{"role": "user", "content": "What is ef_search?"}]},
                   {"configurable": {"thread_id": "alice-1"}})
print("tools offered per model call:", seen)
print("current_step:", out["current_step"], "| component:", out["component"])
print(out["messages"][-1].content)

Output:

tools offered per model call: [['record_component'], ['search_docs'], ['search_docs']]
current_step: answer | component: vector-rag
ef_search trades recall for latency (ann-indexes.adoc).

Before the handoff the model was offered only record_component; afterwards only search_docs. The step is part of the state, so a checkpointer keeps it across turns and the conversation resumes in the right step. Because the tools of the later step are not even offered before the precondition is met, the ordering is enforced by construction, not by asking the model to behave.

The second way is multiple agent subgraphs: distinct agents are nodes of one graph and the handoff tool uses Command.PARENT to name the next node. Use it when a node is a complex graph of its own (reflection, retrieval steps). It needs deliberate context engineering: the update must carry both the AIMessage that requested the handoff and the acknowledging ToolMessage, or the receiving agent sees an unpaired tool call.

Two agents as graph nodes with Command.PARENT handoffs
from typing import Literal

from langchain.agents import AgentState, create_agent
from langchain.messages import AIMessage, ToolMessage
from langchain.tools import ToolRuntime, tool
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command
from typing_extensions import NotRequired

from fake import call, scripted


class TeamState(AgentState):
    active_agent: NotRequired[str]


def handoff(target: str, description: str):
    @tool(f"transfer_to_{target}", description=description)
    def transfer(runtime: ToolRuntime) -> Command:
        last_ai = next(m for m in reversed(runtime.state["messages"]) if isinstance(m, AIMessage))
        ack = ToolMessage(content=f"Transferred to {target}", tool_call_id=runtime.tool_call_id)
        return Command(goto=target, update={"active_agent": target, "messages": [last_ai, ack]},
                       graph=Command.PARENT)
    return transfer


docs_agent = create_agent(
    scripted(call("transfer_to_bug_agent", "t1"),
             AIMessage(content="(never reached: the handoff ends this agent's run)")),
    tools=[handoff("bug_agent", "Hand the conversation to the bug-report agent when the user reports a docs bug.")],
    system_prompt="You answer questions about the documentation.",
)
bug_agent = create_agent(
    scripted(AIMessage(content="I can file that. Which page has the wrong ef_search default?")),
    tools=[handoff("docs_agent", "Hand back to the docs agent for ordinary questions.")],
    system_prompt="You collect details for a documentation bug report.",
)


def route_after(state: TeamState) -> Literal["docs_agent", "bug_agent", "__end__"]:
    last = state["messages"][-1]
    if isinstance(last, AIMessage) and not last.tool_calls:
        return END
    return state.get("active_agent") or "docs_agent"


builder = StateGraph(TeamState)
builder.add_node("docs_agent", lambda s: docs_agent.invoke(s))
builder.add_node("bug_agent", lambda s: bug_agent.invoke(s))
builder.add_conditional_edges(START, lambda s: s.get("active_agent") or "docs_agent", ["docs_agent", "bug_agent"])
for node in ("docs_agent", "bug_agent"):
    builder.add_conditional_edges(node, route_after, ["docs_agent", "bug_agent", END])
team = builder.compile()

out = team.invoke({"messages": [{"role": "user", "content": "The ef_search default in your page is wrong."}]})
for m in out["messages"]:
    print(type(m).__name__, [c["name"] for c in m.tool_calls] if getattr(m, "tool_calls", None) else m.content)
print("active_agent:", out["active_agent"])

Output:

HumanMessage The ef_search default in your page is wrong.
AIMessage ['transfer_to_bug_agent']
ToolMessage Transferred to bug_agent
AIMessage I can file that. Which page has the wrong ef_search default?
active_agent: bug_agent

The conditional edge after each node ends the run when the last message is a plain answer and otherwise follows active_agent, which is how control returns to the user after the bug agent replies. The official guidance is to prefer the single-agent-with-middleware form for most handoffs and reach for subgraph handoffs only when a node needs its own graph. The design of the state machine itself — steps, transitions, escape hatches — is the same agent-swarm topology discussed in Multi-Agent Systems.

See LangChain — Handoffs for both forms and the customer-support tutorial.

Router

A router is a routing step that classifies the input and dispatches it to one or more specialised agents whose answers are then synthesised. It fits distinct verticals — separate knowledge domains that each deserve their own agent — and it is the natural shape for querying several sources in parallel. Use Command(goto=…​) to send the query to a single agent and Send to fan out to several, exactly as in the orchestrator-worker pattern of LangGraph Workflows.

flowchart LR S([START]) --> C{classify} C -->|vector-rag| V[vector_rag agent] C -->|spring-ai| P[spring_ai agent] V --> Y[synthesize] P --> Y Y --> E([END])
Routing to a single agent with Command(goto=…​)
from typing import Literal, TypedDict

from langgraph.graph import END, START, StateGraph
from langgraph.types import Command


class State(TypedDict):
    query: str
    answer: str


def route_query(state: State) -> Command[Literal["vector_rag", "spring_ai"]]:
    """Single-agent routing: Command(goto=...) names the next node and can update state at the same time."""
    target = "spring_ai" if "spring" in state["query"].lower() else "vector_rag"
    return Command(goto=target)


def vector_rag(state: State):
    return {"answer": "vector-rag agent handled it"}


def spring_ai(state: State):
    return {"answer": "spring-ai agent handled it"}


graph = (StateGraph(State)
         .add_node("route_query", route_query)
         .add_node("vector_rag", vector_rag).add_node("spring_ai", spring_ai)
         .add_edge(START, "route_query")
         .add_edge("vector_rag", END).add_edge("spring_ai", END)
         .compile())
print(graph.invoke({"query": "PgVectorStore in Spring AI"})["answer"])
print(graph.invoke({"query": "HNSW ef_search"})["answer"])

Output:

spring-ai agent handled it
vector-rag agent handled it

To fan out to several agents at once, return a list of Send objects from the routing function instead. The next example does that, with two DocsAssistant verticals, each an agent built with create_agent, and a synthesis node that merges their answers through a reducer.

A router that fans out to two documentation verticals with Send
import operator
from typing import Annotated, TypedDict

from langchain.agents import create_agent
from langgraph.graph import END, START, StateGraph
from langgraph.types import Send

from fake import always


class Classification(TypedDict):
    agent: str
    query: str


class RouterState(TypedDict):
    query: str
    results: Annotated[list[str], operator.add]
    final_answer: str


class AgentInput(TypedDict):
    query: str


vector_rag = create_agent(always("pgvector: ef_search trades recall for latency."), tools=[],
                          system_prompt="You answer from the vector-rag documentation.")
spring_ai = create_agent(always("Spring AI: PgVectorStore exposes the same search request."), tools=[],
                         system_prompt="You answer from the Spring AI documentation.")


def classify(query: str) -> list[Classification]:
    """A real router asks the model (structured output); keywords keep this example deterministic."""
    picked = []
    if "pgvector" in query or "ef_search" in query:
        picked.append({"agent": "vector_rag", "query": query})
    if "spring" in query.lower():
        picked.append({"agent": "spring_ai", "query": query})
    return picked or [{"agent": "vector_rag", "query": query}]


def route_query(state: RouterState):
    return [Send(c["agent"], {"query": c["query"]}) for c in classify(state["query"])]


def run(agent, label):
    def node(state: AgentInput):
        answer = agent.invoke({"messages": [{"role": "user", "content": state["query"]}]})["messages"][-1].content
        return {"results": [f"[{label}] {answer}"]}
    return node


def synthesize(state: RouterState):
    return {"final_answer": "\n".join(state["results"])}


builder = StateGraph(RouterState)
builder.add_node("vector_rag", run(vector_rag, "vector-rag"))
builder.add_node("spring_ai", run(spring_ai, "spring-ai"))
builder.add_node("synthesize", synthesize)
builder.add_conditional_edges(START, route_query, ["vector_rag", "spring_ai"])
builder.add_edge("vector_rag", "synthesize")
builder.add_edge("spring_ai", "synthesize")
builder.add_edge("synthesize", END)
router = builder.compile()

print(router.invoke({"query": "Tune ef_search with pgvector, and show it in Spring AI", "results": []})["final_answer"])
print("---")
print(router.invoke({"query": "How do I tune ef_search?", "results": []})["final_answer"])

Output:

[vector-rag] pgvector: ef_search trades recall for latency.
[spring-ai] Spring AI: PgVectorStore exposes the same search request.
---
[vector-rag] pgvector: ef_search trades recall for latency.

The first query matched both verticals and produced two parallel results; the second matched one. The order of parallel results is not guaranteed. In production classify is a model call with structured output (see Structured Output) that returns the list of Classification items.

A bare router is stateless: each request is routed independently. For multi-turn use, the simplest fix is to wrap the whole router graph as a tool that a conversational agent calls, so the agent keeps the memory and the router stays stateless:

The stateless router as a tool of a conversational agent
from langchain.agents import create_agent
from langchain.tools import tool
from fake import scripted, call, AIMessage


@tool
def search_docs(query: str) -> str:
    """Search across the documentation verticals and return a combined, cited answer."""
    return router.invoke({"query": query, "results": []})["final_answer"]   # the router graph built above


chat = create_agent(
    scripted(call("search_docs", "q1", query="ef_search in pgvector"),
             AIMessage(content="Higher ef_search improves recall at the cost of latency.")),
    tools=[search_docs],
    system_prompt="You are DocsAssistant. Use search_docs to answer.",
)
print(chat.invoke({"messages": [{"role": "user", "content": "ef_search?"}]})["messages"][2].content)

Output:

[vector-rag] pgvector: ef_search trades recall for latency.

The alternative, a router that persists its own history, needs custom history management and tends to feel inconsistent when agents have different tones; the official guide recommends the handoffs or subagents patterns for multi-turn conversations instead. A router is a preprocessing step, whereas a supervisor is a full agent that keeps context and chooses across several turns; pick the router when the input categories are clear and cheap classification is enough. See LangChain — Router.

Skills

The skills pattern packages specialised capabilities as prompts that one agent loads on demand. The agent stays in control and talks to the user throughout; a skill is a specialisation, lighter than a whole subagent. It is a form of progressive disclosure: the system prompt names the available skills, and the full instructions enter the context only when the agent asks for them. It is the same idea as the Agent Skills format used by coding assistants (compare Customizing AI Workflows).

A load_skill tool for on-demand prompts
from langchain.agents import create_agent
from langchain.tools import tool
from fake import call, scripted, AIMessage

SKILLS = {
    "write_issue": "Draft a GitHub issue: title, steps to reproduce, expected vs actual, page path. Never open it yourself.",
    "review_page": "Review an .adoc page: check xrefs, code blocks that run, and links to official docs.",
}


@tool
def load_skill(skill_name: str) -> str:
    """Load a specialised skill prompt by name.

    Available skills:
    - write_issue: draft a documentation bug report
    - review_page: review an AsciiDoc page
    """
    return SKILLS.get(skill_name, f"Unknown skill {skill_name!r}; choose one of {sorted(SKILLS)}")


agent = create_agent(
    scripted(call("load_skill", "k1", skill_name="write_issue"),
             AIMessage(content="Title: ef_search default is wrong on ann-indexes.adoc ...")),
    tools=[load_skill],
    system_prompt="You are DocsAssistant. You have two skills, write_issue and review_page; call load_skill to read one before using it.",
)
out = agent.invoke({"messages": [{"role": "user", "content": "The ef_search default is wrong, please file it"}]})
for m in out["messages"]:
    print(type(m).__name__, m.tool_calls[0]["args"] if getattr(m, "tool_calls", None) else m.content)

Output:

HumanMessage The ef_search default is wrong, please file it
AIMessage {'skill_name': 'write_issue'}
ToolMessage Draft a GitHub issue: title, steps to reproduce, expected vs actual, page path. Never open it yourself.
AIMessage Title: ef_search default is wrong on ann-indexes.adoc ...

The skill body is returned as a tool result, so it costs tokens only after the agent chose it. The official guide lists three extensions of the basic pattern: registering new tools when a skill loads (a skill can bring its own tools, using the state and Command mechanics from the handoffs section), hierarchical skills that expose sub-skills in a tree, and reference awareness, where a skill’s prompt points at files, scripts or templates the agent reads only when needed. Skills are prompts, so treat skill text from an untrusted source as untrusted input; a skill that says "never open it yourself" is a request to the model, not a control — the approval gate belongs on the tool. Deep Agents ships skills as a built-in feature; see below. See LangChain — Skills.

Deep Agents

Deep Agents (deepagents) is a standalone library built on LangChain’s agent building blocks and run by the LangGraph runtime. The official docs call it an agent harness: the same tool-calling loop as create_agent, shipped with the capabilities long-running agents need so you do not assemble them yourself. Install it with pip install deepagents; this page used 0.7.19.

flowchart LR U([user]) --> M[deep agent] M --> P[write_todos: planning] M --> F[ls / read_file / write_file / edit_file / glob / grep] F --> B{{backend}} B --> ST[state: per thread] B --> SO[store: across threads] M --> T[task tool] T --> G[general-purpose subagent] T --> R[custom subagents] G -.->|final report only| M R -.->|final report only| M

The harness groups its capabilities into four areas: an execution environment (tools, a virtual filesystem behind a pluggable backend, optional sandbox and permissions), context management (skills, memory, summarisation, context offloading, prompt caching), delegation (planning and subagents) and steering (human-in-the-loop approvals through interrupt_on, the LangGraph interrupt mechanism covered in Human-in-the-Loop & Time Travel). The function that builds one is create_deep_agent; it returns a compiled LangGraph graph, so it streams, checkpoints and deploys like any other agent (see Streaming).

Deep Agents is a fit for long, open-ended tasks with many steps and large intermediate outputs, such as research or code changes, where a plain agent’s context would fill up. For a short question-answering agent like DocsAssistant’s chat loop, create_agent is the right size; the two compose well (see the Agents with create_agent page for the smaller harness).

The planning tool

Planning is a lightweight to-do list kept in agent state. In the 0.7 line it is opt-in: since v0.7 the write_todos tool is no longer added by default, and you enable it by passing LangChain’s TodoListMiddleware. The list is worth having for long tasks, for less capable models that benefit from an accountability tool, and for interfaces that show progress from state. Tasks have the status pending, in_progress or completed and live in the todos key of the state.

Enabling planning and checking which tools the agent has
from deepagents import create_deep_agent
from langchain.agents.middleware import TodoListMiddleware
from fake import scripted, AIMessage


def tools_of(agent):
    return sorted(agent.get_graph().nodes["tools"].data.tools_by_name)


print("default   :", tools_of(create_deep_agent(model=scripted(AIMessage(content="x")))))
print("with todos:", tools_of(create_deep_agent(model=scripted(AIMessage(content="x")),
                                                middleware=[TodoListMiddleware()])))

Output:

default   : ['delete', 'edit_file', 'execute', 'glob', 'grep', 'ls', 'read_file', 'task', 'write_file']
with todos: ['delete', 'edit_file', 'execute', 'glob', 'grep', 'ls', 'read_file', 'task', 'write_file', 'write_todos']

The default toolset is the filesystem tools plus the task tool for subagents; write_todos appears only with the middleware. According to the docs the execute tool works when the backend is a sandbox or the local shell backend; do not rely on it with the default in-memory backend. See Deep Agents — Overview.

The virtual filesystem

The agent works with files instead of stuffing everything into the conversation: it writes notes, reads them back, and keeps large tool outputs on the side. The default backend, StateBackend, stores the files in the graph state, so they are scoped to a thread and persist across turns through the checkpointer. The next snippet shows planning and the filesystem together in one run.

Planning and the virtual filesystem in one run
from deepagents import create_deep_agent
from langchain.agents.middleware import TodoListMiddleware
from fake import scripted, call, AIMessage

model = scripted(
    call("write_todos", "c1", todos=[
        {"content": "Read the ANN index page", "status": "in_progress"},
        {"content": "Write the summary", "status": "pending"}]),
    call("write_file", "c2", file_path="/notes/ann.md", content="HNSW ef_search trades recall for latency."),
    call("read_file", "c3", file_path="/notes/ann.md"),
    call("ls", "c4", path="/notes"),
    AIMessage(content="Saved and re-read /notes/ann.md."),
)
agent = create_deep_agent(model=model, system_prompt="You are DocsAssistant.", middleware=[TodoListMiddleware()])
out = agent.invoke({"messages": [{"role": "user", "content": "Summarise the ANN page"}]})
for m in out["messages"]:
    print(type(m).__name__, (m.content or m.tool_calls)[:120] if isinstance(m.content, str) else m.content)
print(out.keys())
print(out["todos"])
print(list(out["files"]))

Output:

HumanMessage Summarise the ANN page
AIMessage [{'name': 'write_todos', 'args': {'todos': [{'content': 'Read the ANN index page', 'status': 'in_progress'}, {'content': 'Write the summary', 'status': 'pending'}]}, 'id': 'c1', 'type': 'tool_call'}]
ToolMessage Updated todo list to [{'content': 'Read the ANN index page', 'status': 'in_progress'}, {'content': 'Write the summary', 'status': 'pending'}]
AIMessage [{'name': 'write_file', 'args': {'file_path': '/notes/ann.md', 'content': 'HNSW ef_search trades recall for latency.'}, 'id': 'c2', 'type': 'tool_call'}]
ToolMessage Updated file /notes/ann.md
AIMessage [{'name': 'read_file', 'args': {'file_path': '/notes/ann.md'}, 'id': 'c3', 'type': 'tool_call'}]
ToolMessage @@ lines 1-1 of 1 @@
HNSW ef_search trades recall for latency.
AIMessage [{'name': 'ls', 'args': {'path': '/notes'}, 'id': 'c4', 'type': 'tool_call'}]
ToolMessage ['/notes/ann.md']
AIMessage Saved and re-read /notes/ann.md.
dict_keys(['messages', 'files', 'todos'])
[{'content': 'Read the ANN index page', 'status': 'in_progress'}, {'content': 'Write the summary', 'status': 'pending'}]
['/notes/ann.md']

The state carries two extra keys, todos and files, next to messages. Files live in the graph state, so they show up in the streamed state and the checkpoint without any disk access.

The backend is a pluggable object. Besides StateBackend there are FilesystemBackend (local disk; the docs advise virtual_mode=True with root_dir so paths cannot escape the root), StoreBackend (a LangGraph store, shared across threads), sandboxes and a local shell backend. A CompositeBackend routes path prefixes to different backends, which is the standard way to get both: thread-scoped scratch space and durable, cross-thread /memories/. A FilesystemPermission rule then restricts what the agent may touch, for example making a policies directory read-only.

A composite backend: scratch files per thread, /memories/ across threads, and a write-protected /policies/
from deepagents import create_deep_agent, FilesystemPermission
from deepagents.backends import CompositeBackend, StateBackend, StoreBackend
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
from fake import scripted, call, AIMessage

store = InMemoryStore()


def build(model):
    return create_deep_agent(
        model=model,
        backend=CompositeBackend(
            default=StateBackend(),
            routes={"/memories/": StoreBackend(namespace=lambda _rt: ("docsassistant", "memories"))},
        ),
        permissions=[FilesystemPermission(operations=["write"], paths=["/policies/**"], mode="deny")],
        store=store,
        checkpointer=InMemorySaver(),
    )


a1 = build(scripted(
    call("write_file", "c1", file_path="/scratch/draft.md", content="thread-only draft"),
    call("write_file", "c2", file_path="/memories/style.md", content="Cite the page path."),
    call("write_file", "c3", file_path="/policies/rules.md", content="nope"),
    AIMessage(content="done"),
))
out = a1.invoke({"messages": [{"role": "user", "content": "save notes"}]}, {"configurable": {"thread_id": "t1"}})
print([m.content for m in out["messages"] if m.type == "tool"])
print("state files:", list(out["files"]))
print("store items:", [i.key for i in store.search(("docsassistant", "memories"))])

a2 = build(scripted(call("ls", "c1", path="/"), call("read_file", "c2", file_path="/memories/style.md"),
                    AIMessage(content="ok")))
out = a2.invoke({"messages": [{"role": "user", "content": "what do you remember?"}]}, {"configurable": {"thread_id": "t2"}})
print([m.content for m in out["messages"] if m.type == "tool"])

Output:

['Updated file /scratch/draft.md', 'Updated file /memories/style.md', 'Error: permission denied for write on /policies/rules.md']
state files: ['/scratch/draft.md']
store items: ['/style.md']
['/memories/']
@@ lines 1-1 of 1 @@
Cite the page path.

The scratch file stayed in the state of thread t1, the memory file went to the store, and the write to /policies/ was refused with an error the model can read. A second agent on a different thread listed /memories/ and read the note back. The store here is in memory; production uses a persistent store (see Long-Term Memory Stores). Paths under the composite backend’s routes are what separates the two kinds of memory: the same distinction the AI Agents page draws in Memory. See Deep Agents — Backends and Deep Agents — Permissions.

Subagents in Deep Agents

Every deep agent has a built-in task tool that spawns an ephemeral subagent for an isolated, long-running or parallel subtask. A general-purpose subagent is available by default. Add specialised ones with the subagents parameter, either as dictionaries or as pre-built graphs. The subagent gets a fresh context, works autonomously and returns one final report; the main agent’s context never sees its intermediate tool calls.

A dictionary subagent has these fields: name, description (the text the main agent uses to decide to delegate) and system_prompt are required for the default isolated mode; tools, model, middleware, interrupt_on, skills, permissions, response_format and mode are optional. A subagent that does not set tools inherits the main agent’s, so list only the tools it needs. permissions replaces the parent’s rules, and skills are not inherited by custom subagents.

A custom subagent, delegated to through the task tool
from deepagents import create_deep_agent
from langchain_core.tools import tool
from fake import scripted, call, AIMessage


@tool
def search_docs(query: str) -> str:
    """Search the documentation site."""
    return "ann-indexes.adoc :: HNSW :: ef_search trades recall for latency."


docs_researcher = {
    "name": "docs-researcher",
    "description": "Searches the documentation site in depth and returns a short, cited summary.",
    "system_prompt": "You search the docs. Reply with at most five lines and name each page.",
    "tools": [search_docs],
}
model = scripted(
    call("task", "c1", description="Find what HNSW ef_search does", subagent_type="docs-researcher"),  # main agent
    call("search_docs", "s1", query="HNSW ef_search"),                                                    # subagent
    AIMessage(content="ef_search sets the candidate list size (ann-indexes.adoc)."),                     # subagent final
    AIMessage(content="HNSW ef_search: candidate list size; see ann-indexes.adoc."),                     # main final
)
agent = create_deep_agent(model=model, subagents=[docs_researcher], system_prompt="You are DocsAssistant.")
out = agent.invoke({"messages": [{"role": "user", "content": "What does ef_search do?"}]})
for m in out["messages"]:
    print(type(m).__name__, m.tool_calls if getattr(m, "tool_calls", None) else m.content)

Output:

HumanMessage What does ef_search do?
AIMessage [{'name': 'task', 'args': {'description': 'Find what HNSW ef_search does', 'subagent_type': 'docs-researcher'}, 'id': 'c1', 'type': 'tool_call'}]
ToolMessage ef_search sets the candidate list size (ann-indexes.adoc).
AIMessage HNSW ef_search: candidate list size; see ann-indexes.adoc.

The main agent’s history has the task call and the subagent’s final report, but not the search_docs call the subagent made: that is the context isolation the harness exists to provide. The task tool takes a description and the name of the subagent in subagent_type.

When the subagent needs more than a prompt and a tool list, wrap any compiled LangGraph graph (for example the corrective RAG graph from LangGraph Workflows) as a CompiledSubAgent with a name, a description and a runnable:

A compiled graph as a subagent
from deepagents import CompiledSubAgent, create_deep_agent
from langchain.agents import create_agent
from fake import AIMessage, call, scripted

corrective_rag = create_agent(
    scripted(AIMessage(content="Corrective RAG result: ef_search (ann-indexes.adoc).")),
    tools=[], system_prompt="Answer only from retrieved documentation.")

rag_subagent = CompiledSubAgent(
    name="corrective-rag",
    description="Answers documentation questions with a graded, self-correcting retrieval loop.",
    runnable=corrective_rag,
)
agent = create_deep_agent(
    model=scripted(call("task", "c1", description="What does ef_search do?", subagent_type="corrective-rag"),
                   AIMessage(content="ef_search sets the HNSW candidate list size.")),
    subagents=[rag_subagent],
)
out = agent.invoke({"messages": [{"role": "user", "content": "What does ef_search do?"}]})
print([m.content for m in out["messages"] if m.type == "tool"])
print(out["messages"][-1].content)

Output:

['Corrective RAG result: ef_search (ann-indexes.adoc).']
ef_search sets the HNSW candidate list size.

Both forms accept mode="fork" (the default is "isolated"; forking is in beta and needs deepagents 0.7.13 or later), which makes the subagent inherit the parent’s conversation instead of seeing only the delegated task; use it when the subagent genuinely needs the context and accept the larger prompt. For work that should run in the background while the main agent keeps talking, the library also exports AsyncSubAgent; the official async subagents page covers it. The best-practice advice on the subagents page matches the tool design rules of Tool Design: write specific descriptions, keep system prompts detailed, minimise each subagent’s tool set, choose a cheaper model per task where it is enough, and have subagents return concise results. Subagent and main-agent activity can be told apart in streams and in traces by the subagent name (see Observability with LangSmith).

Skills in Deep Agents

Deep Agents implements the skills pattern with the Agent Skills layout: a directory per skill containing a SKILL.md file with name and description frontmatter, followed by instructions. The skills parameter takes a list of source directories, each one a container of skill directories. At startup the harness puts only each skill’s name and description into the system prompt; the agent reads the full SKILL.md with read_file when a task matches. With the default StateBackend, provide the skill files through the files input, formatted with create_file_data.

Skills with the default state backend
from deepagents import create_deep_agent
from deepagents.backends.utils import create_file_data
from fake import ScriptedChat, call, AIMessage

SKILL = """---
name: docs-bug-report
description: Use when the user reports a wrong or missing statement in the documentation and wants an issue drafted.
---
# docs-bug-report
1. Name the page path and section.
2. Quote the wrong statement and the correct one.
3. Draft the issue text; never open it without approval.
"""

prompts = []


class Spy(ScriptedChat):
    def _generate(self, messages, *a, **k):
        prompts.append(messages[0].content if isinstance(messages[0].content, str) else str(messages[0].content))
        return super()._generate(messages, *a, **k)


agent = create_deep_agent(
    model=Spy(messages=iter([call("read_file", "k1", file_path="/skills/docs-bug-report/SKILL.md"),
                             AIMessage(content="Drafted the issue following the skill.")])),
    skills=["/skills/"],
)
out = agent.invoke({
    "messages": [{"role": "user", "content": "The ef_search default is wrong, draft an issue"}],
    "files": {"/skills/docs-bug-report/SKILL.md": create_file_data(SKILL)},
})
p = prompts[0]
i = p.find("docs-bug-report")
print(p[i-40:i+200].replace("\n", " | "))
print([m.content[:60] for m in out["messages"] if m.type == "tool"])

Output:

achine.\n\n**Available Skills:**\n\n- **docs-bug-report**: Use when the user reports a wrong or missing statement in the documentation and wants an issue drafted.\n  -> Read `/skills/docs-bug-report/SKILL.md` for full instructions\n\n**How
['@@ lines 1-8 of 8 @@\n---\nname: docs-bug-report\ndescription: Use when the user reports a wrong or missing statement in the documentation and wants an issue drafted.\n']

The system prompt carried the one-line description only; the body came in through read_file after the agent chose the skill. See Deep Agents — Skills.

Deep Agents or your own graph

Use create_agent when one agent with a few tools is enough, plain LangGraph when you need to control the flow, and create_deep_agent when a task is long enough that planning, offloaded files and delegated subagents pay for themselves. Everything in Deep Agents is built from pieces this section has already covered: write_todos is a middleware (see Middleware), the filesystem is state or a store, and task is the single-dispatch subagent tool from earlier, with the registry built in. That also means the security rules are the same: scope tools per subagent, use permissions for write access, and put interrupt_on approval in front of destructive tools.

Summary

Table 3. Where each concept lives
Concept LangChain / LangGraph Deep Agents

Isolated worker

A @tool that calls agent.invoke and returns the last message

subagents=[…​] and the built-in task tool

Registry of workers

One task tool with a Literal agent name

SubAgent dictionaries and CompiledSubAgent

State-driven stages

Command from a tool, wrap_model_call, or Command.PARENT

Not a built-in concept

Classification then dispatch

Command(goto=…​) or Send in a graph

Not a built-in concept

On-demand specialisation

A load_skill tool

skills=[…​] with SKILL.md files

Planning

Your own state and tools

TodoListMiddleware for write_todos

Working memory outside the prompt

State, or a store through tools

A virtual filesystem with pluggable backends and permissions

More patterns and the reasoning behind them are in Multi-Agent Systems, the graph mechanics in LangGraph Fundamentals, and the production concerns in Guardrails & Security.