Subagents
Many agent frameworks let an agent delegate work to child agents — a supervisor dispatching research tasks, an agents-as-tools pattern where a tool call is a nested agent, or a planner farming out subtasks in parallel. To a frontend, all of that arrives as one event stream. Without extra information there is no way to tell which subagent produced a given message, so three concurrent researchers render as one undifferentiated wall of text. AG-UI’s subagent support solves exactly that problem and nothing more: it attributes each event to the subagent that produced it, and reports when subagents start and stop. It does not orchestrate, schedule, or define subagents — that stays entirely with the framework.subagentRunId identifies an invocation, not a definition
This is the single most important thing to understand, and the easiest thing to
get wrong.
subagentRunId is an opaque handle for one invocation of a subagent. Run the
same subagent twice and you get two different values. It is not a stable
identifier for a reusable subagent definition, and it is not a name.
The symmetry with the top-level run makes the distinction clearer:
So:
- Do key transient UI state — a collapsible group, a spinner, a progress row —
by
subagentRunId. - Do not persist anything by
subagentRunIdexpecting it to be meaningful on a later run, and do not treat two invocations of one subagent as sharing a value. Usenamewhen you mean “which kind of subagent this is”. - One exception: a subagent that finished with
outcome: { type: "suspended" }may reuse its id on a resuming run. Producers that can correlate the resumed work with the suspended invocation should do so, and a client must treat that laterSubagentStartedas a continuation (waiting → running), never as a duplicate. A producer that cannot correlate mints a new id — the client’s waiting state then resolves through the interrupt ids it answered, not through id reuse (see Suspension).
This field was called
subagentId in prerelease builds. It was renamed because
the old name implied a reusable definition. If you are on a canary build that
still uses subagentId, the value has the same meaning — only the name changed.Lifecycle events
Three events bracket a subagent’s activity.SubagentStarted
Announces a new subagent invocation and gives it a name a UI can display.parentToolCallId and parentMessageId exist for the agents-as-tools pattern.
They let a client correlate a subagent to the call that created it — to render
the subagent’s output inside the tool-call card, for instance — without having to
inspect rawEvent.
SubagentFinished
Closes a subagent invocation’s stream segment for this run.Suspension
A subagent can pause mid-task waiting for outside input — a human approval raised inside it, say. The run then ends with an interrupt outcome (see Interrupts), and because every started subagent closes beforeRunFinished, the paused subagent still emits SubagentFinished — but
with outcome: { type: "suspended" }, so a UI can render “waiting” instead of
“done”. interruptIds names the run-level interrupts this subagent directly
owns (each such Interrupt also carries subagentRunId back-reference); it
may be empty or omitted for an ancestor suspended because a descendant
interrupted.
Suspension is the one case where a subagentRunId may deliberately span
runs — with different obligations on each side:
- Clients must accept a later run’s
SubagentStartedthat reuses a suspended invocation’s id, and treat it as a continuation — transition the existing group from waiting back to running, never render a duplicate. - Producers should reuse the id when they can correlate the resumed work with the suspended invocation. The LangGraph integration does: its ids derive from checkpoint task identity, which survives the pause. Reuse is what lets a client continue the group seamlessly.
- Producers that cannot correlate mint a new id, and that is valid. The
client’s waiting state does not dangle: the suspended outcome’s
interruptIdsand the resume entries the client itself sent identify which approvals were answered, so the waiting badge resolves on that basis even if no continuation arrives under the old id.
SubagentError
Marks a subagent invocation as failed.Attribution
Beyond the lifecycle events, most events can carry an optionalsubagentRunId
saying who produced them.
An event with no subagentRunId belongs to the parent agent. Attribution is
additive: a stream that never sets the field behaves exactly as it did before
subagents existed.
Attribution also stands on its own. A producer may tag events without ever
emitting SubagentStarted / SubagentFinished — enough for a UI to group output
by producer, without committing to reporting lifecycle. Clients must accept an
identifier they have never seen announced; see
Rules clients enforce.
Events that can carry attribution: the text message family, the tool call
family, activity events, the reasoning family, step events, state events, and
Raw/Custom.
Events that cannot: RunStarted, RunFinished, RunError — these describe
the run as a whole — and MessagesSnapshot, which carries attribution
per-message instead, since one snapshot mixes messages from several producers.
Attribution is provenance, not ownership
This distinction matters most on state events, so it is worth stating plainly.StateSnapshot and StateDelta are attributable. Attribution on them records
which subagent produced the update — it does not mean the subagent has state
of its own. AG-UI state is run-scoped: there is one state document for the run,
and an attributed snapshot or delta is still applied to that one document. The
tag is provenance you can surface (“the researcher updated the shared
scratchpad”), not a separate scope.
That is exactly the meaning attribution carries on the other standalone events.
Nobody reads an attributed Custom event as the subagent having private custom
events, and state is no different.
Note that a producer is never obliged to attribute state. Some do not: the
LangGraph integration, for instance, does not emit state while a subagent is
active, because its state is one shared document and a mid-delegation snapshot
would carry a partial view. That is a reasonable producer-side choice, not a
protocol requirement.
Attribution transfers to messages
When an attributed event creates a message, thesubagentRunId transfers onto
that message. This is what lets a rendering layer group a conversation by
subagent without replaying the event stream — the messages themselves carry
their origin, and they keep it across turns and snapshots.
Tool results carry their own attribution
ToolCallResult is attributed independently of the call it answers, and that is
intentional rather than an oversight: the party that executes a tool call can
differ from the subagent that requested it. A frontend-executed tool, or a
supervisor running a call on a subagent’s behalf, both produce a result whose
owner is not the caller. Inheriting the caller’s attribution would misreport
those cases, so each result states its own.
Nesting and concurrency
Subagents nest.parentSubagentRunId on SubagentStarted links a child to its
enclosing subagent; a subagent with no parent link belongs directly to the run.
Subagents also run concurrently, and this is the case that separates a
working implementation from a plausible one. When three subagents stream at once,
their events interleave, and attribution is the only thing that disambiguates
them. In particular:
- Two subagents may have open text messages simultaneously. A client must track each independently rather than assuming one open message at a time.
- A subagent’s
SubagentFinishedcloses only that subagent’s own open streams, never a sibling’s or the parent’s. - A parent may finish before its child.
parentSubagentRunIdmay therefore name a subagent that has already finished, which is valid.
Concurrency and the chunk shorthand
TheTextMessageChunk / ToolCallChunk / ReasoningMessageChunk events are a
shorthand: a client synthesizes the START/CONTENT/END boundaries, and a chunk
that omits its id means “the same as the previous one”. Under concurrency,
“previous” is only meaningful per subagent — so the shorthand resolves it
within the sending subagent’s own stream, not across the run.
The practical consequence is one rule for producers: a chunk that carries neither
an id nor a subagentRunId is resolved to the parent’s open stream of that kind
if there is one — untagged means the parent — and otherwise to the sole open
stream of that kind. When several subagents’ streams could all claim it, there is
nothing to resolve it against, and the client rejects it rather than guessing.
When streaming concurrently, attribute every chunk, or repeat the id.
Rules clients enforce
Every rule below is conditional on the events being present. Attribution alone is a complete, valid use of subagent support, so nothing here requires a stream to send lifecycle events at all.SubagentFinishedandSubagentErrorname a subagent that is currently active, and a subagent is not started twice within a run. The lifecycle events carry their schema-required fields (subagentRunIdon all three,nameonSubagentStarted,messageonSubagentError) — clients enforce this even for in-process producers that bypass wire-level schema validation.- Continuation and close events agree with the owner their entity was created under. A text message opened by one subagent cannot be continued by another.
- Ownership is also established by replayed history: a message in a
MessagesSnapshot(and each tool call it carries) is owned by thesubagentRunIdit carries — absent means the parent — and a later event reopening that id under a different owner is rejected, exactly as a conflicting second opener is. - A tool call belongs to the assistant message its
parentMessageIdnames.ToolCallitself carries no attribution field, so aToolCallStartwhose explicitsubagentRunIddisagrees with that message’s owner cannot be represented faithfully and is rejected; an untagged tool call inherits the parent message’s owner. - Steps are scoped to the agent that opened them: a subagent cannot close the parent’s step, or a sibling’s.
- Every started subagent is closed before
RunFinished.
- A
subagentRunIdused for attribution need not have been started. Attribution without lifecycle events is a supported mode, so a client must not treat an unannounced identifier as an error. UIs should group by whatever ids they see and fall back to the id when they have noname. parentSubagentRunIdneed only name a subagent that has been started, not one still active — a parent legitimately finishes before its child.- Events attributed to an already-finished subagent are accepted. Continuation events carry the tag of the subagent they belong to even after it finishes.
- Closure is required before
RunFinishedonly, not beforeRunError. An unclosed subagent is the expected shape of an aborted run. - Attributed state events are accepted. A producer choosing not to emit them mid-delegation is a producer-side decision, not a rule — see Attribution is provenance, not ownership.
Compatibility
The two directions are not symmetric, so they are worth separating.A new agent talking to an older client
The lifecycle events break it, so emitting them has to be opt-in on the producer. Integrations that support subagents therefore expose a flag to enable them, off by default; with it off, no lifecycle events are emitted, nosubagentRunId is attributed, and nothing subagent-related reaches
MessagesSnapshot, so the stream is what it was before subagent support existed.
Turn it on once every consumer is new enough.
Attribution alone is the safer intermediate step, since the field is ignored by
clients that do not know it. A producer that wants grouping without a
compatibility break can attribute events and skip the lifecycle entirely.
A new client talking to an older agent
Handled automatically. The TypeScript client inserts a compatibility shim based on the agent’s reported version, and it acts in both directions.- Client → agent. The shim strips
subagentRunIdfrom the outgoing input messages, so an older agent never receives attribution it cannot interpret. This is the load-bearing half: a replayed message history, or a stored thread written by a newer client, really can carry the field. - Agent → client. The shim also drops
SubagentStarted,SubagentFinishedandSubagentError, and stripssubagentRunIdfrom every remaining event (includingMessagesSnapshotmessages and theRunStartedinput echo). This is defensive normalization rather than translation — an agent that reports a pre-subagent version should not be emitting either of those in the first place, so what it really guards is a mixed or proxied pipeline.
Support
The binary protobuf transport carries subagent attribution on both TypeScript
and .NET, generated from the same schema, so a subagent-attributed stream
survives a round trip across languages.
Known limitations
- Protobuf covers a subset of event types. 19 of the 36 event types have a
protobuf mapping: all three subagent events, plus the text message, tool call
start/args/end, state, step and run families, and
MessagesSnapshot,RawandCustom. The rest cannot be encoded at all — and several of those carry attribution:ToolCallResult, the reasoning family, and the activity events. The chunk shorthand is unencodable too:TextMessageChunkandToolCallChunkhave message definitions carryingsubagent_run_id, but noEventTypeenum entry to select them, andReasoningMessageChunkhas no proto message at all. All of this predates subagent support. - .NET loses the second owner on parallel tool calls from different subagents.
Converting a run to
Microsoft.Extensions.AImessages merges consecutive tool calls into oneChatMessage, and AG-UI attributes per message, so only the first owner survives. It is a current limitation rather than an inherent conflict — the provider constraint is adjacency, which interleaving would also satisfy while preserving attribution.