Building an end-to-end Coding Agent with LangGraph

Subagents Mechanism

Aug 31, 2026 · 27 min read

Chapter 4 gave the agent a plan, and the plan works. It also made a second problem easy to see: everything the agent reads stays in the thread forever. Check one fact in a file, and that file rides along in every later turn of the session. Ask three questions that each need a sweep of the codebase, and the context window is mostly file dumps with your conversation somewhere underneath.

This chapter adds the mechanism that fixes it. Before the demo, the concept in full, so the rest of the chapter is just watching it happen.

What a subagent is. A second instance of the loop we have been building since chapter 1 (chat, tools, router), started by the parent on an empty message list, with its own system prompt, its own tool set, and its own model. The parent starts it the way it does everything: by emitting a tool call, task(description, prompt, subagent_type). The subagent runs its whole ReAct loop to completion, and exactly one thing crosses back: its final message, delivered to the parent as an ordinary ToolMessage. To the parent it is a tool that happens to think.

Why our harness needs it. Three benefits, and each maps to a piece of this chapter:

  1. Context isolation. The subagent's greps and file reads land in its message list, which is thrown away when it finishes. The parent pays one report per question instead of the whole investigation, and since the parent re-sends its entire list on every turn, whatever stays out of it stays out for the rest of the session. This is the reason subagents exist; the other two are side effects.
  2. Parallelism. Independent questions become independent graphs. Two task calls in one message run as two subagents in the same superstep, courtesy of LangGraph's Send, with no threads in our code.
  3. Specialization. Each subagent type is its own configuration of the loop. explore holds only read-only tools, skips the approval node entirely, and can run on a cheaper model; general can edit files and keeps human approval. The tool surface, not a promise in a prompt, is what limits what a child can do.

The price. The subagent cannot see the parent's conversation, so the brief must be complete on its own; there is no follow-up channel to a finished subagent, only a new task; and the parent gets a report it did not watch being produced, so it should verify anything it acts on. The task docstring and the system prompt spend most of their words on exactly these three points.

(Chapter 4 promised plan mode next. This came first because it solves the problem the planning layer exposed, and plan mode will turn out to be one configuration of what we build here.)

terms in this chapter
subagent
The same ReAct loop, run on a fresh message list, returning one report to the parent.
context isolation
The child's tool output lands in the child's messages, never the parent's.
task
A Pydantic schema bound as a tool. The model calls it, but no function runs; the graph dispatches it.
Send
An edge return that starts one node instance with its own input. Several in one step run in parallel.
subgraph
A compiled graph invoked from inside a node with that node's config. It inherits the checkpointer, and its interrupts bubble up.
step cap
A limit on chat turns, after which the child gets one tool-free call to summarize what it has.

1. One request, two subagents#

Two questions about this codebase, unrelated to each other, each needing several files: how a tool result travels to the terminal, and where configuration is read.

Three message lists are on screen. The parent on the left is the conversation you are having. The two lanes on the right are the children: each is a separate graph with its own message list, and each header counts what is in it. Hit send to start the run, or step with the arrows. The note above the lanes says what is happening at each step, and the toggle at the bottom shows the run the way the parent model sees it.

two questions, two subagentsstep 1 of 11

Nothing has run yet. The parent holds its system prompt and the request is still in the composer. Hit send (or press Enter) to run it.

parentcomposing1 msg · 358 chars
system
you>Two quick questions: which function wraps tool results in ToolMessages, and which env vars does the agent read?
[find ToolMessage builder]explore · gpt-4o-mininot started

empty message list

[list env vars]explore · gpt-4o-mininot started

empty message list

viewSend the message to start the run, or click the bar and use ← → to step through.

Two things to take from that run before we open any code.

The report is the interface. Nothing crosses from a child lane to the parent lane except the child's last message. Not the greps, not the file reads, not the child's own system prompt. That is what context isolation means in practice: the parent gets a conclusion and pays for a conclusion.

The parent's list only ever grows by reports. This run is deliberately tiny, so the counters stay close; point either child at a real sweep and its lane climbs into the thousands of characters while the parent still gains exactly one ToolMessage per task. And because every later turn re-sends the whole list, what stays out of the parent stays out of every turn that follows.

2. The graph#

One new node, and one new kind of edge. Everything else is chapter 4's graph.

