Subagents
Delegation in Deep Agents is one tool. SubAgentMiddleware registers a tool named task taking { description, subagent_type }, and each call runs a real child graph with its own system prompt, its own tools, and its own transcript. Nothing about that is special on the wire — it is an ordinary tool call — and yet the browser renders it as a child agent, because task is the name the subagent tracker already watches for. The running example is an aviation dispatch desk, and this page walks the three files behind it.
What the demo does
The Run tab shows the prebuilt <chat> composition beside a sidebar holding the specialist roster and a live dispatch count. The first welcome suggestion asks for a brief on KASE and KDEN, covering field data and weather at both, and the orchestrator answers by dispatching four specialists in a single turn: four cards appear in the conversation at once, each expanded while its child works and collapsed to its header row when it reports. The second suggestion asks for the field data at KSFO alone, which is one dispatch and one card, so the two suggestions are the fan-out and the single case side by side. The orchestrator has no lookup tools of its own, so there is no run in which it answers without delegating.
How it is built
Three files carry the capability: a Python graph holding the lookup tools, the specialist specs, and the agent; an application config; and the Angular component that reads the dispatches into a sidebar. Open the Code tab to read them in place.
The tools the specialists own
Each lookup answers one question about one airport from a fixed table, so a recorded run stays stable. These tools belong to the children, never to the orchestrator.
@tool
def lookup_field_elevation(airport: str) -> str:
"""Return the field elevation in feet for a four-letter ICAO airport code."""
elevation = FIELD_ELEVATION_FT.get(airport.upper())
if elevation is None:
return f"No field elevation on file for {airport.upper()}."
return f"{airport.upper()} field elevation is {elevation} ft."
@tool
def lookup_runway_length(airport: str) -> str:
"""Return the longest runway length in feet for a four-letter ICAO airport code."""
length = RUNWAY_LENGTH_FT.get(airport.upper())
if length is None:
return f"No runway data on file for {airport.upper()}."
return f"{airport.upper()} longest runway is {length} ft."
@tool
def lookup_weather(airport: str) -> str:
"""Return the current field conditions for a four-letter ICAO airport code."""
conditions = WEATHER.get(airport.upper())
if conditions is None:
return f"No observation on file for {airport.upper()}."
return f"{airport.upper()}: {conditions}"
Declaring a specialist
A SubAgent is a TypedDict with three required fields and a handful of optional ones. name is what the orchestrator passes as subagent_type, description is what the orchestrator reads when it decides where to send work, and system_prompt governs the child once it starts. Handing each spec its own tools is what splits the two jobs apart.
FIELD_RESEARCHER: SubAgent = {
"name": "field-researcher",
"description": "Gathers field elevation and runway length for one airport.",
"system_prompt": (
"You research airport field data for a dispatch desk. Use "
"lookup_field_elevation and lookup_runway_length for the airport you were "
"given. Reply with two sentences: the numbers, and whether the runway is "
"long enough for a mid-size business jet at that elevation."
),
"tools": [lookup_field_elevation, lookup_runway_length],
}
WEATHER_ANALYST: SubAgent = {
"name": "weather-analyst",
"description": "Reads the current conditions for one airport and calls out the operational impact.",
"system_prompt": (
"You read weather for a dispatch desk. Use lookup_weather for the airport "
"you were given. Reply with two sentences: the conditions, and what they "
"mean for a departure or arrival there."
),
"tools": [lookup_weather],
}Omitting tools would inherit the main agent's tools instead, which is the opposite of what this demo wants.
Installing the task tool
create_deep_agent assembles a middleware stack, and SubAgentMiddleware is already on it: the factory adds a general-purpose subagent unless that one is explicitly disabled, so the task tool ships by default. Passing subagents is what puts your own specialists on that tool. The demo passes no tools of its own, so delegation is the only path to an answer.
def build_subagents_agent():
"""Build the orchestrator.
`SubAgentMiddleware` and the `task` tool ship by default through the
`general-purpose` subagent; passing `subagents` puts these specialists on
that tool. The orchestrator gets no lookup tools of its own so it has no
way to answer without delegating.
"""
return create_deep_agent(
model=ChatOpenAI(model="gpt-4.1", temperature=0),
system_prompt=(PROMPTS_DIR / "subagents.md").read_text(),
subagents=[FIELD_RESEARCHER, WEATHER_ANALYST],
)
graph = build_subagents_agent()Unless it is explicitly disabled, create_deep_agent inserts a general-purpose subagent ahead of the specs you pass, with the main agent's model and tools. The task tool description therefore lists three agent types rather than two. The demo does not disable it; the system prompt simply names the two specialists it wants, which is enough to keep the orchestrator on them.
Asking for the dispatches in one turn
The middleware supplies a tool, not a policy. A model left to itself will dispatch one specialist, wait for the report, and dispatch the next, which is correct but produces a run with nothing to see. Two paragraphs in prompts/subagents.md change the shape of the run instead.
Give each dispatch a `description` that names the airport and says exactly what
you want back. One airport per dispatch — never ask a specialist to cover two.
When a request covers more than one airport or more than one kind of data,
issue every dispatch you need **in a single turn** so the specialists work in
parallel. Do not wait for one to report before sending the next.Both halves matter, and the first one matters for a reason worth reading the next section for.
Providing the agent
provideAgent() from @threadplane/langgraph registers the agent at the application root, and it is the only provider the <chat> composition requires. subagentToolNames: ['task'] is already the default, so the line changes nothing at runtime and is there to say which tool call means that a child started. The example resolves its connection at runtime because the host that serves the demo decides which runtime is attached; your own application passes apiUrl and assistantId directly.
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
export const appConfig: ApplicationConfig = {
providers: [
provideAgent(() => {
const connection = injectCockpitRuntimeConnection();
if (connection.adapter !== 'langgraph') {
throw new Error('incompatible runtime');
}
return {
apiUrl: connection.apiUrl,
assistantId: connection.assistantId,
clientOptions: connection.clientOptions,
// `SubAgentMiddleware` dispatches every child through one tool named
// `task`, carrying `{description, subagent_type}`. That name is also the
// SubagentTracker's default, so this line changes nothing at runtime —
// it is here to say out loud which tool call means "a child agent
// started". Set it when your dispatch tool is named something else;
// overriding it with a name the graph never calls is what turns the
// cards back into generic tool chips.
subagentToolNames: ['task'],
};
}),
],
};Reading the dispatches
injectAgent() returns the agent, and agent.subagents() is a signal holding a map of the dispatches in the current thread. The sidebar spreads that map into an array once and derives both numbers from it, so the two counts can never disagree.
private readonly dispatches = computed(() => [...this.agent.subagents().values()]);
protected readonly dispatchCount = computed(() => this.dispatches().length);
protected readonly runningCount = computed(
() => this.dispatches().filter((subagent) => subagent.status() === 'running').length,
);status is itself a signal on each record, so it is called rather than read.
The sidebar
The <chat> composition already renders every dispatch as a card inside the conversation, so a second tray of the same cards would only duplicate it. The sidebar spends its space on the two things the cards do not show: how wide the fan-out went, and who the specialists are.
<div sidebar class="panel">
<h3 class="cap">Dispatches</h3>
<p class="count" data-testid="dispatch-count">
{{ dispatchCount() }} dispatched, {{ runningCount() }} running
</p>
<h3 class="cap">Specialists</h3>
<ul class="roster">
<li><span class="roster__name">field-researcher</span> — elevation and runway length</li>
<li><span class="roster__name">weather-analyst</span> — conditions and operational impact</li>
</ul>
</div>How a dispatch becomes a card
Two independent things have to happen for a task call to render as a child agent, and they come from opposite ends of the run.
The first is registration. When the orchestrator's message carries a task tool call, the tracker records a dispatch keyed by the tool-call id, reading subagent_type out of the arguments. That name becomes the card's label, and a call with no usable subagent_type is ignored rather than tracked. Registration is also what makes the card its own thing in the transcript: <chat-tool-calls> groups adjacent calls of the same name into one collapsed strip, but a call that spawned a subagent is always its own group, so four task calls in one turn stay four cards instead of collapsing into a single grouped strip. A dispatch is invisible while it is still pending — the map behind subagents() filters those out — so those four calls briefly render as one grouped tool-call strip before their cards appear.
The second is attribution: deciding which child stream belongs to which dispatch. This is the part that is not free. A child runs under a tools:<id> namespace whose id is a checkpoint identifier assigned independently of the parent's tool-call id, and the two are not linked anywhere on the wire. So the adapter matches on content instead. SubAgentMiddleware seeds each child with a single human message whose content is the dispatch description, verbatim, and the adapter matches that first message against the description argument of each unclaimed dispatch — exactly first, then by containment either way.
The deepagents task tool cannot announce its binding, so inside this graph the description is the only signal that survives fan-out; a graph that announces its children through the Threadplane middleware gets an exact binding instead. With one child outstanding the adapter can fall back to claiming the single unmatched dispatch, but with four outstanding at once there is nothing positional to fall back on: arrival order is not dispatch order, and guessing would put one specialist's transcript in another specialist's card. Descriptions that name their airport keep the four apart. Two dispatches whose descriptions are identical give the ladder nothing to tell them apart: the exact rung claims the first unmapped dispatch, so the second child lands on the wrong card rather than on none.
Until a stream is attributed its chunks are held rather than dropped, and they are replayed into the card the moment the match lands. A dispatch that never matches shows an empty card, which is a worse card but never a wrong one.
A dispatch description is both the child's opening instruction and the key the adapter attributes its output by. A vague description costs twice: the child starts with less to go on, and its stream is harder to tell from its siblings.
What's Next
The orchestrator's own todo list, which pairs naturally with delegation.
The card component on its own, including what it renders for a child.
How namespaced child execution is attributed underneath the tracker.
The workspace the agent writes into while it works.