Kubilay Gülaçdı
← Building an end-to-end Coding Agent with LangGraph

Subagents

Aug 31, 2026 · 26 min read

1. Where the code stands#

Axon is a terminal coding assistant built on LangGraph by hand, with no prebuilt agent class doing the orchestration. Its graph is a loop. A chat node calls the model with every tool bound and returns one message. A router looks at that message: if it has tool calls, the run goes to human_approval, which asks you about each call and turns a rejection into a ToolMessage; if it has none, the turn ends. Approved calls run in tools, whose results go back to chat. State is a messages list with the add_messages reducer, plus a todo list the model writes for itself.

That is the whole machine. This chapter adds one node and one edge to it.

kubilaygulacdi/axonThe code for this chapter. Every block below links to its file.

2. What a subagent is#

The message list only grows. A grep that returns sixty lines is in the list for the rest of the session, and because every turn re-sends the whole list, you pay for those sixty lines on every turn that follows. Ask three questions that each need a sweep of the codebase and your conversation is buried under file dumps.

The fix is to do the reading somewhere else. Start a second copy of the loop with an empty message list, hand it the question, let it grep and read as much as it needs, and take back only its final answer. The digging happens in a list you throw away. The parent gains one paragraph.

That is a subagent: the same loop, fresh context, one report back. It is the mechanism behind the Task tool in Claude Code and the equivalent in other assistants. In LangGraph you build it from three pieces: a tool the model can call, an edge that fans out, and a compiled graph invoked from inside a node.

terms in this chapter
subagent
The same loop, run on a fresh message list, handing back one report.
context isolation
The child's tool output lands in the child's messages, never the parent's.
report
The child's last message. The only thing that crosses back to the parent.

3. 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 toggle at the bottom shows the same 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 · 373 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.

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 system prompt. The parent gets a conclusion and pays for a conclusion.

The parent's list only grows by reports. This run is small on purpose, 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 one ToolMessage per call.

4. The graph#

One new node, one new kind of edge. Everything else is what you already had.

the graph with subagentsDashed = new. Click a node for role, contract, code.

The mechanism in five lines, so you can come back to it cold:

  • task is a schema bound to the parent's model as a tool. The model calls it like any tool. No function ever runs.
  • dispatch is the edge after human_approval. Each approved task call becomes a Send, and any other approved call adds "tools".
  • subagent is a node that receives one tool call, picks a compiled child graph, invokes it with the node's own config, and returns the child's last message as a ToolMessage.
  • The child is the same build_graph call with a different model, tool list, and prompt. Children do not get task, so the depth is one.
  • Both edges into chat deliver ToolMessages and add_messages merges them. The parent cannot tell which path a result took, and does not need to.

5. Building it#

task is a schema, not a function#

Every tool so far has been a Python function under @tool. This one is a Pydantic class with no body:

