Elements

Elements · Messages

Speaker identity

Who is talking, once a thread holds more than a user and one model.

YouFind out why the converter drops turns.
MaintaineropusSplitting this: one worker reads the converter, one reads the tests.
readerhaikuconvertMessages returns early when parts is empty.
read_file42mspackages/core/src/convertMessages.ts
fig. 01

Installation

npx shadcn@latest add "@assistant-ui/elements-speaker-identity"
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.

SpeakerIdentity rows each turn with an icon and tone keyed to who or what produced it: you, the assistant, a subagent, or a tool. With a runtime the user, assistant, and tool distinctions come straight from message and part data; standalone you assemble the list yourself.

Getting started

s.message.role gives you the user/assistant split directly. Tool calls live as parts inside an assistant message's content, each with a toolName and, when the runtime tracks it, a timing. A nested subagent run shows up as a tool call's own messages field, a real part of ToolCallMessagePart for exactly this case. assistant-ui has no fixed "agent name" concept beyond that: a display name for a particular agent or model is something you attach yourself through the message's open metadata.custom bag.

Turn a top-level message into a row

components/assistant-ui/elements/speaker-list.tsx
"use client";

import type { ThreadMessage } from "@assistant-ui/react";
import type { SpeakerTurn } from "@/components/assistant-ui/elements/speaker-identity";

function textOf(content: readonly { type: string; text?: string }[]) {
  return content
    .filter((part): part is { type: "text"; text: string } => part.type === "text")
    .map((part) => part.text)
    .join(" ");
}

function customString(message: ThreadMessage, key: string): string | undefined {
  const value = message.metadata.custom[key];
  return typeof value === "string" ? value : undefined;
}

function messageTurn(message: ThreadMessage): SpeakerTurn {
  if (message.role === "user") {
    return { id: message.id, kind: "user", name: "You", text: textOf(message.content) };
  }
  return {
    id: message.id,
    kind: "agent",
    name: customString(message, "agentName") ?? "Assistant",
    detail: customString(message, "model"),
    text: textOf(message.content),
  };
}

agentName and model above are an example convention, not a built-in field; your own multi-agent backend decides what it stashes in metadata.custom and under which keys.

Add tool calls and nested subagents

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

function toolTurn(part: ToolCallMessagePart): SpeakerTurn {
  const ms =
    part.timing?.completedAt !== undefined
      ? part.timing.completedAt - part.timing.startedAt
      : undefined;
  return {
    id: part.toolCallId,
    kind: "tool",
    name: part.toolName,
    detail: ms !== undefined ? `${ms}ms` : undefined,
    text: part.argsText,
  };
}

function turnsFor(message: ThreadMessage): SpeakerTurn[] {
  const turns = [messageTurn(message)];
  if (message.role !== "assistant") return turns;

  for (const part of message.content) {
    if (part.type !== "tool-call") continue;
    turns.push(toolTurn(part));
    for (const sub of part.messages ?? []) {
      turns.push({ ...messageTurn(sub), kind: "subagent" });
    }
  }
  return turns;
}

Which argument is worth showing as a tool row's text is tool-specific; argsText (the raw streamed JSON) is a safe generic fallback, but a read_file tool's path or a search tool's query is usually more readable when you have toolName to switch on.

Anatomy

<div data-slot="speaker-identity">
  {/* per turn: an icon in a tinted badge, then a name/detail line and the text below it */}
</div>

Each kind maps to a fixed icon and tone: user and subagent share a neutral tint, agent is the only one tinted blue, and tool reads dimmest of the four. subagent is the one kind whose badge renders as a full circle instead of a rounded square, echoing that it stands in for a whole nested conversation rather than a single speaker.

Examples

Restyle the badges

Both lanes take className on the root. detail reads the shared mono surface from surfaces.tsx.

<SpeakerIdentity className="gap-4" turns={turns} />

A thread with only one voice

Most threads never produce a subagent or tool row; turnsFor above still returns a plain two-kind list (user, agent) for them, since the loop over content simply finds nothing to add.

API reference

Message state

Selector / fieldTypeDescription
s.message.role"user" | "assistant" | "system"The real split; "agent" and "subagent" are this component's own vocabulary, not runtime roles.
s.message.metadata.customRecord<string, unknown>Open bag for app-defined display data, such as an agent's name or model.
ToolCallMessagePart.toolNamestringName of the called tool.
ToolCallMessagePart.timing{ startedAt: number; completedAt?: number } | undefinedEpoch ms; subtract for a duration once completedAt is set.
ToolCallMessagePart.messagesreadonly ThreadMessage[] | undefinedNested thread messages produced by this call, for example a subagent's own conversation.