LangGraph · Guides

Subgraphs

Subgraphs let you compose larger agents from smaller, focused units. injectAgent() streams their output through the same message, state, tool-call, and custom-event signals as the parent graph.

Subgraphs vs subagents

LangGraph subgraphs are graph nodes. Deep Agents-style subagents are delegated tool calls. injectAgent() requests subgraph streams by default, but the subagents() signal is populated only for tool calls whose names match subagentToolNames and whose args include a subagent_type.

How subgraph composition works

Subgraph composition starts on the agent side. Each subgraph is a fully compiled StateGraph that can be added as a node in a parent graph.

from langgraph.graph import END, START, MessagesState, StateGraph
from langchain_openai import ChatOpenAI
 
llm = ChatOpenAI(model="gpt-5-mini")
 
# --- Research subgraph ---
def search_web(state: MessagesState) -> dict:
    query = state["messages"][-1].content
    results = web_search(query)
    return {"messages": [{"role": "assistant", "content": results}]}
 
def summarize_results(state: MessagesState) -> dict:
    response = llm.invoke(state["messages"])
    return {"messages": [response]}
 
research_builder = StateGraph(MessagesState)
research_builder.add_node("search", search_web)
research_builder.add_node("summarize", summarize_results)
research_builder.add_edge(START, "search")
research_builder.add_edge("search", "summarize")
research_builder.add_edge("summarize", END)
 
research_subgraph = research_builder.compile()
 
# --- Analysis subgraph ---
def analyze_data(state: MessagesState) -> dict:
    response = llm.invoke([
        {"role": "system", "content": "Analyze the data and provide insights."},
        *state["messages"],
    ])
    return {"messages": [response]}
 
analysis_builder = StateGraph(MessagesState)
analysis_builder.add_node("analyze", analyze_data)
analysis_builder.add_edge(START, "analyze")
analysis_builder.add_edge("analyze", END)
 
analysis_subgraph = analysis_builder.compile()
 
# --- Parent orchestrator ---
def route_task(state: MessagesState) -> str:
    last = state["messages"][-1].content.lower()
    if "research" in last or "search" in last:
        return "research"
    return "analyze"
 
builder = StateGraph(MessagesState)
builder.add_node("research", research_subgraph)
builder.add_node("analyze", analysis_subgraph)
builder.add_conditional_edges(START, route_task)
builder.add_edge("research", END)
builder.add_edge("analyze", END)
 
graph = builder.compile()
Child messages land in the parent transcript

Both graphs above share MessagesState, so the child appends to the same message list the parent is building — its intermediate output renders as its own chat bubble. filterSubagentMessages does not help here: that option is only consulted for tools:-namespaced streams, and a plain subgraph node emits research:<uuid>. The lever for this shape is transcriptNodeNames, which whitelists the graph nodes whose messages count as transcript.

The leak is mid-stream with a clean end state — the parent's final values event rewrites the message list from authoritative graph state, so the stray bubble disappears once the run settles. A final-state test cannot catch it.

Giving the child its own state

Adding a compiled graph as a node does not isolate state. If you want a real boundary, design one: give the child its own state schema and share only the keys you want crossing it. LangGraph passes a subgraph node through the keys the two schemas have in common.

from typing import Annotated, TypedDict
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
 
class ResearchState(TypedDict):
    """Child state — deliberately has no `messages` key."""
    research_topic: str
    research_brief: str
 
class OrchestratorState(TypedDict):
    """Parent state — the transcript plus the shared channel."""
    messages: Annotated[list, add_messages]
    research_topic: str
    research_brief: str
 
async def research_node(state: ResearchState) -> dict:
    # Receives a topic, returns a brief. No transcript access.
    brief = await researcher.ainvoke(f"Topic: {state['research_topic']}")
    return {"research_brief": brief.content}
 
research_graph = StateGraph(ResearchState)
research_graph.add_node("research", research_node)
research_graph.add_edge(START, "research")
research_graph.add_edge("research", END)
compiled_research = research_graph.compile()
 