the chapter 5 graphDashed = new this chapter. Click a node for role, contract, code.

The mechanism in one place, so you can come back to it cold:

  • task is a Pydantic schema bound to the parent's LLM as a tool. The model calls it like any tool; tool_node never runs it.
  • dispatch is the edge after human_approval. Approved task calls become Send("subagent", tool_call), one per call, all in the same superstep. Any other approved call adds "tools". If everything was rejected it returns "chat" so the model can replan.
  • subagent is a node that receives one tool call, picks the compiled child graph by subagent_type, invokes it with the node's own config, and returns the child's final message as a ToolMessage. Exceptions become "Subagent failed: …"; interrupts are re-raised so they bubble up.
  • The child is build_graph(...) with a different LLM, tool list, prompt, and a step cap. explore is read-only with no approval step; general can edit and keeps approval. Neither has task, so the depth is one.
  • Both edges into chat, from tools and from subagent, deliver ToolMessages, and add_messages merges them. The parent cannot tell which path a result took, and does not need to.

Five pieces get us there:

PieceWhere
task schematools/task_tool.py
dispatch router and Sendagent/graph.py
subagent nodeagent/graph.py
build_graph factoryagent/graph.py
explore / general configurationagent/subagents.py

3. task is a schema, not a tool#

Every tool so far has been a Python function under @tool. task breaks the pattern on purpose:

src/tools/task_tool.py
class task(BaseModel):
    """Delegate a self-contained piece of work to a subagent with its own context window.
 
    The subagent starts fresh: it sees only the prompt you give it, works with
    its own tools, and returns a single text report. None of its intermediate
    tool output lands in your context -- that is the point. ...
 
    When NOT to use:
        - A single-fact lookup where you already know the file or symbol.
        - Work that depends on your conversation history. The subagent cannot
          see it, so anything it needs must be in the prompt.
        - Follow-up questions to a finished subagent. There is no channel back;
          start a new task with a sharper prompt instead.
    ...
    """
 
    description: str = Field(description="Three to five word label shown to the user while the subagent runs, e.g. 'map auth flow'.")
    prompt: str = Field(description="The full, self-contained brief for the subagent. Include where to look and what exact form the answer should take.")
    subagent_type: Literal["explore", "general"] = Field(description="'explore' is read-only (list_dir, read_file, glob, grep, web_search) and runs on a cheaper model; use it for research and search. 'general' can also edit and write files; use it for delegated changes.")

There is no function body because nothing here is called. bind_tools accepts a Pydantic class as readily as a decorated function, and the model sees the same thing either way: a name, a description, a JSON schema for the arguments. Chapter 4 made the point that a tool has two consumers, the model that reads the schema and the runtime that calls the function. task has only the first consumer. The runtime's half of the job is done by the graph.

Two lines elsewhere complete the picture:

src/agent/graph.py
llm_with_tools = llm.bind_tools([*tools, task] if subagents else tools)

The schema is bound only when the graph has subagents to dispatch to, which is how the children end up without it (section 6). And in tool_node:

python
for tc in pending_tool_calls(state):
    if tc["name"] == TASK:            # NEW: dispatched to the subagent node via Send
        continue

If you wrote task as a @tool with a body that raises "never called", it would work, and it would also be a lie in the one place the model reads carefully. The schema says what it is.

The docstring is doing real work. "Work that depends on your conversation history" and "there is no channel back" are the two things models get wrong about delegation, and the prompt is the only place to say so.

4. Dispatch: one Send per call#

Chapter 3 wired human_approval → tools as a plain edge. It becomes a conditional edge that returns a list:

src/agent/graph.py
def pending_tool_calls(state: State) -> list[dict]:
    ai = next(m for m in reversed(state["messages"]) if getattr(m, "tool_calls", None))
    answered = {m.tool_call_id for m in state["messages"] if isinstance(m, ToolMessage)}
    return [tc for tc in ai.tool_calls if tc["id"] not in answered]
 
 
def dispatch(state: State) -> list:
    pending = pending_tool_calls(state)
    targets: list = [Send("subagent", tc) for tc in pending if tc["name"] == TASK]
    if any(tc["name"] != TASK for tc in pending):
        targets.append("tools")
    return targets or ["chat"]          # everything rejected -> let the model replan

