Render sub-agent conversations inside tool call UIs.
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 tool UI registrations are available in sub-agent messages. A
makeAssistantToolUIregistered 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.
Quick Start
Register a Tool UI for the Sub-Agent
import {
makeAssistantToolUI,
MessagePartPrimitive,
} from "@assistant-ui/react";
const ResearchAgentToolUI = makeAssistantToolUI({
toolName: "invoke_researcher",
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
components={{
UserMessage: MyUserMessage,
AssistantMessage: MyAssistantMessage,
}}
/>
</div>
),
});Provide the Messages from the Backend
Your backend must populate the messages field on the tool call result. For example, with the AI SDK:
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,
// The messages field is picked up by assistant-ui
messages: subAgentMessages,
};
},
}),
},The exact mechanism for populating messages depends on your backend
framework. 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>
);
}Recursive Sub-Agents
If a sub-agent's tool calls also have nested messages, the same pattern applies recursively:
const OuterAgentToolUI = makeAssistantToolUI({
toolName: "invoke_planner",
render: () => (
<div className="rounded border p-3">
<h4>Planner Agent</h4>
<MessagePartPrimitive.Messages
components={{
AssistantMessage: () => (
<MessagePrimitive.Parts
components={{
Text: MyText,
tools: {
by_name: {
invoke_researcher: () => (
<div className="ml-4 rounded border p-3">
<h5>Researcher Agent</h5>
{/* Nested sub-agent renders recursively */}
<MessagePartPrimitive.Messages
components={{
UserMessage: MyUserMessage,
AssistantMessage: MyAssistantMessage,
}}
/>
</div>
),
},
Fallback: MyToolFallback,
},
}}
/>
),
UserMessage: MyUserMessage,
}}
/>
</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
components={{
UserMessage: MyUserMessage,
AssistantMessage: MyAssistantMessage,
}}
/>
</ReadonlyThreadProvider>
);
}ReadonlyThreadProvider inherits the parent's tool UI registrations and model context through scope inheritance.
Related
- Generative UI — Creating tool call UIs
- MessagePartPrimitive — API reference for message part primitives