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.
iSubgraphs 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, StateGraphfrom langchain_openai import ChatOpenAIllm = 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, TypedDictfrom langgraph.graph import END, START, StateGraphfrom langgraph.graph.message import add_messagesclass ResearchState(TypedDict): """Child state — deliberately has no `messages` key.""" research_topic: str research_brief: strclass OrchestratorState(TypedDict): """Parent state — the transcript plus the shared channel.""" messages: Annotated[list, add_messages] research_topic: str research_brief: strasync 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 nodeparent.add_node("answer", answer_node) # the only node that writes messagesparent.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().
iState 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 onesconst running = computed(() => [...orchestrator.subagents().values()].filter((subagent) => subagent.status() === 'pending' || subagent.status() === 'running' ));const runningCount = computed(() => running().length);// Lookup helpers for common UI pathsconst specific = computed(() => orchestrator.getSubagent('research-tool-call-id'));const researchers = computed(() => orchestrator.getSubagentsByType('researcher'));// React to count changeseffect(() => { 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 IDconst researchAgent = computed(() => orchestrator.getSubagent('research-tool-call-id'));// Or get the subagents spawned by a specific AI message with tool callsconst messageAgents = computed(() => { const message = selectedAiMessage(); return message ? orchestrator.getSubagentsByMessage(message) : [];});// Track its progressconst 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.
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 reactivelyconst 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
iChoosing 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.