Multi-Agent Chat UI

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.Messages again 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.answerToolCall is false there.

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.

api/chat/route.ts
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,
      };
    },
  }),
},

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.