Elements

Subagent list

Parallel workers with their own progress, models, and completions.

Explore the runtimehaiku
Fix composer typessonnet
Write regression testssonnet
fig. 01 · plays once, replay from the corner

Installation

npx shadcn@latest add "@assistant-ui/elements-subagent-list"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

A stack of cards, one per worker, each with a done or spinning icon, its model, and a progress bar, plus an optional trailing card for a synthesis step. With a runtime each worker is its own tool call and the list is assembled from them; standalone you pass in the roster and its progress directly.

Getting started

A dispatch to several sub-agents is naturally several tool calls in one assistant turn, one per worker. The list itself is not any single call's UI; it is assembled from all of them.

Render the dispatch calls invisibly

Register the dispatch tool with a render that returns nothing. Its schema and execution live on your server; here it only needs to stop the default tool card from also appearing inline, since the aggregate list below owns the visual.

app/toolkit.tsx
"use client";

import { defineToolkit } from "@assistant-ui/react";

export const toolkit = defineToolkit({
  dispatch_subagent: { type: "backend", render: () => null },
  summarize_findings: { type: "backend", render: () => null },
});

Aggregate them below the message

Read every part of the current message, keep the dispatch_subagent calls, and fold them into the props SubagentList expects. summarize_findings becomes the trailing summary card when it is present at all.

components/assistant-ui/elements/thread.aui.tsx
import { MessagePrimitive, useAuiState } from "@assistant-ui/react";
import { SubagentList } from "@/components/assistant-ui/elements/subagent-list";

function SubagentProgress() {
  const parts = useAuiState((s) => s.message.parts);
  const calls = parts.filter(
    (p) => p.type === "tool-call" && p.toolName === "dispatch_subagent",
  );
  const summaryCall = parts.find(
    (p) => p.type === "tool-call" && p.toolName === "summarize_findings",
  );
  if (calls.length === 0) return null;

  return (
    <SubagentList
      agents={calls.map((c) => ({ name: c.args.name, model: c.args.model }))}
      completedCount={calls.filter((c) => c.status.type === "complete").length}
      progress={calls.map((c) => (c.status.type === "complete" ? 100 : 0))}
      showSummary={summaryCall !== undefined}
      summaryAgent={
        summaryCall
          ? { name: summaryCall.args.name, model: summaryCall.args.model }
          : { name: "", model: "" }
      }
    />
  );
}

function AssistantMessage() {
  return (
    <MessagePrimitive.Root>
      <MessagePrimitive.Parts />
      <SubagentProgress />
    </MessagePrimitive.Root>
  );
}

Anatomy

<div data-slot="subagent-list">
  <div>
    <span>{/* check or spinner */}</span>
    <span>{/* agent name */}</span>
    <span>{/* model */}</span>
    <span role="progressbar">{/* progress bar, named from its agent */}</span>
  </div>
  {/* one card per agent, then the summary card when shown */}
</div>

An agent at index i is done when i < completedCount; its bar width and progressbar value come from progress[i], clamped into 0…100 with a missing or NaN entry read as 0. Each progressbar is named from its agent and exposes a 0…100 value to assistive technology. The summary card, when showSummary is true, always shows the spinner and a full-track shimmer treatment. Its progressbar carries no value because this version has no way to mark the summary itself done, only to show or hide it. The root carries a minimum height so the layout does not jump as agents complete and the summary card fades in.

Examples

Restyle the roster

Both lanes take className on the root. Each card uses the shared paper surface and the mono token for its model label, so retheming those in surfaces.tsx restyles every card here alongside every other element built on them.

<SubagentList className="max-w-sm gap-3" /* ... */ />

Where progress comes from

A tool call's status only distinguishes running from complete, so the mapping above reads as two states: 0 while a worker's call is in flight, 100 once its result lands. A smoother live percentage needs your own progress channel, for example an app-level store keyed by the call's toolCallId that a websocket or polling update fills in, read into progress the same way.

API reference

Render props

SourceTypeDescription
dispatch_subagent call args.name / args.modelstringOne roster entry per call.
dispatch_subagent call status.type"running" | "requires-action" | "incomplete" | "complete""complete" counts toward completedCount and reads as 100 progress; anything else reads as 0.
summarize_findings calltool call or undefinedPresence alone drives showSummary; its args become summaryAgent.