pending_tool_calls is the same filter tool_node has used since chapter 3, pulled out so both can use it: the calls in the last AIMessage that do not already have a ToolMessage. A rejection in human_approval answers a call early, which is how rejected task calls never become Sends.

Then the split, by name. Three kinds of destination can come out of one router call:

  • Send("subagent", tc) for each task call. A Send is not an edge to a node; it is an instruction to run one instance of a node with this input. Two task calls produce two Sends, so two subagent instances, each holding a different tool call. The node's input is the payload, not the graph state.
  • "tools", once, if any ordinary call remains. tool_node reads the state as usual and runs everything that is not task.
  • "chat", only when the list would otherwise be empty. The user rejected everything, the rejections are already in the thread, and the model should see them and try again.

LangGraph runs everything the router returns in the same superstep. That is the entire parallelism story: the two children in section 1 ran at the same time because dispatch returned two Sends, and tools would have run alongside them if the model had asked for a read_file in the same message. No threads in our code, no executor, no gather.

The other side of the superstep is the reducer. tools returns ToolMessages. Each subagent returns one ToolMessage. All of them land on messages, and add_messages appends them in whatever order they finish. Then every branch edges back to chat, which sees a thread where every call has an answer.

Wiring it up:

python
builder.add_conditional_edges("human_approval", dispatch, ["tools", "chat", "subagent"])
builder.add_edge("subagent", "chat")

The list of possible targets is for the compiler and the drawing; the Send objects bypass it.

5. The subagent node#

The node that actually runs a child is short, and one argument in it carries most of the weight:

src/agent/graph.py
def subagent_node(tc: dict, config: RunnableConfig) -> dict:
    args = tc["args"]
    try:
        if args["subagent_type"] not in subagents:
            raise ValueError(
                f"Unknown subagent_type {args['subagent_type']!r}. "
                f"Use one of: {', '.join(subagents)}."
            )
        sub = subagents[args["subagent_type"]]
        result = sub["graph"].invoke(
            {
                "messages": [SystemMessage(content=sub["prompt"]), HumanMessage(content=args["prompt"])],
                "description": args["description"],
                "steps": 0,
                "todos": [],
            },
            config,
        )
        content = result["messages"][-1].content
    except GraphBubbleUp:                 # interrupt() inside the subgraph -> let it propagate
        raise
    except Exception as e:
        content = f"Subagent failed: {e!r}"
    return {"messages": [ToolMessage(content=content, tool_call_id=tc["id"], name=TASK)]}

Read it top to bottom.

The input is a tool call, not the state. Because this node is only ever reached through Send, its first argument is whatever the Send carried: one tc dict with name, args, id. The node never sees the parent's messages, which is a nice property to get for free. It could not leak the parent's context into the child even by accident.

The child's message list is built right here. Two messages: the child type's system prompt, and the brief from the task call as a HumanMessage. That is the fresh start you watched in section 1. There is no third message.

config is passed through, and this is the line that matters. A compiled graph invoked with the config of the node that is running it becomes a subgraph: it inherits the parent's checkpointer, gets its own namespace under the parent's checkpoint, and, crucially, its interrupt() calls reach the outside world. Pass a fresh {"configurable": {"thread_id": ...}} instead and the child believes it is a root graph. Its interrupt() then returns an __interrupt__ marker inside the child's result, the parent never pauses, and the general child's edit goes ahead without asking anyone. Nothing errors. The approval prompt just never appears.

GraphBubbleUp is re-raised. An interrupt propagates as an exception, and it inherits from Exception. The generic handler that turns a crashed child into a "Subagent failed" message would otherwise swallow it. Order of the except clauses is not cosmetic.

Only the last message comes back. result["messages"][-1].content is the child's final answer, or its forced summary if the cap hit (section 7). Wrapped in a ToolMessage with the original tool_call_id, it is indistinguishable, to the parent, from a very informative tool.

That leaves the case the demo did not show: a general child that wants to edit a file. Its own human_approval node calls interrupt(), the exception climbs out of sub["graph"].invoke(...), through the re-raise, and into the parent's run, which pauses exactly as chapter 3 described. In the terminal it looks like this, with the child's label on the prompt so you know who is asking:

[task→general] rename helper
   │ Rename fmt_value to format_value in src/agent/cli.py and update its callers.
   [rename helper] [tool] grep(pattern='fmt_value', path='src')
   [rename helper] [tool] edit_file(path='src/agent/cli.py', old_string='def fmt_value(', new_string='def format_value(')