def route_after_orchestrate(state: OrchestratorState) -> str:
    # Writing a topic is what triggers delegation.
    return "research" if state.get("research_topic") else "answer"
 
parent = StateGraph(OrchestratorState)
parent.add_node("orchestrate", orchestrate_node)
parent.add_node("research", compiled_research)  # the compiled graph IS the node
parent.add_node("answer", answer_node)          # the only node that writes messages
parent.add_edge(START, "orchestrate")
parent.add_conditional_edges(
    "orchestrate", route_after_orchestrate, {"research": "research", "answer": "answer"}
)
parent.add_edge("research", "answer")
parent.add_edge("answer", END)
graph = parent.compile()

Because ResearchState has no messages key, the child cannot read the transcript or append to it — its brief reaches the parent through research_brief and never becomes a chat message. Pair it with transcriptNodeNames: ['answer'] so only the parent's answering node streams into messages().

State type parameters

OrchestratorState and PipelineState below are placeholders for your own graph's state schema — the shape your subgraph's StateGraph produces. They mirror the Python state the same way ChatState does on the State Management page. Use createAgentRef<YourState>('your-assistant-id') to create a typed ref, then pass it to both provideAgent() and injectAgent().

Tracking delegated subagent execution

The subagents() signal contains a Map of active delegated subagent streams. Use it when your graph delegates through tool calls, such as Deep Agents' default task tool or your own delegation tools. Plain subgraph nodes do not appear in this map.

// In a shared file (e.g. agent.ts):
// import { createAgentRef } from '@threadplane/chat';
// export const ORCHESTRATOR = createAgentRef<OrchestratorState>('orchestrator');
 
// Configure in app.config.ts:
// provideAgent(ORCHESTRATOR, {
//   apiUrl: '...',
//   subagentToolNames: ['task', 'delegate_to_researcher'],
// });
 
const orchestrator = injectAgent(ORCHESTRATOR);
 
// All subagent streams (active and completed)
const subagents = computed(() => orchestrator.subagents());
 
// Only active ones
const running = computed(() =>
  [...orchestrator.subagents().values()].filter((subagent) =>
    subagent.status() === 'pending' || subagent.status() === 'running'
  )
);
const runningCount = computed(() => running().length);
 
// Lookup helpers for common UI paths
const specific = computed(() => orchestrator.getSubagent('research-tool-call-id'));
const researchers = computed(() =>
  orchestrator.getSubagentsByType('researcher')
);
 
// React to count changes
effect(() => {
  console.log(`${runningCount()} subagents currently running`);
});

Subagent stream details

Each SubagentStreamRef exposes its own reactive signals — status, messages, and state — so you can surface granular progress in your UI.

// Access a specific subagent by its tool call ID
const researchAgent = computed(() =>
  orchestrator.getSubagent('research-tool-call-id')
);
 
// Or get the subagents spawned by a specific AI message with tool calls
const messageAgents = computed(() => {
  const message = selectedAiMessage();
  return message ? orchestrator.getSubagentsByMessage(message) : [];
});
 
// Track its progress
const researchStatus = computed(() => researchAgent()?.status());
const researchMessages = computed(() => researchAgent()?.messages() ?? []);

Orchestrator pattern

The orchestrator pattern delegates specialised work to subagents and merges their results. Each subagent runs its own graph independently while the parent coordinates the whole.

// In a shared file (e.g. agent.ts):
// import { createAgentRef } from '@threadplane/chat';
// export const PIPELINE = createAgentRef<PipelineState>('pipeline-orchestrator');
 
// Configure in app.config.ts:
// provideAgent(PIPELINE, {
//   apiUrl: '...',
//   subagentToolNames: ['task'],
//   filterSubagentMessages: true,
// });
 
const pipeline = injectAgent(PIPELINE);
 
