"Compose LangGraph 1.0 subgraphs correctly \u2014 shared state key propagation,\n\
Scanned 9/2/2026
Install to Claude Code
npx -y skills add jeremylongshore/tons-of-skills-marketplace --skill langchain-langgraph-subgraphs --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Langchain Langgraph Subgraphs?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/jeremylongshore-langchain-langgraph-subgraphs-90ec5c10)More formats (shields.io, HTML) on the badges page.
---
name: langchain-langgraph-subgraphs
description: "Compose LangGraph 1.0 subgraphs correctly \u2014 shared state key propagation,\n\
Send / Command(graph=...) dispatch, callback scoping, per-subgraph recursion\nbudgets,\
\ and testing each subgraph in isolation. Use when building a planner +\nexecutor,\
\ a nested agent team, or a reusable subgraph library. Trigger with\n\"langgraph\
\ subgraph\", \"langgraph composition\", \"langgraph send\",\n\"nested agents\"\
, \"langgraph state propagation\", \"Command(graph=...)\",\n\"langgraph subgraph\
\ callbacks\".\n"
allowed-tools: Read, Write, Edit, Bash(python:*)
version: 2.5.0
license: MIT
author: Jeremy Longshore <jeremy@intentsolutions.io>
tags:
- saas
- langchain
- langgraph
- python
- langchain-1.0
- subgraphs
- composition
compatibility: Designed for Claude Code
---
# LangGraph Subgraphs and Composition (Python)
## Overview
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.
## Prerequisites
- Python 3.10+
- `langgraph >= 1.0, < 2.0`
- `langchain-core >= 1.0, < 2.0`
- Completion of `langchain-langgraph-basics` (L25) — `StateGraph`, `TypedDict`
state, `Annotated[list, add_messages]` reducer, `MemorySaver` checkpointing
- Test tooling: `pytest`, `langchain_core.language_models.fake_chat_models.FakeListChatModel`
## Instructions
### Step 1 — Declare the shared-state contract explicitly
The 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.
```python
from 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](references/state-contract.md) for the full
state-propagation matrix and the "subset rule" for schema inheritance.
### Step 2 — Pick the dispatch pattern
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.
```python
from 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.
```python
from 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.
```python
from 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](references/dispatch-patterns.md) for the full decision
tree (inline function vs subgraph-as-node vs `Send` vs `Command` vs separate
service) and a sizing guide.
### Step 3 — Scope callbacks at invocation time, not definition time
```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](references/callback-scoping.md) for the debugging
playbook when a callback "should be firing but isn't."
### Step 4 — Budget recursion per subgraph
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.
### Step 5 — Test every subgraph in isolation before composing
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.
```python
from 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](references/testing-subgraphs.md) for the full isolation
pattern — fixtures, state-shape assertions per node, and how to assert callback
propagation with a capturing handler.
### Step 6 — Version subgraphs independently of the parent
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.
## Output
- `ParentState` + per-subgraph child `TypedDict`s with the shared-key contract
declared explicitly; every shared list field has a matching reducer
- Dispatch pattern chosen per subgraph (node vs `Send` vs `Command(graph=PARENT)`)
with rationale recorded in code comments
- Every invocation path (API, batch, test) passes `callbacks` via
`config["callbacks"]` so observability propagates into every subgraph
- `recursion_limit` set explicitly on parent and each subgraph, with the
worst-case superstep count documented
- Unit tests for each subgraph in isolation, asserting shared-contract fields
on return and callback propagation via a capturing handler
- Reusable subgraphs ship with their own `__version__` and a frozen
`SHARED_KEYS` set
## Error Handling
| 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 `TypedDict`s |
| `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 |
## Examples
### Planner-executor — shared messages, private scratch
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](references/dispatch-patterns.md) for the full worked example.
### Hierarchical agent team — supervisor fans out to N specialists
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](references/dispatch-patterns.md) for the `Send`-based
fan-out and the callback propagation pattern needed to trace the team.
### Reusable subgraph library — pinned contract with versioned exports
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](references/state-contract.md) for the schema-pinning pattern.
### Testing a specialist with a capturing callback
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](references/testing-subgraphs.md) for the full
fixture and assertion pattern.
## Resources
- [LangGraph subgraph concepts](https://langchain-ai.github.io/langgraph/concepts/subgraphs/)
- [LangGraph subgraph how-to](https://langchain-ai.github.io/langgraph/how-tos/subgraph/)
- [`Send` API reference](https://langchain-ai.github.io/langgraph/reference/types/#langgraph.types.Send)
- [`Command` API reference](https://langchain-ai.github.io/langgraph/reference/types/#langgraph.types.Command)
- [LangGraph callback propagation](https://langchain-ai.github.io/langgraph/how-tos/streaming/)
- [LangChain 1.0 release notes](https://blog.langchain.com/langchain-langgraph-1dot0/)
- Sibling skill: `langchain-langgraph-basics` (L25) — prerequisite `StateGraph`, reducers, checkpointing
- Pack pain catalog: `docs/pain-catalog.md` (entries P18, P19, P21, P28, P55)
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!