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. Just use
MessagePartPrimitive.Messagesagain. - Read-only — Sub-agent messages are rendered in a readonly context. No editing, branching, or composing.
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 {
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. AI SDK and LangGraph backends populate it from the tool result; an AG-UI backend emits the protocol's subagent lifecycle events and the runtime assembles it.
tools: {
invoke_researcher: tool({
description: "Invoke the researcher sub-agent",
parameters: z.object({ query: z.string() }),
execute: async ({ query }) => {
const subAgentMessages = await runResearcherAgent(query);
return {
answer: subAgentMessages.at(-1)?.content,
messages: subAgentMessages,
};
},
}),
},With @assistant-ui/react-langgraph, use unstable_createLangGraphStream and set unstable_allowCancellation: true to wire up the stop button. When your graph runs a subgraph, the subgraph's messages appear on ToolCallMessagePart.messages automatically once you handle the onSubgraphValues / onSubgraphUpdates events.
import {
useLangGraphRuntime,
unstable_createLangGraphStream,
} from "@assistant-ui/react-langgraph";
const runtime = useLangGraphRuntime({
unstable_allowCancellation: true,
stream: unstable_createLangGraphStream({
client,
assistantId,
// "custom" is required for generative UI; "updates" for subgraph events
streamMode: ["messages", "updates", "custom"],
// abort the run server-side when the user clicks stop
onDisconnect: "cancel",
}),
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-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() {
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
<ResearchAgentToolUI />
</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>
),
},
});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.
Related
- Generative 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