approve> [rename helper] edit_file
  path: src/agent/cli.py
  old_string: def fmt_value(
  new_string: def format_value(
approve? [y/N]:

Approve, and the child resumes from its own checkpoint. Reject, and the rejection becomes a ToolMessage in the child's list, which then decides what to do about it, exactly as the parent would. /auto is inherited: it lives in config, and config is what we just passed down.

6. One loop, three configurations#

The children are not a second implementation. They are the graph from chapters 1 to 4, built with different arguments. To make that literally true, graph.py stops defining LLM and TOOLS at module level and becomes a factory:

src/agent/graph.py
def build_graph(
    llm: BaseChatModel,
    tools: list,
    hitl: bool,
    max_steps: int | None = None,
    subagents: dict | None = None,
    checkpointer=None,
):
    tools_by_name = {t.name: t for t in tools}                                   # NEW: was module-level
    llm_with_tools = llm.bind_tools([*tools, task] if subagents else tools)      # NEW
 
    def chat_node(state: State) -> dict: ...
    def human_approval_node(state: State, config: RunnableConfig) -> dict: ...
    def tool_node(state: State) -> dict: ...
    def subagent_node(tc: dict, config: RunnableConfig) -> dict: ...
    def dispatch(state: State) -> list: ...
 
    def route_after_chat(state: State) -> list | str:
        if not state["messages"][-1].tool_calls:
            return END
        return "human_approval" if hitl else dispatch(state)                     # NEW: no approval node -> dispatch directly
 
    dispatch_targets = ["tools", "chat"] + (["subagent"] if subagents else [])
 
    builder = StateGraph(State)
    builder.add_node("chat", chat_node)
    builder.add_node("tools", tool_node)
    builder.add_edge(START, "chat")
    builder.add_edge("tools", "chat")
    if subagents:                                                                # NEW
        builder.add_node("subagent", subagent_node)
        builder.add_edge("subagent", "chat")
    if hitl:                                                                     # NEW
        builder.add_node("human_approval", human_approval_node)
        builder.add_conditional_edges("chat", route_after_chat, ["human_approval", END])
        builder.add_conditional_edges("human_approval", dispatch, dispatch_targets)
    else:
        builder.add_conditional_edges("chat", route_after_chat, [*dispatch_targets, END])
    return builder.compile(checkpointer=checkpointer)

The nodes become closures over llm_with_tools and tools_by_name; nothing inside them changed except the two # NEW lines from sections 3 and 7. Two switches shape the topology: hitl decides whether human_approval exists, and subagents decides whether subagent exists. When there is no approval node, route_after_chat calls dispatch itself, so the Send logic is the same function either way.

Then the three configurations, in one file:

src/agent/subagents.py
READ_ONLY_TOOLS = [list_dir, read_file, glob, grep, web_search]
WRITE_TOOLS = [edit_file, write_file]
PLANNING_TOOLS = [write_todos, read_todos]
 
 
def build_subagents() -> dict:
    cfg = get_config()
    return {
        "explore": {
            "graph": build_graph(get_llm(cfg.llm_explore_model), READ_ONLY_TOOLS, hitl=False, max_steps=MAX_STEPS),
            "prompt": load_prompt("explore"),
        },
        "general": {
            "graph": build_graph(get_llm(), READ_ONLY_TOOLS + WRITE_TOOLS, hitl=True, max_steps=MAX_STEPS),
            "prompt": load_prompt("general"),
        },
    }
 
 
def build_agent():
    return build_graph(
        get_llm(),
        READ_ONLY_TOOLS + WRITE_TOOLS + PLANNING_TOOLS,
        hitl=True,
        subagents=build_subagents(),
        checkpointer=InMemorySaver(),
    )

Side by side:

parentexploregeneral
toolsall, plus taskread-only + web_searchread-only + edit_file, write_file
human_approvalyesnoyes, inherits /auto
modelLLM_MODELLLM_EXPLORE_MODEL, else LLM_MODELLLM_MODEL
step capnone2020
checkpointerInMemorySaverinheritedinherited
system promptsystem.mdexplore.mdgeneral.md

Three rows deserve a sentence each.

No task for children. The schema is bound only when subagents is passed, and the children are built without it. That is the whole recursion policy: depth one, enforced by what the model can see rather than by a counter.

explore has no approval node. Every tool it holds is read-only, so there is nothing to approve, and a child that stops to ask would defeat the point of sending it away. This is also the first time the series has built a graph without human_approval since chapter 3, and it took one boolean.

explore can run on a cheaper model. get_llm(model=None) gained an override, and LLM_EXPLORE_MODEL is optional so that LM Studio users, who have one model loaded, change nothing. The explore prompt says "ask for facts, not judgement" for the same reason: the cheap model is doing retrieval, the expensive one is doing the thinking.

The parent's own system prompt grew a Delegation section, quoted in the demo's first message. It is the mirror image of the task docstring: when to reach for a subagent, and when not to.

7. The step cap#

A child that cannot find what it was asked for will keep looking. Nothing in a ReAct loop makes it stop, and unlike the parent it has no human in the loop to get bored. So the child's chat node counts:

src/agent/graph.py
STEP_LIMIT_PROMPT = (
    "Step limit reached. Do not call any more tools. Summarize what you found "
    "or changed so far and state clearly what is left unfinished."
)
 
 
def chat_node(state: State) -> dict:
    steps = state.get("steps", 0) + 1                                        # NEW
    if max_steps is not None and steps > max_steps:                          # NEW
        nudge = HumanMessage(content=STEP_LIMIT_PROMPT)
        summary = llm.invoke([*state["messages"], nudge])                    # no tools bound
        return {"messages": [nudge, summary], "steps": steps}
    return {"messages": [llm_with_tools.invoke(state["messages"])], "steps": steps}

steps is a new channel on State, without a reducer, so each write replaces it, and the parent never sets max_steps so it never trips. On the twenty-first call the child gets a HumanMessage telling it to stop, and the model is invoked without tools bound. That second part is what makes the cap a cap: a model with tools available will, if asked to summarize, quite often call one more tool instead. Remove the option and the only thing it can produce is text. The router sees no tool_calls, the child reaches END, and the summary is what section 5's node lifts out.

Why count turns instead of tokens? Tokens are what actually cost money, but counting them means provider-specific usage metadata, a running total in state, and a threshold nobody can pick well. Turns are visible in the trace, identical across providers, and twenty of them is a budget you can reason about: the children in section 1 needed one and two. The task docstring tells the parent the report may be partial, so a cap that trips is information, not an error.

8. Decisions#

Every one of these had a reasonable alternative. The chosen column is what the code does; the rejected column is what it would have looked like instead.

DecisionChosenRejectedWhy
Where subagents liveA tool-shaped call, executed as a nodeA supervisor topology: chat emits work items, a dispatch node fans out to worker nodes, results reduce into the parent stateThe tool shape keeps "when to delegate" a decision the model makes mid-turn like any other, and reducing worker output into the parent's messages is the pollution subagents exist to prevent.
How task is exposedPydantic schema@tool function with a body that is never calledThe model reads the schema; a fake function body misdescribes what happens.
ParallelismOne Send per call, same superstepA thread pool inside tool_nodeSend gives parallel nodes with proper checkpoints and interrupt propagation for free; threads inside a tool bury the child where neither works.
RecursionDepth oneChildren can spawn childrenNobody has yet shown a use for it that a better brief does not cover, and unbounded fan-out is unbounded cost. Enforced by not binding task in children.
BudgetChat-turn cap with a tool-free summaryToken countingTurns are visible and provider-neutral; twenty is a number you can reason about.
ModelCheaper model for explore, same for generalOne model everywhereRetrieval tolerates a weaker model; edits do not. Optional, so a single local model still works.
Approval inside generalInherit the parent's /auto settingAlways ask, or never askOne switch with one meaning. The task call itself was already approved by the user.

The first row is the big one, and it is the same shape as chapter 4's decision to make write_todos a no-op: put the intelligence where the model already is, and keep the graph as plumbing.

9. Up next#

The agent can now keep its own context clean while it works. What it cannot do is remember anything once the process exits: the InMemorySaver from chapter 1 is still the only memory it has, and thread_id="1" dies with the REPL.

Next chapter: memory. What to keep between sessions, where to keep it, and how a coding agent decides which of the last hundred messages were worth remembering.