Render sub-agent conversations and handoffs inside tool calls. Build supervisor and multi-agent patterns in a React chat UI with assistant-ui.
In a multi-agent (orchestrator) architecture, a main agent invokes sub-agents via tool calls. Each sub-agent may produce its own conversation (user/assistant messages, tool calls, etc.). assistant-ui supports rendering these nested conversations using the MessagePartPrimitive.Messages primitive.
Overview
When a tool call includes a messages field (ToolCallMessagePart.messages), it represents a sub-agent's conversation history. MessagePartPrimitive.Messages reads this field from the current tool call part and renders it as a nested thread.
Key behaviors:
- Scope inheritance: Parent toolkit renderers are available in sub-agent messages. A
Tools({ toolkit })registration at the top level works inside sub-agent conversations too. - Recursive: Sub-agent messages can contain tool calls that themselves have nested messages. Use
MessagePartPrimitive.Messagesagain to render them. - Read-only: Sub-agent messages are rendered in a readonly context, without editing, branching, composing, or answering tool calls;
s.thread.capabilities.answerToolCallisfalsethere.
Info
This renders a sub-agent's messages. To visualize a sub-agent's execution trace instead (timing, span hierarchy, waterfalls), see react-o11y, which renders observability spans as collapsible trees and timelines.
Quick Start
Register a Tool UI for the Sub-Agent
import {
AuiConfig,
defineToolkit,
Tools,
MessagePartPrimitive,
} from "@assistant-ui/react";
const toolkit = defineToolkit({
invoke_researcher: {
type: "backend",
render: ({ args, status }) => (
<div className="my-2 rounded-lg border p-4">
<div className="mb-2 text-sm font-medium text-gray-500">
Researcher Agent {status.type === "running" && "(working...)"}
</div>
<MessagePartPrimitive.Messages>
{({ message }) => {
if (message.role === "user") return <MyUserMessage />;
return <MyAssistantMessage />;
}}
</MessagePartPrimitive.Messages>
</div>
),
},
});Provide the Messages from the Backend
The sub-agent's conversation has to reach ToolCallMessagePart.messages. An AI SDK backend populates it from the tool result. The AG-UI runtime assembles it from the protocol's subagent lifecycle events, the LangChain runtime from the subagent discovered for each task tool call, and the OpenCode runtime from the child session behind each task tool call. The LangGraph runtime and Google ADK leave it unset: LangGraph streams subgraph messages into the parent thread and attributes them through the namespaced events, and an ADK long-running call is owned by the client and settles on requires-action until the function response is sent; see long-running tools.
tools: {
invoke_researcher: tool({
description: "Invoke the researcher sub-agent",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => {
const subAgentMessages = await runResearcherAgent(query);
return {
answer: subAgentMessages.at(-1)?.content,
messages: subAgentMessages,
};
},
}),
},@assistant-ui/react-langgraph does not nest subgraph output. Messages a subgraph emits stream into the parent thread, and the namespaced events attribute them: onSubgraphValues and onSubgraphUpdates receive the subgraph's namespace and state, and onMessageChunk carries metadata.namespace for chunks that originate in a subgraph. Those events exist only when the run streams subgraphs, so unstable_createLangGraphStream forwards streamSubgraphs to client.runs.stream and this example enables it together with "updates" in streamMode. Set unstable_allowCancellation: true to wire up the stop button. A nested conversation per sub-agent on LangGraph needs @assistant-ui/react-langchain, which attaches each task subagent's transcript to its tool call.
import { useMemo } from "react";
import {
unstable_createLangGraphStream,
useLangGraphRuntime,
} from "@assistant-ui/react-langgraph";
const stream = useMemo(
() =>
unstable_createLangGraphStream({
client,
assistantId,
// "updates" carries the subgraph events; "custom" is required for generative UI
streamMode: ["messages", "updates", "custom"],
// without it the run streams the top graph only
streamSubgraphs: true,
onDisconnect: "cancel",
}),
[client, assistantId],
);
const runtime = useLangGraphRuntime({
unstable_allowCancellation: true,
stream,
eventHandlers: {
onSubgraphValues: (namespace, values) => {
// namespace = e.g. "tools:call_abc", the sub-agent's node path
// values contains the subgraph state, including its messages array
},
onSubgraphUpdates: (namespace, updates) => {
// incremental state updates from the subgraph
},
onSubgraphError: (namespace, error) => {
// error scoped to the subgraph; does not mark the parent message failed
},
onMessageChunk: (chunk, metadata) => {
// metadata.namespace is set when the chunk originates from a subgraph
// use it to attribute the chunk to the correct sub-agent
},
},
});See LangGraph Streaming for the full event handler reference.
With @assistant-ui/react-langchain there is nothing to populate. useStream discovers every task subagent on the run, and the runtime reads that subagent's own message projection and attaches it to the spawning call's ToolCallMessagePart.messages, keyed by the task tool call id. The transcript streams while the subagent runs, nests recursively up to sixteen levels deep when subagents spawn subagents, and is present again on a thread loaded from history, because the SDK seeds subagent discovery from checkpoint messages.
See subagent and subgraph views for the discovery hooks and custom views.
With @assistant-ui/react-ag-ui there is no messages field to populate: the runtime builds the nesting from the protocol's own subagent lifecycle. A backend running the agents-as-tools pattern emits SUBAGENT_STARTED naming the spawning call as parentToolCallId, tags every event that subagent produces with its subagentRunId, and closes the run with SUBAGENT_FINISHED or SUBAGENT_ERROR. The runtime groups each run into one nested assistant message and attaches it to that call's ToolCallMessagePart.messages, so the tool UI above renders it unchanged.
The subagent's name, description, result, interruptIds, and errorCode ride on the nested message's metadata.custom.agui. A run whose parentToolCallId names no reachable call has nowhere to nest and renders in the parent thread instead.
See AG-UI Subagents for the full behavior, the comparison with LangChain's discovery-hook shape, and two limitations (nesting does not survive a reload, and nested human-in-the-loop has no resume path).
Info
The key requirement is that the tool result's corresponding
ToolCallMessagePart includes a messages array of ThreadMessage objects.
Register the Tool UI Component
function App() {
const config = AuiConfig({ tools: Tools({ toolkit }) });
return (
<AssistantRuntimeProvider runtime={runtime} config={config}>
<Thread />
</AssistantRuntimeProvider>
);
}Subgraph Namespace Events
When using LangGraph, subgraph events (onSubgraphValues / onSubgraphUpdates / onSubgraphError, plus the namespace on onMessageChunk) carry a namespace that identifies which sub-agent emitted them, letting you attribute messages and state to specific sub-agents. See LangGraph Streaming for the full reference.
Recursive Sub-Agents
If a sub-agent's tool calls also have nested messages, the same pattern applies recursively:
const toolkit = defineToolkit({
invoke_planner: {
type: "backend",
render: () => (
<div className="rounded border p-3">
<h4>Planner Agent</h4>
<MessagePartPrimitive.Messages>
{({ message }) => {
if (message.role === "user") return <MyUserMessage />;
return (
<MessagePrimitive.Parts>
{({ part }) => {
if (part.type === "text") return <MyText />;
if (part.type === "tool-call" && part.toolName === "invoke_researcher") return (
<div className="ml-4 rounded border p-3">
<h5>Researcher Agent</h5>
{/* Nested sub-agent renders recursively */}
<MessagePartPrimitive.Messages>
{({ message }) => {
if (message.role === "user") return <MyUserMessage />;
return <MyAssistantMessage />;
}}
</MessagePartPrimitive.Messages>
</div>
);
if (part.type === "tool-call") return <MyToolFallback {...part} />;
return null;
}}
</MessagePrimitive.Parts>
);
}}
</MessagePartPrimitive.Messages>
</div>
),
},
});Status
While a nested conversation has a running assistant message, the tool call and the message that holds it stay running even after the outer run has ended. This is how delegated work that outlives the run shows up. Once the nested conversation settles without a tool result, the message reads requires-action with reason tool-calls, the same as any other pending tool call.
The nested thread rendered by MessagePartPrimitive.Messages or ReadonlyThreadProvider reports isRunning from its trailing assistant message, and the background rule reads the same trailing message. A runtime that fills messages owns settling the nested status: a trailing nested assistant message left running keeps the outer message running until it is settled. On the useExternalMessageConverter path a cancelled run clears it; on the plain useExternalStoreRuntime path the store settles the nested transcript on cancel as well. A transcript imported from persistence starts idle: a nested message that was saved while running does not keep the outer message running.
Tasks
s.thread.tasks derives child work from every tool call that carries a messages array, including an empty array. Tasks follow document order, with nested tasks directly after their parent. Each task includes parentTaskId and depth for reconstructing the hierarchy.
The array keeps its identity while no task changes, so a component that subscribes to it does not rerender for unrelated streamed tokens. The walk stops past 32 levels of nesting; deeper tasks are not listed.
import { useMemo } from "react";
import { useAuiState } from "@assistant-ui/react";
function TasksChip() {
const tasks = useAuiState((s) => s.thread.tasks);
const { running, total } = useMemo(
() => ({
running: tasks.filter((task) => task.status.type === "running").length,
total: tasks.length,
}),
[tasks],
);
return <span>{`${running}/${total} tasks running`}</span>;
}For imperative lookup, aui.thread.task({ id }) resolves the first task with that toolCallId in document order (nested conversations may repeat ids), or use an index selector.
Kit elements
Task card renders nested tool calls inside Thread once components.TaskGroup is set: every tool call that carries messages and has no registered tool UI becomes a lane with its state and timing, sibling tasks are grouped four at a time, and each nested transcript opens on demand.
Agent status reads s.thread.tasks for a compact task summary. Its chip reports the current work, and its tray lists every task with nesting, state, label, meta, and elapsed time.
ReadonlyThreadProvider
For advanced use cases where you have a ThreadMessage[] array and want to render it as a thread outside of a tool call context, use ReadonlyThreadProvider directly:
import {
ReadonlyThreadProvider,
ThreadPrimitive,
type ThreadMessage,
} from "@assistant-ui/react";
function SubConversation({
messages,
}: {
messages: readonly ThreadMessage[];
}) {
return (
<ReadonlyThreadProvider messages={messages}>
<ThreadPrimitive.Messages>
{({ message }) => {
if (message.role === "user") return <MyUserMessage />;
return <MyAssistantMessage />;
}}
</ThreadPrimitive.Messages>
</ReadonlyThreadProvider>
);
}ReadonlyThreadProvider inherits the parent's tool UI registrations and model context through scope inheritance. A tool UI rendered inside it reads s.thread.capabilities.answerToolCall as false, because addResult, resume, and respondToApproval do nothing in a readonly thread; show the request without its controls there.
Related
- Tool UI: Creating tool call UIs
- react-o11y: Visualize sub-agent execution as collapsible span trees, waterfalls, and call timelines
- MessagePartPrimitive: API reference for message part primitives
- Sub-Agent Model Tracking: Track delegated model usage and costs in the Cloud dashboard
- AG-UI Subagents: Nested subagent messages attributed from the AG-UI event stream
- LangGraph Streaming: Event handlers, subgraph events, and message metadata
- LangGraph Generative UI: Structured UI components emitted by your graph