How do you add a compiled LangGraph subgraph as a node when its state schema differs?
answer
- overlapping keys, or a wrapper
- compiled graphs are callable nodes
- translate down, invoke, translate up
- non-overlapping keys arrive empty
- Command.PARENT for a sideways handoff
basics
~20 sWrap it. When parent and subgraph share the state keys, pass the compiled subgraph straight to add_node. When the schemas differ, add an ordinary function node that translates parent state into the subgraph's input, calls subgraph.invoke, and maps the result back into parent keys.
solid answer
~50 sThere are exactly two ways to attach a nested agent. If the child's schema declares the same keys as the parent — the common case with `MessagesState` on both sides — `builder.add_node("research", research_graph)` works directly, and the child's updates propagate through the parent's reducers for those keys. If the child has its own schema (say `query`, `notes`, `summary`), a direct attach silently starves it: it is invoked with state that lacks the keys it reads, and it fails at the first node that needs them. The fix is a plain function node in the parent that builds the child's input dict, calls `research_graph.invoke({...})`, and returns a parent-shaped update. That wrapper is also the natural place to enforce the interface: what goes down, what comes back, and what gets dropped. Inside the child, `Command(goto="writer", graph=Command.PARENT)` hands control to a node in the parent.
code
python · 40 linesfrom typing import TypedDict
from langgraph.graph import END, START, MessagesState, StateGraph
class ResearchState(TypedDict):
query: str
notes: list[str]
summary: str
def gather(state: ResearchState) -> dict:
return {"notes": [f"note about {state['query']}"]}
def summarize(state: ResearchState) -> dict:
return {"summary": "; ".join(state["notes"])}
child = StateGraph(ResearchState)
child.add_node("gather", gather)
child.add_node("summarize", summarize)
child.add_edge(START, "gather")
child.add_edge("gather", "summarize")
child.add_edge("summarize", END)
research_graph = child.compile()
def research_node(state: MessagesState) -> dict:
result = research_graph.invoke(
{"query": state["messages"][-1].content, "notes": [], "summary": ""}
)
return {"messages": [{"role": "assistant", "content": result["summary"]}]}
parent = StateGraph(MessagesState)
parent.add_node("research", research_node)
parent.add_edge(START, "research")
parent.add_edge("research", END)
graph = parent.compile()go deeper
Know that a compiled graph can be used as a node in another graph, and that the two graphs have to agree on the state keys they exchange.
Describe both attach routes — direct when schemas share keys, a wrapper function when they do not — and what the wrapper does on the way in and out.
Talk about the wrapper as an explicit interface you can test and version, plus the operational details: compile once, async all the way down, and shared recursion budget.
Decide when an agent deserves to be a separate graph at all, and set the house rule for what crosses a boundary, so nested agents stay independently testable instead of coupling through a sprawling shared schema.
## Why subgraphs at all A multi-agent system is a graph of graphs. Each agent has its own internal loop — call model, maybe call tools, maybe reflect — and that loop is itself a `StateGraph`. Composing them means attaching a compiled child graph as a node in a parent graph. This is what makes agents genuinely reusable: the research agent can be tested standalone, published as a module, and dropped into three different parents. ## Route one: shared schema, direct attach `StateGraph.add_node` accepts a compiled graph wherever it accepts a callable, because a compiled graph *is* callable. When the child's schema declares the same channels as the parent, this is all you need: builder.add_node("research", research_graph) At runtime the parent hands the child the values of the overlapping channels, the child runs its own supersteps, and whatever it returns for those channels is merged into the parent's state through the *parent's* reducers. With a shared appending `messages` channel that means the child's messages simply extend the parent's transcript — no translation code, and one readable conversation. The cost is that the child has no privacy. Every intermediate message it produces is now parent state, with all the prompt-growth and contamination that implies. ## Route two: different schema, wrapper node When the child declares its own working keys, direct attach is wrong. The child is invoked with whatever the parent has under the names it declared, which for non-overlapping keys is nothing; the first node that reads `state["query"]` fails. Worse, if the schemas overlap *partially*, it half-works, which is harder to spot. The wrapper is a normal node function: def research_node(state: ParentState) -> dict: result = research_graph.invoke({"query": state["messages"][-1].content, "notes": [], "summary": ""}) return {"messages": [{"role": "assistant", "content": result["summary"]}]} Three things are happening here, and naming them is what an interview is testing: **projection down** (which parent facts the child is allowed to see), **invocation** (the child runs its own graph, with its own recursion budget), and **projection up** (which of the child's outputs become parent state). That is an explicit, versionable interface between two agents, and it is the main reason to prefer the wrapper even when a shared schema would technically work. A useful discipline: keep the projection functions pure and unit-test them. Most multi-agent bugs that present as "the model got confused" are actually a projection dropping or mangling a field. ## Handing control back sideways A nested agent that finishes normally returns to whatever the parent's edges say comes next. Sometimes a node *inside* the child needs to hand off to a sibling of the child in the parent — swarm-style, no router in the middle. `Command(goto="writer", graph=Command.PARENT)` does that: the update applies in the child's context and propagates upward through shared keys, and control resumes at `writer` in the parent graph. Without `graph=Command.PARENT`, `goto` resolves inside the child and would look for a node named `writer` there. ## Operational consequences - **Recursion budget.** The child's supersteps count toward the run; a chatty child can exhaust the budget the parent was sized for. Treat the recursion limit as a property of the whole nested run, not of each graph. - **Debugging is two-level.** With the wrapper pattern, the parent's state does not contain the child's intermediate steps at all. You need subgraph-aware tracing to see inside, and you should log the projected input and output at the wrapper boundary, because that pair is what you will actually want when reproducing a bad run. - **Compile once.** Compile the child at module import, not inside the node function. Rebuilding and recompiling a graph on every invocation is wasted work on every hop. - **Async matters.** If the parent runs with the async API, the wrapper must call the child's async invocation, otherwise a synchronous call blocks the event loop for the entire child run — invisible in tests with one request, very visible under concurrency. ## When to skip nesting entirely If the "agent" is a single model call with a fixed prompt, do not give it a graph. A plain node function is cheaper, appears directly in the parent's topology, and does not add a level of state to reason about. Nesting earns its keep when the child has its own loop, its own termination condition, or its own reuse story.
- What actually goes wrong if you attach a compiled subgraph directly when the schemas do not overlap?The child is invoked with only the channels it declares that the parent also has — none, in that case — so its first node reads a key that was never populated and the run fails there. The nastier version is partial overlap: enough keys line up that the graph runs, and the missing one surfaces as bad output rather than an exception.
- Where does a nested agent's recursion budget come from?The child's supersteps count toward the same run limit as the parent's, so a chatty child can exhaust a budget the parent was sized for. Size the recursion limit for the whole nested run, and give any child with its own loop an internal termination condition rather than relying on the outer ceiling to stop it.
- Why compile the child graph at import time rather than inside the wrapper node?Compilation walks the builder and validates the topology; doing it per invocation repeats that work on every hop for no benefit and makes latency scale with graph size rather than with the work. Compile once at module level, keep the wrapper node to projection and invocation, and the node stays cheap and easy to test.
saying these in an interview costs you the question
- Thinks a subgraph automatically inherits every parent state key
- Rebuilds and recompiles the child graph inside the node function
- Uses plain goto to reach a node in the parent graph
- Assumes nesting adds no supersteps to the run's recursion budget
- Calls the child synchronously from an async parent graph