src/tools/task_tool.py
class task(BaseModel):
    """Delegate a self-contained piece of work to a subagent with its own context window.
 
    When NOT to use:
        - A single-fact lookup where you already know the file or symbol.
          Just call read_file or grep yourself; a subagent is slower.
        - 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 and runs on a cheaper model; use it for research and search. 'general' can also edit and write files.")
src/tools/task_tool.py

There is no body because nothing here is called. bind_tools takes 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. A normal tool has two consumers, the model that reads the schema and the runtime that calls the function. task only has the first. The graph does the runtime's half.

Two lines elsewhere finish the picture. In build_graph:

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

and in tool_node:

python
if tc["name"] == TASK:            # dispatched to the subagent node via Send
    continue

You could write task as a @tool whose body raises "never called". It would work, and it would also be a lie in the one place the model reads carefully.

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

Dispatch: one Send per call#

The edge from human_approval used to be a plain edge to tools. 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 already uses, pulled out so both can share it: the calls in the last model message that do not yet have a ToolMessage answering them. A rejection answers a call early, which is how a rejected task never becomes a Send.

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 give two Sends, so two subagent instances, each holding a different tool call. The node's input is that payload, not the graph state.
  • "tools", once, if any ordinary call is left. tool_node reads state as usual and runs everything that is not task.
  • "chat", only when the list would otherwise be empty. You 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 whole parallelism story. The two children above ran at the same time because dispatch returned two Sends, and tools would have run alongside them if the model had also asked for a read_file in that message. No threads in your code, no executor, no gather.

The other half of a superstep is the reducer. tools returns ToolMessages, each subagent returns one, and add_messages appends them all in whatever order they finish. Every branch then edges back to chat, which sees a thread where every call has an answer.

python
builder.add_conditional_edges("human_approval", dispatch, dispatch_targets)
builder.add_edge("subagent", "chat")

The target list is for the compiler and the diagram. The Send objects bypass it.

The subagent node#

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)]}
src/agent/graph.py

Read it top to bottom.

The input is a tool call, not the state. This node is only ever reached through a Send, so its first argument is whatever the Send carried: one dict with name, args, id. The node never sees the parent's messages. 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 the demo. 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 running it becomes a subgraph. It inherits the parent's checkpointer, gets its own namespace under the parent's checkpoint, and 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 a 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, so the generic handler that turns a crashed child into a "Subagent failed" message would otherwise swallow it. The 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 step cap hit. Wrapped in a ToolMessage with the original tool_call_id, it is indistinguishable, to the parent, from a very informative tool.

One loop, three configurations#

The children are not a second implementation. They are the same graph, built with different arguments. To make that literally true, graph.py stops defining the model and tool list at module level and becomes a factory:

src/agent/graph.py
def build_graph(llm, tools, hitl, max_steps=None, subagents=None, checkpointer=None):
    ...
    if subagents:
        builder.add_node("subagent", subagent_node)
        builder.add_edge("subagent", "chat")
    if hitl:
        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])

The node functions become closures over llm_with_tools and tools_by_name, and nothing inside them changed except the two lines above. Two switches shape the topology: hitl decides whether human_approval exists, subagents decides whether subagent exists. With 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 small 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]
MAX_STEPS = 20
 
 
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(),
    )
src/agent/subagents.py

Side by side:

parentexploregeneral
toolsall, plus taskread-only, plus web_searchread-only, plus 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. That took one boolean.

explore can run on a cheaper model. get_llm gained an optional model override, and LLM_EXPLORE_MODEL defaults to None so 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 does retrieval, the expensive one does the thinking.

The step cap#

A child that cannot find what it was asked for will keep looking, 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
        if max_steps is not None and steps > max_steps:
            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 channel on State with no reducer, so each write replaces it, and the parent never passes max_steps so it never trips. On the twenty-first call the child gets a message 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, asked to summarize, will quite often call one more tool instead. Take the option away and the only thing it can produce is text. The router sees no tool calls, the child ends, and that summary is what the subagent node lifts out.

Why count turns instead of tokens? Tokens are what 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 is a budget you can reason about. The children in the demo needed one and two. The task docstring warns the parent that a report may be partial, so a cap that trips is information, not an error.

6. Watching a subagent work#

A subagent that runs silently is unnerving. You want to see which child is doing what, and when one of them wants to edit a file you need to know who is asking. Both come from the same field.

subagent_node seeds description into the child's state. The child's chat node emits it once, on its first step, as a custom stream event:

src/agent/graph.py
if steps == 1 and state.get("description"):
    get_stream_writer()({"description": state["description"]})

The REPL streams with subgraphs=True, which means every event arrives with the namespace of the graph that produced it. It keeps a map from namespace to label, and any later event from that namespace gets the label printed in front of it:

src/agent/cli.py
labels: dict[tuple, str] = {}             # subgraph namespace -> task description
stream = graph.stream(stream_input, config=config, stream_mode=["updates", "custom"], subgraphs=True)
for ns, mode, payload in stream:
    if mode == "custom":
        if "description" in payload:
            labels[ns] = payload["description"]
        continue
src/agent/cli.py

The same description reaches the approval prompt by a different road: human_approval_node puts it in the interrupt() payload, and the REPL prints it in front of the call. So when a general child wants to edit a file, its own interrupt() climbs out of sub["graph"].invoke(...), through the re-raise in subagent_node, and into the parent's run, which pauses. In the terminal:

[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, because it lives in config and config is what you passed down.

Two children can interrupt in the same step, so the REPL collects every pending interrupt before it asks anything, then resumes them by id in one command:

src/agent/cli.py
stream_input = Command(resume={i.id: prompt_approval(i.value) for i in pending.values()})

7. What goes wrong#

Three failure modes, all of which you will hit.

The brief that assumes context. The parent writes "fix the bug we discussed" and the child, which has never seen your conversation, invents a bug to fix. It reads as a hallucinating model. It is actually a missing paragraph. The task docstring and the parent's system prompt both say this in as many words, and it is still the most common failure, because writing a self-contained brief is genuinely more work than writing "the thing we talked about".

The confident wrong report. You did not watch the child work, so all you have is a paragraph in a ToolMessage and no way to check how it got there. A child that greps badly will report "no matches found" with the same tone it reports a real answer. Two habits help: ask for evidence in the brief (file paths, line numbers, the actual matching line), and have the parent verify anything it is about to act on. The explore prompt ends with "findings first, with file paths and line numbers" for exactly this reason.

The config mistake that turns approval off. Passing a fresh config into sub["graph"].invoke(...) instead of the node's own is a one-word change that makes the child a root graph. Its interrupt() stops reaching you, the __interrupt__ marker gets buried in the child's result, and the child's edits run unapproved. Nothing raises. Nothing logs. The prompt just never appears, and the only symptom is that a file changed and you were not asked. Worth a test: the repo has one that resumes two parallel subagent interrupts by id, which would fail immediately if the config stopped being passed through.

8. Decisions#

Every one of these had a reasonable alternative. The chosen column is what the code does.

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 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 you.
Labelling childrenOne custom stream event carrying descriptionParse the subgraph namespace, or thread a label through every messageThe namespace is an implementation detail and message plumbing would touch every node. One event on the first step is enough.

The first row is the big one. It is the same move as making write_todos a no-op that only writes state: put the intelligence where the model already is, and keep the graph as plumbing.

9. Next#

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

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