Elements

Elements · Voice

Voice conversation

A live call: the orb tracks your voice, the caption names the turn, the transcript follows.

Runtime-wired version: Orb

ConnectingOpening the mic
fig. 01 · plays once, replay from the corner

Installation

npx shadcn@latest add "@assistant-ui/elements-voice-conversation"
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 voice conversation is a live call laid over the thread: an orb that tracks who's talking, a caption naming the turn, and the transcript filling in as it goes. With a runtime the orb, the caption, and the transcript all read straight off the thread's voice session; standalone you drive mode, amplitude, and the turns yourself.

Getting started

assistant-ui models a realtime voice call as s.thread.voice, populated once you call connectVoice() against a runtime configured with a realtime voice adapter. /elements/orb covers wiring that adapter and its own states in full; this page covers the surrounding call screen.

Read the call's mode and volume

components/assistant-ui/elements/voice.aui.tsx
"use client";

import { useVoiceState, useVoiceVolume, useVoiceControls } from "@assistant-ui/react";
import { VoiceConversation, type VoiceMode } from "./voice-conversation";

function toMode(voice: ReturnType<typeof useVoiceState>): VoiceMode {
  if (!voice || voice.status.type === "starting") return "connecting";
  return voice.mode === "speaking" ? "speaking" : "listening";
}

function LiveCallScreen() {
  const voice = useVoiceState();
  const amplitude = useVoiceVolume();
  const { mute, unmute, disconnect } = useVoiceControls();

  return (
    <VoiceConversation
      mode={toMode(voice)}
      amplitude={amplitude}
      muted={voice?.isMuted ?? false}
      onToggleMute={() => (voice?.isMuted ? unmute() : mute())}
      onEnd={disconnect}
      transcript={[]}
    />
  );
}

The adapter only reports "listening" and "speaking" for mode; there's no separate "thinking" phase in the runtime, so a screen that wants one has to infer it itself, for example the gap after the user stops talking and before mode flips to "speaking".

Fill the transcript from the thread

Voice turns are appended as ordinary messages, so the transcript is the same s.thread.messages any text thread uses, not a separate voice-only log:

import { useAuiState, type TextMessagePart } from "@assistant-ui/react";

const transcript = useAuiState((s) =>
  s.thread.messages.map((message) => ({
    id: message.id,
    role: message.role === "user" ? ("user" as const) : ("assistant" as const),
    text: message.parts
      .filter((part): part is TextMessagePart => part.type === "text")
      .map((part) => part.text)
      .join(""),
  })),
);

Anatomy

<div data-slot="voice-conversation">
  <button aria-label="Interrupt the assistant">{/* orb rings, amplitude-scaled */}</button>
  <div>{/* caption plus hint */}</div>
  <div>{/* transcript, user or assistant tagged */}</div>
  <div>
    <button aria-pressed={/* muted */}>{/* mic */}</button>
    <button aria-label="End the call" />
  </div>
</div>

The orb's rings scale with amplitude (clamped to 0..1) only while mode is "listening" or "speaking"; in "connecting" and "thinking" they hold a fixed smaller scale and the center dot pulses instead. The caption under the orb reads "Mic off" whenever muted is true, taking priority over the interrupt hint even while speaking.

Examples

Every mode

<VoiceConversation mode="connecting" amplitude={0} transcript={[]} />
<VoiceConversation mode="listening" amplitude={0.4} transcript={[]} />
<VoiceConversation mode="thinking" amplitude={0} transcript={[]} />
<VoiceConversation mode="speaking" amplitude={0.7} transcript={[]} onInterrupt={() => {}} />

Ending the call

useVoiceControls().disconnect() is the only way to end a session; there's no separate hang-up action distinct from disconnecting, so wire it straight to onEnd:

const { disconnect } = useVoiceControls();

<VoiceConversation onEnd={disconnect} /* ... */ />

API reference

Voice hooks

HookReturnsDescription
useVoiceState()VoiceSessionState | undefined{ status, isMuted, mode }. undefined before connectVoice() is called or after the session ends.
useVoiceVolume()numberLive input amplitude while a session is connected.
useVoiceControls(){ connect, disconnect, mute, unmute }Each calls the matching aui.thread.*Voice() method.

Voice session state

FieldTypeDescription
status.type"starting" | "running" | "ended""starting" maps to the "connecting" mode shown here.
mode"listening" | "speaking"No "thinking" value; infer that gap yourself if you want to show it.
isMutedbooleanReflects mute() and unmute().