Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Compose LangGraph 1.0 subgraphs correctly — shared state key propagation, Send / Command(graph=...) dispatch, callback scoping, per-subgraph recursion budgets, and testing each subgraph in isolation. Use when building a planner + executor, a nested agent team, or a reusable subgraph library. Trigger with "langgraph subgraph", "langgraph composition", "langgraph send", "nested agents", "langgraph state propagation", "Command(graph=...)", "langgraph subgraph callbacks".
.claude/skills/jeremylongshore-langchain-langgraph-subgraphs/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 11% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 16% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 18% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 115% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 203% | 0% |
A parent StateGraph invokes a compiled child subgraph as a node. The child node writes state["answer"] = "42" and returns. The parent's next node reads state["answer"] and gets None. No error, no warning, no deprecation notice — just a silent None that surfaces as a wrong answer three nodes later when the router picks the "couldn't find it" branch.
The cause is pain-catalog entry P21: LangGraph subgraphs run on an independent state schema. Only keys declared in both the parent's TypedDict and the child's TypedDict propagate across the subgraph boundary. answer existed in the child schema but not the parent schema, so it was discarded on return. The fix is to declare answer in both schemas (with matching reducers, if the field is a list) or to use explicit Command(graph=ParentGraph, update={"answer": "42"}) to bubble it up.
The second silent failure waits one step further. Attach a tracing callback to the parent runnable via parent.with_config(callbacks=[tracer]) and invoke. The tracer fires on parent nodes and never on child tool calls. This is pain-catalog entry P28: LangGraph creates a fresh runtime per subgraph, so callbacks bound at definition time do not inherit. The fix is to pass callbacks at invocation time via config["callbacks"], which does propagate.
This skill walks through the shared-state contract, three dispatch patterns (compiled subgraph as a node, Send fan-out, Command(graph=Parent) bubble-up), callback scoping, per-subgraph recursion_limit budgets, and a testing pattern that exercises every subgraph in isolation before composition. Pin: langgraph 1.0.x, langchain-core 1.0.x. Pain-catalog anchors: P21, P28, with supporting references to P18 (reducers), P19 (stream modes on nested graphs), and P55 (recursion budget).
A planner-executor is typically 1 parent + 2-4 subgraphs; a hierarchical agent team with a supervisor and N specialists is 1 parent + N subgraphs. Each subgraph has its own independent recursion_limit (default 25) — a parent at step 20 can still invoke a child that runs 25 of its own steps.
langgraph >= 1.0, < 2.0langchain-core >= 1.0, < 2.0langchain-langgraph-basics (L25) — StateGraph, TypedDictstate, Annotated[list, add_messages] reducer, MemorySaver checkpointing
pytest, langchain_core.language_models.fake_chat_models.FakeListChatModelThe single most important decision when composing subgraphs is: which keys cross the boundary? Every key that must survive the call must appear in both TypedDict schemas with compatible types and reducers.
pythonfrom typing import Annotated, TypedDict from langchain_core.messages import AnyMessage from langgraph.graph.message import add_messages # Keys both schemas declare -> these propagate # Keys only in parent -> invisible to child # Keys only in child -> discarded on return (P21) class ParentState(TypedDict): # Shared with every subgraph messages: Annotated[list[AnyMessage], add_messages] # P18 reducer required session_id: str # Parent-only coordination fields plan: list[str] current_step: int class ExecutorState(TypedDict): # Shared with parent — must match reducer exactly (P18) messages: Annotated[list[AnyMessage], add_messages] session_id: str # Executor-only scratch — parent never sees these tool_result: dict | None retries: int
If messages on the child used a different reducer (or no reducer), list updates would silently replace instead of append on one side of the boundary (P18). The messages + session_id pair is the propagation contract. Everything else is private to its owner.
See State Contract for the full state-propagation matrix and the "subset rule" for schema inheritance.
Three ways a parent can invoke a subgraph, and each solves a different problem.
A. Compiled subgraph as a node — Simplest. Subgraph runs, returns a state update, parent continues.
pythonfrom langgraph.graph import StateGraph, END executor_graph = ( StateGraph(ExecutorState) .add_node("run_tool", run_tool_node) .add_node("summarize", summarize_node) .add_edge("run_tool", "summarize") .add_edge("summarize", END) .set_entry_point("run_tool") .compile() ) parent_graph = ( StateGraph(ParentState) .add_node("plan", planner_node) .add_node("execute", executor_graph) # compiled subgraph as a node .add_node("finalize", finalize_node) .add_edge("plan", "execute") .add_edge("execute", "finalize") .set_entry_point("plan") .compile() )
Only messages and session_id cross the boundary in either direction (from Step 1). tool_result stays inside the child; plan stays inside the parent.
B. Send(graph, state) for fan-out — One parent step spawns N parallel subgraph invocations, each with a different slice of state.
pythonfrom langgraph.types import Send def dispatch_specialists(state: ParentState) -> list[Send]: return [ Send("specialist_graph", {"messages": state["messages"], "session_id": state["session_id"], "topic": topic}) for topic in state["plan"] ]
Use Send when the number of subgraph calls depends on runtime state. Reducers on shared keys merge the parallel results.
C. Command(graph=ParentGraph, update=...) to bubble up — A subgraph node jumps control back to the parent with an explicit state update, skipping the rest of the subgraph.
pythonfrom langgraph.types import Command def specialist_early_exit(state: ExecutorState) -> Command: if state.get("tool_result") and state["tool_result"].get("done"): return Command( graph=Command.PARENT, update={"messages": [AIMessage("done")]}, goto="finalize", ) return {"retries": state.get("retries", 0) + 1}
Command(graph=Command.PARENT) is the explicit opposite of P21 — it forces a field up to the parent scope regardless of schema overlap.
See Dispatch Patterns for the full decision tree (inline function vs subgraph-as-node vs Send vs Command vs separate service) and a sizing guide.
python# WRONG — callbacks bind at definition time and do NOT propagate to subgraphs (P28) traced_parent = parent_graph.with_config(callbacks=[tracer]) traced_parent.invoke({"messages": [HumanMessage("...")], "session_id": "s1"}) # tracer fires on parent nodes only. Child tool calls are invisible. # RIGHT — callbacks pass via config at invocation time, propagating into every subgraph parent_graph.invoke( {"messages": [HumanMessage("...")], "session_id": "s1"}, config={ "configurable": {"thread_id": "s1"}, "callbacks": [tracer], }, ) # tracer fires on parent nodes AND every child tool, LLM, and chain event.
Every production invocation path — API handler, batch worker, test harness — should pass callbacks via config["callbacks"]. Lint for with_config(callbacks= on compiled graphs in CI and flag it.
See Callback Scoping for the debugging playbook when a callback "should be firing but isn't."
LangGraph's recursion_limit (default 25 supersteps) is per-graph, not global. A parent graph at superstep 20 invoking a subgraph resets the counter inside that subgraph to zero. Pros: one runaway subgraph cannot starve the parent. Cons: adding subgraphs does not reduce your global budget — a poorly bounded specialist can still rack up 25 of its own steps while the parent thinks it spent only one.
python# Parent gets 10 steps of its own planning. # Each executor call gets its own 15-step budget, independent of the parent's 10. parent_graph.invoke( initial_state, config={ "configurable": {"thread_id": "s1"}, "recursion_limit": 10, }, ) executor_graph.invoke( sub_state, config={"recursion_limit": 15}, )
For a parent that dispatches N specialists via Send, worst-case step count is parent_limit + N * specialist_limit. Monitor the actual distribution with a callback on on_chain_start / on_chain_end at each graph boundary — GraphRecursionError with no obvious loop is P55 in the pain catalog, and subgraph composition is the most common cause.
A subgraph that works alone and breaks in composition is almost always a state-contract bug (Step 1) or a callback-scoping bug (Step 3). Catch both by unit-testing each subgraph with a FakeListChatModel and an in-memory checkpointer before wiring it into a parent.
pythonfrom langchain_core.language_models.fake_chat_models import FakeListChatModel from langgraph.checkpoint.memory import MemorySaver def test_executor_subgraph_standalone(): fake = FakeListChatModel(responses=['{"done": true, "result": 42}']) graph = build_executor(llm=fake).compile(checkpointer=MemorySaver()) out = graph.invoke( {"messages": [HumanMessage("do the thing")], "session_id": "test", "tool_result": None, "retries": 0}, config={"configurable": {"thread_id": "test"}, "recursion_limit": 5}, ) # Assert the shared-contract fields (Step 1) are present on return assert "messages" in out assert out["session_id"] == "test" # Assert child-only field is scoped correctly assert out["tool_result"] == {"done": True, "result": 42}
See Testing Subgraphs for the full isolation pattern — fixtures, state-shape assertions per node, and how to assert callback propagation with a capturing handler.
A reusable subgraph should ship with its own semantic version and a pinned schema contract. Breaking either the shared-state contract (Step 1) or the dispatch signature (Step 2) is a major-version bump; adding a new private child-only field is a patch.
python# executor_subgraph/__init__.py __version__ = "1.2.0" SHARED_KEYS = frozenset({"messages", "session_id"}) # parent must declare these def build_executor(llm) -> StateGraph: """v1.2.0 executor — adds 'retries' field (child-only, backward-compatible).""" ...
Parents pin executor_subgraph>=1.2.0,<2.0.0. A v2.0.0 that renames session_id forces every parent to re-sync its TypedDict. This is how you catch P21 at pip install time instead of at runtime.
ParentState + per-subgraph child TypedDicts with the shared-key contractdeclared explicitly; every shared list field has a matching reducer
Send vs Command(graph=PARENT))with rationale recorded in code comments
callbacks viaconfig["callbacks"] so observability propagates into every subgraph
recursion_limit set explicitly on parent and each subgraph, with theworst-case superstep count documented
on return and callback propagation via a capturing handler
__version__ and a frozenSHARED_KEYS set
| Error / Symptom | Cause | Fix | |---|---|---| | Parent reads state["foo"] and gets None after subgraph call | Key declared only in child schema; discarded on return (P21) | Add foo to parent TypedDict; use matching reducer; or return Command(graph=Command.PARENT, update={"foo": ...}) | | Tracer attached to parent never fires on child tool calls | Callback bound at definition time via .with_config(callbacks=[...]) (P28) | Pass callbacks=[tracer] in config at each invoke() / ainvoke() call | | TypeError: unhashable type from the message reducer in the parent after a Send fan-out | Child schema used list instead of Annotated[list, add_messages] (P18) | Match reducers on both sides of the boundary | | GraphRecursionError: Recursion limit of 25 reached inside a subgraph only | Subgraph has its own 25-step budget; long sub-loop (P55) | Set config={"recursion_limit": N} explicitly or restructure subgraph | | Subgraph returns state but parent sees empty messages | List field not using add_messages reducer on the parent side (P18) | messages: Annotated[list[AnyMessage], add_messages] in both TypedDicts | | Command(goto="next_node") halts instead of continuing | Command did not set graph=Command.PARENT — tried to goto a parent node from inside the child scope | Use Command(graph=Command.PARENT, goto="next_node", update=...) | | Subgraph runs but thread state never persists | Checkpointer attached only to parent; child invoke() without thread_id | Compile subgraphs with checkpointer too, or invoke with a thread_id matching the parent |
The prototypical 1 + 2-4 composition: a planner writes a step list, an executor runs each step, a finalizer summarizes. messages and session_id cross every boundary; plan is parent-only; tool_result and retries are executor-only. Uses a compiled-subgraph-as-node dispatch (Step 2A). See Dispatch Patterns for the full worked example.
A supervisor picks which specialists to invoke and calls Send("specialist", ...) in parallel. Each specialist runs its own subgraph with its own recursion budget. Results merge via the messages reducer. See Dispatch Patterns for the Send-based fan-out and the callback propagation pattern needed to trace the team.
An executor_subgraph package exports build_executor(llm) and a frozen SHARED_KEYS set, ships with a semantic version, and its CI fails if SHARED_KEYS changes without a major-version bump. See State Contract for the schema-pinning pattern.
A CaptureHandler subclasses BaseCallbackHandler, appends every on_* event to a list, and asserts both parent and child events are present after a single parent_graph.invoke(..., config={"callbacks": [handler]}). Catches P28 at PR review. See Testing Subgraphs for the full fixture and assertion pattern.
Send API referenceCommand API referencelangchain-langgraph-basics (L25) — prerequisite StateGraph, reducers, checkpointingdocs/pain-catalog.md (entries P18, P19, P21, P28, P55)| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 46,227 | 31,059 | -33% | 1 | 1 | 0% | 8,316 | 9,234 | +11% | 0 | 0 | — |
case-02 | fail→pass | 56,240 | 31,261 | -44% | 1 | 1 | 0% | 8,276 | 9,602 | +16% | 0 | 0 | — |
case-03 | fail→pass | 34,850 | 23,195 | -33% | 1 | 1 | 0% | 7,618 | 9,014 | +18% | 0 | 0 | — |
case-04 | pass→pass | 22,750 | 19,822 | -13% | 1 | 1 | 0% | 3,520 | 7,028 | +100% | 0 | 0 | — |
case-05 | pass→pass | 8,408 | 8,491 | +1% | 1 | 1 | 0% | 1,643 | 5,784 | +252% | 0 | 0 | — |
case-06 | pass→pass | 8,826 | 13,690 | +55% | 1 | 1 | 0% | 1,837 | 5,986 | +226% | 0 | 0 | — |
case-07 | pass→pass | 23,271 | 25,564 | +10% | 1 | 1 | 0% | 4,368 | 8,303 | +90% | 0 | 0 | — |
case-08 | fail→pass | 15,621 | 17,239 | +10% | 1 | 1 | 0% | 2,970 | 6,397 | +115% | 0 | 0 | — |
case-09 | fail→pass | 17,546 | 17,621 | +0% | 1 | 1 | 0% | 2,195 | 6,658 | +203% | 0 | 0 | — |
case-10 | fail→pass | 19,805 | 16,187 | -18% | 1 | 1 | 0% | 2,656 | 7,086 | +167% | 0 | 0 | — |
case-11 | pass→pass | 14,336 | 19,074 | +33% | 1 | 1 | 0% | 2,590 | 6,425 | +148% | 0 | 0 | — |
case-12 | pass→pass | 22,872 | 18,877 | -17% | 1 | 1 | 0% | 2,506 | 7,986 | +219% | 0 | 0 | — |
case-13 | fail→pass | 17,424 | 12,188 | -30% | 1 | 1 | 0% | 2,089 | 6,401 | +206% | 0 | 0 | — |
case-14 | fail→fail | 21,483 | 25,750 | +20% | 1 | 1 | 0% | 3,126 | 8,168 | +161% | 0 | 0 | — |
case-15 | pass→pass | 16,054 | 15,397 | -4% | 1 | 1 | 0% | 1,932 | 6,018 | +211% | 0 | 0 | — |
case-16 | pass→pass | 21,430 | 24,143 | +13% | 1 | 1 | 0% | 4,321 | 8,046 | +86% | 0 | 0 | — |
case-17 | pass→pass | 16,896 | 15,550 | -8% | 1 | 1 | 0% | 2,261 | 6,274 | +177% | 0 | 0 | — |
case-18 | pass→pass | 19,963 | 17,216 | -14% | 1 | 1 | 0% | 2,379 | 7,375 | +210% | 0 | 0 | — |
case-19 | pass→pass | 7,478 | 12,532 | +68% | 1 | 1 | 0% | 1,294 | 5,541 | +328% | 0 | 0 | — |
case-20 | fail→pass | 16,557 | 15,168 | -8% | 1 | 1 | 0% | 1,836 | 6,116 | +233% | 0 | 0 | — |
case-21 | fail→fail | 12,261 | 16,485 | +34% | 1 | 1 | 0% | 2,120 | 6,168 | +191% | 0 | 0 | — |
case-22 | pass→pass | 12,851 | 13,468 | +5% | 1 | 1 | 0% | 2,249 | 5,798 | +158% | 0 | 0 | — |
case-23 | pass→pass | 20,642 | 15,415 | -25% | 1 | 1 | 0% | 2,510 | 6,048 | +141% | 0 | 0 | — |
case-24 | fail→pass | 11,573 | 9,273 | -20% | 1 | 1 | 0% | 2,021 | 5,679 | +181% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 24 cases were attempted. The headline lift of +38 percentage points is the difference between those two pass rates over the 24 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.