// Derive a summary of all subagent states
const pipelineStatus = computed(() => {
  const agents = pipeline.subagents();
  const entries = [...agents.entries()];
 
  return {
    total: entries.length,
    pending: entries.filter(([, a]) => a.status() === 'pending').length,
    running: entries.filter(([, a]) => a.status() === 'running').length,
    done: entries.filter(([, a]) => a.status() === 'complete').length,
    failed: entries.filter(([, a]) => a.status() === 'error').length,
  };
});

Subagent progress UI

Render live progress for each subagent using the signals above.

import { Component, computed, ChangeDetectionStrategy } from '@angular/core';
import { injectAgent } from '@threadplane/langgraph';
import { ORCHESTRATOR } from './agent'; // createAgentRef<OrchestratorState>('orchestrator')
 
@Component({
  selector: 'app-subagent-progress',
  templateUrl: './progress-panel.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SubagentProgressComponent {
  protected readonly orchestrator = injectAgent(ORCHESTRATOR);
 
  subagentEntries = computed(() =>
    [...this.orchestrator.subagents().entries()]
  );
}

Filtering subagent messages

By default, subagent messages appear in the parent's messages() signal. Filter them out for a cleaner parent view.

This applies to tool-dispatched subagents — the tools:-namespaced streams that populate subagents(). For a plain subgraph node, use transcriptNodeNames instead; filterSubagentMessages has no effect on that shape.

// In a shared file (e.g. agent.ts):
// import { createAgentRef } from '@threadplane/chat';
// export const ORCHESTRATOR = createAgentRef<OrchestratorState>('orchestrator');
 
// Configure in app.config.ts:
// provideAgent(ORCHESTRATOR, {
//   apiUrl: '...',
//   filterSubagentMessages: true,  // Hide subagent messages from parent
//   subagentToolNames: ['task'],
// });
 
const orchestrator = injectAgent(ORCHESTRATOR);
 
// Parent messages only (no subagent chatter)
const parentMessages = computed(() => orchestrator.messages());
Subagent tool names

Set subagentToolNames to the tool names that spawn subagents. injectAgent() uses this to identify tool calls that create subagent streams.

Registration is skipped silently unless the tool call also carries a valid subagent_type argument: a string of 3-50 characters, starting with a letter, containing only letters, digits, _, or -. A value like qa (too short) or 2nd_pass (leading digit) produces no subagent and no error, so subagents() stays empty with nothing in the console to explain it.

Error handling per subagent

Each subagent exposes its own status() signal. A failure changes that subagent's status to 'error' without necessarily stopping sibling delegates.

// Collect all failed subagents reactively
const failedAgents = computed(() =>
  [...orchestrator.subagents().entries()].filter(
    ([, agent]) => agent.status() === 'error'
  )
);
 
// One effect over the derived list — it re-runs as subagents appear and fail.
effect(() => {
  for (const [id] of failedAgents()) {
    console.error(`Subagent ${id} failed`);
    // Retry, surface to user, or fall back gracefully
  }
});

Derive the list first, then react to it. Looping over a subagents() snapshot to create one effect() per entry does not work: the read happens outside a reactive context so it never re-runs, subagents that appear later never get an effect, and effect() needs an injection context.

Partial failures

Always check failedAgents() before presenting final results. A completed orchestrator can still have subagents that errored — success at the top level does not guarantee all delegates succeeded.

When to use subagents vs a single agent

Choosing your architecture

Use subagents when tasks are independent and can run in parallel, when each task needs its own context window, or when you want isolated error boundaries. Use a single agent for sequential reasoning, tasks that share tightly coupled state, or when latency from spawning subagents outweighs the parallelism benefit.

None of those three come from compiling a child graph. A narrow context window follows from what you pass into the child, an error boundary from how the parent handles a failed delegation, and state isolation from giving the child its own schema. Compiling buys you nested execution and a namespace; the rest is yours to design. See the decision matrix.

What's Next