# Voice Agent

> **Info:** This is documentation for **Stream Chat React SDK v14**, which is no longer actively maintained. For up-to-date documentation, see the [latest version (v15)](https://getstream.io/chat/docs/sdk/react/).

Talking is often faster than typing, and it's the only way to use an app with busy hands. A voice agent listens to the user, answers out loud, and lets them interrupt it, like a phone call with your assistant.

>
> **Note:** **What you'll build**
>
> - A call view that puts your agent on a call with the user
> - The agent as a glowing globe that moves with its voice
> - What was said, written out under the globe, revealed as the agent says it
> - Mute and hang up
> - The question a tool asks before it runs, over the call
>

**Starts from:** the [Quickstart](https://getstream.io/chat/docs/sdk/react/v14/ai-components/), with the user signed in through the `Client` from `@stream-io/stream-agents`.

>
> **Info:** `VoiceSession` from `@stream-io/stream-agents/video` does the hard part. It starts your agent on a call, and joins the call from the browser with Stream Video. The agent's speech-to-text, model and text-to-speech run on Stream, and when the user talks over the agent, it stops and listens.
>
> What is said on the call is written into the session's Stream Chat channel as it happens, so `MessageList` shows the transcript and live captions. There is no second transcript to build.
>
> This page needs `stream-chat-react` 14.15.0 or later. `VoiceGlow`, `VoiceWave` and `useAudioLevel` ship from 14.14.0, and `useHeardText` and `AIToolApprovalCard` from 14.15.0. `VoiceSession` is in `@stream-io/stream-agents/video`.
>

## Best practices

- Show what the agent is doing at every moment. A silent screen while the agent thinks feels broken.
- Drive the globe from the agent's real voice, so it swells with its words.
- Write out what was said, and reveal each reply as it's heard, not as it's written. Users glance at it to check a name, a number or an address.
- Start the call from a click. A browser asks for the microphone and plays audio only after one.
- Leave the composer out while the call is on. A typed turn on a call is never marked finished, and the next `responses.create` is refused.
- Make hanging up obvious, so the agent stops listening.

## Give the agent a voice

Any agent can talk, with the router's default speech models. Make it from the voice template to choose its models, voice and greeting:

```bash
getstream agents init my-agent --template voice
getstream agents sync my-agent
```

[STT](https://getstream.io/agents/docs/stt/?lang=javascript) and [TTS & Voices](https://getstream.io/agents/docs/tts/?lang=javascript) list the models and voices.

The call runs on Stream Video. Add the Video React SDK, which plays the agent's audio:

```bash
npm install @stream-io/video-react-sdk
```

`@stream-io/stream-agents/video` brings in `@stream-io/video-client` with it, so don't install that separately. Browsers give a page the microphone only on `localhost` or over HTTPS.

## Start the call

`VoiceAgent` starts a voice session with the agent you name, from a click, and shows the call once the agent is on it. `VoiceSession.start` resolves once the agent has joined. `voice.session` is a regular session, so `voice.session.chat()` opens its channel for the transcript:

```tsx title="VoiceAgent.tsx"
import "@stream-io/stream-agents/chat";
import type { Client } from "@stream-io/stream-agents";
import { VoiceSession } from "@stream-io/stream-agents/video";
import { useEffect, useState } from "react";
import type { Channel as ChatChannel, StreamChat } from "stream-chat";
import { Button } from "stream-chat-react";
import { VoiceGlow } from "stream-chat-react/ai-components";
import "stream-chat-react/css/index.css";
import "stream-chat-react/css/ai-components.css";

import { VoiceCall } from "./VoiceCall";

export type VoiceAgentCall = {
  voice: VoiceSession;
  chatClient: StreamChat;
  channel: ChatChannel;
};

/** Puts the agent on a new session's call, and opens the session's channel. */
const startCall = async (
  api: Client,
  agent: string,
): Promise<VoiceAgentCall> => {
  const voice = await VoiceSession.start(api.agent(agent));
  try {
    // What is said on the call is written into this channel.
    const { client, channel } = await voice.session.chat();
    const transcript = channel as ChatChannel;
    // Loads what was said so far and receives the rest live.
    await transcript.watch();
    return { voice, chatClient: client as StreamChat, channel: transcript };
  } catch (error) {
    // Without a transcript there is no call to show: end the session it opened.
    await voice.end().catch(() => undefined);
    throw error;
  }
};

// `api` is the Client from the Quickstart, with the user set.
export const VoiceAgent = ({ api, agent }: { api: Client; agent: string }) => {
  const [active, setActive] = useState<VoiceAgentCall>();
  const [isStarting, setIsStarting] = useState(false);
  const [failure, setFailure] = useState<string>();

  // Leaves the call, and ends its session, if the view goes away mid-call,
  // such as when the user navigates elsewhere. It runs when a call starts,
  // not on mount, so Strict Mode doesn't end the call it just started.
  useEffect(() => {
    if (!active) return;
    return () => void active.voice.leave().catch(() => undefined);
  }, [active]);

  const start = async () => {
    setIsStarting(true);
    setFailure(undefined);
    try {
      setActive(await startCall(api, agent));
    } catch (error) {
      setFailure(error instanceof Error ? error.message : String(error));
    } finally {
      setIsStarting(false);
    }
  };

  if (active) {
    return <VoiceCall {...active} onHangUp={() => setActive(undefined)} />;
  }
  if (isStarting) {
    return <VoiceGlow className="voice-glow" state="connecting" />;
  }
  return (
    <div className="voice-agent">
      <Button
        appearance="solid"
        onClick={() => void start()}
        size="md"
        type="button"
        variant="primary"
      >
        Talk to the agent
      </Button>
      {failure && <p role="alert">{failure}</p>}
    </div>
  );
};
```

Render it inside an element with the `str-chat` class, the SDK's theme scope, so the SDK's `Button` and the transcript take the SDK's fonts, colors and spacing:

```tsx
<div className="str-chat voice-page">
  <VoiceAgent agent="my-agent" api={api} />
</div>
```

The glow has no size of its own, so give it one, and lay the call out as a column the height of the page, with the transcript as its growing child, since the transcript scrolls inside itself:

```css
.voice-page {
  height: 100dvh;
}

.voice-agent,
.voice-call {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1rem;
  height: 100%;
  padding: 1rem;
  box-sizing: border-box;
}

.voice-agent {
  justify-content: center;
}

.voice-glow {
  width: 240px;
  height: 240px;
}

.voice-call .str-chat__channel {
  flex: 1;
  min-height: 0;
  align-self: stretch;
  width: 100%;
  max-width: 48rem;
  margin-inline: auto;
}

.voice-call__controls {
  display: flex;
  gap: 0.5rem;
}

.agent-glow {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.agent-glow__status {
  color: var(--str-chat__text-secondary);
}
```

`session.chat()` connects Stream Chat as the user you set, and `watch()` loads the transcript and keeps it live before the call shows, so a failure to open it ends the session rather than showing an empty call. Hang up leaves the call, and so does the effect when `VoiceAgent` goes away mid-call; leaving twice does nothing. `session.chat()` needs `@stream-io/stream-agents/chat` imported once, or it throws. If your app already has connected `StreamChat` and `StreamVideoClient` instances, hand them over with `api.use({ chat: chatClient, video: videoClient })` before starting, so a session doesn't open second connections.

## Show the call

`VoiceCall` joins the call from the browser, with the microphone on, and shows the agent, what was said, and the controls. The transcript is the session's channel, rendered with `MessageList`. `isMessageAIGenerated` makes it stream the agent's replies in with markdown, and `AIStateIndicator` shows while the agent thinks or writes:

```tsx title="VoiceCall.tsx"
import {
  ParticipantsAudio,
  StreamCall,
  StreamVideo,
  useCallStateHooks,
} from "@stream-io/video-react-sdk";
import { useEffect, useSyncExternalStore } from "react";
import type { LocalMessage } from "stream-chat";
import {
  AIStateIndicator,
  Attachment,
  Button,
  Channel,
  Chat,
  MessageList,
  WithComponents,
  type AttachmentProps,
} from "stream-chat-react";
import { VoiceGlow } from "stream-chat-react/ai-components";

import { AgentGlow } from "./AgentGlow";
import type { VoiceAgentCall } from "./VoiceAgent";

/**
 * A reply's steps are `ai_*` attachments, and a connector's sign-in also
 * writes a `connector_authorization` one for older clients. `MessageList`
 * draws neither, so leave them out of the transcript.
 */
const isAgentStep = (type: string | undefined) =>
  !!type && (type.startsWith("ai_") || type === "connector_authorization");

const TranscriptAttachment = (props: AttachmentProps) => (
  <Attachment
    {...props}
    attachments={props.attachments.filter(
      (attachment) => !("type" in attachment && isAgentStep(attachment.type)),
    )}
  />
);

// The default asks the user to send a message, which a call has no composer for.
const NothingSaidYet = () => null;

// Declared once, so WithComponents gets the same object on every render.
const overrides = {
  Attachment: TranscriptAttachment,
  EmptyStateIndicator: NothingSaidYet,
};

// `ai_generated` is declared in stream-chat.d.ts, further down this page.
const isMessageAIGenerated = (message: LocalMessage) =>
  Boolean(message.ai_generated);

/** Plays the agent: the other participant on the session's call. */
const AgentAudio = () => {
  const { useRemoteParticipants } = useCallStateHooks();
  return <ParticipantsAudio participants={useRemoteParticipants()} />;
};

export const VoiceCall = ({
  voice,
  chatClient,
  channel,
  onHangUp,
}: VoiceAgentCall & { onHangUp: () => void }) => {
  const { call, client, failure, muted } = useSyncExternalStore(
    voice.subscribe,
    voice.get,
  );

  // Joins from this browser, with the microphone on.
  // Run twice, as in Strict Mode, it joins once.
  useEffect(() => {
    // A failed join also lands in `failure`, shown below.
    voice.join().catch(() => undefined);
  }, [voice]);

  const hangUp = async () => {
    // Leaves the call, and ends the session `VoiceSession.start` opened.
    await voice.leave();
    onHangUp();
  };

  return (
    <div className="voice-call">
      {call && client ? (
        <StreamVideo client={client}>
          <StreamCall call={call}>
            <AgentGlow voice={voice} />
            <AgentAudio />
          </StreamCall>
        </StreamVideo>
      ) : (
        <VoiceGlow className="voice-glow" state="connecting" />
      )}
      {failure && <p role="alert">{failure.message}</p>}
      <WithComponents overrides={overrides}>
        <Chat client={chatClient} isMessageAIGenerated={isMessageAIGenerated}>
          <Channel channel={channel}>
            <MessageList />
            <AIStateIndicator />
            {/* No composer while the call is on. */}
          </Channel>
        </Chat>
      </WithComponents>
      <div className="voice-call__controls">
        <Button
          appearance="outline"
          onClick={() => void voice.setMuted(!muted).catch(() => undefined)}
          size="md"
          type="button"
          variant="secondary"
        >
          {muted ? "Unmute" : "Mute"}
        </Button>
        <Button
          appearance="solid"
          onClick={() => void hangUp()}
          size="md"
          type="button"
          variant="danger"
        >
          Hang up
        </Button>
      </div>
    </div>
  );
};
```

The transcript's overrides leave out what `MessageList` can't draw: the steps of a reply, such as a tool call, which arrive as attachments of their own, and the empty state, which asks for a message the call has no composer to send. `voice.get()` returns a snapshot that stays the same object until something changes, and `get` and `subscribe` are bound, so `useSyncExternalStore` takes them as they are. `join()` rejects if joining fails, for example because the user denied the microphone, and the same error is in the snapshot's `failure` until the next join, mute or camera change works.

## Show what the agent is doing

`AgentGlow` shows the agent as a `VoiceGlow`. The snapshot's `status` is already one of the glow's states, worked out from the call and the session together, and the agent's `audioStream` moves the glow with its voice while it speaks:

```tsx title="AgentGlow.tsx"
import type { VoiceSession } from "@stream-io/stream-agents/video";
import { useCallStateHooks } from "@stream-io/video-react-sdk";
import { useSyncExternalStore } from "react";
import {
  useAudioLevel,
  VoiceGlow,
  type VoiceAgentState,
} from "stream-chat-react/ai-components";

const statusLabels: Record<VoiceAgentState, string> = {
  connecting: "Connecting…",
  idle: "Ready",
  listening: "Listening",
  speaking: "Speaking",
  thinking: "Thinking…",
};

/** Render it inside `StreamCall`, so it can read the call's participants. */
export const AgentGlow = ({ voice }: { voice: VoiceSession }) => {
  const { status } = useSyncExternalStore(voice.subscribe, voice.get);
  const { useRemoteParticipants } = useCallStateHooks();
  // The agent is the other participant on the session's call.
  const [agent] = useRemoteParticipants();
  const agentLevel = useAudioLevel(agent?.audioStream);

  return (
    <div className="agent-glow">
      <VoiceGlow
        aria-hidden="true"
        audioLevel={status === "speaking" ? agentLevel : undefined}
        className="voice-glow"
        state={status}
      />
      <p className="agent-glow__status">{statusLabels[status]}</p>
    </div>
  );
};
```

The states:

- `connecting` until this browser has joined the call.
- `speaking` while the call hears the agent.
- `listening` while the user is heard or transcribed.
- `thinking` while the agent answers or its skills work, and until its reply is heard. A reply is written into the channel before it's spoken.
- `idle` otherwise, and after `voice.leave()`.

`useAudioLevel` returns a getter the glow reads once a frame, so nothing re-renders as the level changes. To show the user's microphone too, pass the `mediaStream` from the Video SDK's `useMicrophoneState()` to `useAudioLevel`, and the getter to a `VoiceWave`. [Voice visualizations](https://getstream.io/chat/docs/sdk/react/v14/ai-components/voice-visualizations/) lists the options of both, and the Video SDK's [Participant data](https://getstream.io/video/docs/react/guides/call-and-participant-state/#participant-data) lists `audioStream` and `isSpeaking`.

The call stays up when the agent fails, so say so under the glow. `session.conversation` has the same `get` and `subscribe`, and its `failure` names what went wrong, with `failureContext` saying which part failed (`stt`, `llm` or `tts`), until the agent next hears, answers or speaks:

```tsx
import type { Session } from "@stream-io/stream-agents";
import { useSyncExternalStore } from "react";

export const AgentTrouble = ({ session }: { session: Session }) => {
  const { failure } = useSyncExternalStore(
    session.conversation.subscribe,
    session.conversation.get,
  );
  return failure ? <p role="alert">{failure}</p> : null;
};
```

Without a call, `AIStateIndicator` and `useAIState` show the same thinking and writing, from the `ai_indicator.update` and `ai_indicator.clear` events the agent sends to the channel. For an indicator of your own inside `Channel`:

```tsx
import {
  AIStates,
  useAIState,
  useChannelStateContext,
} from "stream-chat-react";

export const AgentStatus = () => {
  const { channel } = useChannelStateContext();
  const { aiState } = useAIState(channel);

  if (aiState === AIStates.Thinking) return <span>Thinking...</span>;
  if (aiState === AIStates.Generating) return <span>Writing...</span>;
  return null;
};
```

For latency timings, read `event.frame` from `session.events()`, not the status or the transcript. Each loop over it is told everything from when it started, so start one in an effect and stop it in the effect's cleanup.

## What the call writes into the transcript

The backend writes the conversation into the channel as it happens:

- Each thing the user says becomes a message with `source: "speech"`.
- The agent's reply streams into its message while it's generated, with `generating: true`, as ephemeral updates about every 200 ms. It settles once it has been spoken.
- A reply the user cut off is marked `interrupted: true`, and its text is usually cleared.
- Speech the agent ignored is deleted. If the delete fails, its message is emptied instead.
- Every agent reply carries `ai_generated: true`, and `turn_id`: the turn of the conversation it belongs to.

Live speech-to-text and replies arrive as message updates, so `MessageList` already shows them as captions. There is nothing else to subscribe to.

These are custom message fields. With `stream-chat` v9, which `stream-chat-react` v14 uses, they're top-level fields such as `message.interrupted`. Declare them for TypeScript:

```ts title="stream-chat.d.ts"
// The import makes this file extend `stream-chat` instead of replacing it.
import "stream-chat";

declare module "stream-chat" {
  interface CustomMessageData {
    ai_generated?: boolean;
    generating?: boolean;
    interrupted?: boolean;
    source?: string;
    turn_id?: string;
  }
}
```

[TypeScript and custom data types](https://getstream.io/chat/docs/sdk/react/v14/guides/typescript-and-custom-data-types/) covers custom fields.

## Reveal replies as they're spoken

A reply reaches the channel as fast as the model writes it, well before the agent has said it. `StreamedMessageText`, the default typewriter for AI replies, reveals about 65 characters a second, while speech runs at around 15, so the text races ahead of the voice.

`useHeardText` from `stream-chat-react/ai-components` turns how much has been heard into the text to show. Give it the `text` and a `heard(now)` function that returns `{ chars, done }` (Unicode code points heard by `now`, a `performance.now()` time), or `undefined` when the text isn't paced, plus an optional `subscribe`, `generating` and `live`. It returns `{ heardText, isPaced, skip }`.

The session already knows how much has been heard: `session.speech.heard(message, now)` follows the agent's speech on the call and reads the turn off the message's `turn_id`. `SpokenMessageText` fills the `StreamedMessageText` slot with it:

```tsx title="SpokenMessageText.tsx"
import type { Session } from "@stream-io/stream-agents";
import { createContext, useContext, useState } from "react";
import {
  MessageText,
  StreamedMessageText,
  useMessageContext,
  type StreamedMessageTextProps,
} from "stream-chat-react";
import { useHeardText } from "stream-chat-react/ai-components";

/** The voice session's conversation, provided around the transcript. */
export const VoiceSessionContext = createContext<Session | undefined>(
  undefined,
);

// Only a reply that is new when it mounts is paced. Older ones show in full.
const LIVE_WINDOW_MS = 10_000;

export const SpokenMessageText = (props: StreamedMessageTextProps) => {
  const session = useContext(VoiceSessionContext);
  const { message: messageFromContext } = useMessageContext();
  const message = props.message ?? messageFromContext;

  const [live] = useState(
    () => Date.now() - (message.created_at?.getTime() ?? 0) < LIVE_WINDOW_MS,
  );

  const { heardText, isPaced } = useHeardText({
    text: message.text ?? "",
    heard: (now) => session?.speech.heard(message, now),
    subscribe: session
      ? (listener) => session.speech.subscribe(listener)
      : undefined,
    generating: Boolean(message.generating),
    live,
  });

  if (!isPaced) return <StreamedMessageText {...props} />;

  return (
    <MessageText
      message={{ ...message, text: heardText }}
      renderText={props.renderText}
    />
  );
};
```

Register it in `VoiceCall`: add it to the transcript's overrides, and provide the session around the transcript:

```tsx title="VoiceCall.tsx"
import { SpokenMessageText, VoiceSessionContext } from "./SpokenMessageText";

const overrides = {
  Attachment: TranscriptAttachment,
  EmptyStateIndicator: NothingSaidYet,
  StreamedMessageText: SpokenMessageText,
};

// In VoiceCall, around the transcript:
<VoiceSessionContext.Provider value={voice.session}>
  <WithComponents overrides={overrides}>
    <Chat client={chatClient} isMessageAIGenerated={isMessageAIGenerated}>
      <Channel channel={channel}>
        <MessageList />
        <AIStateIndicator />
      </Channel>
    </Chat>
  </WithComponents>
</VoiceSessionContext.Provider>;
```

How the reveal behaves:

- A reply is revealed in step with its audio: each utterance starts when it's heard on the call and runs for the length of its audio.
- A reply whose audio hasn't started yet stays empty for up to five seconds, then shows in full if no speech comes.
- A reply the user cuts off stops at what was heard.
- What people say, and messages loaded from history, render as usual: `heard` returns `undefined` for them, and the component renders the stock `StreamedMessageText`.

Use it only on voice sessions. In a conversation held in writing there is no speech to follow, so keep the default typewriter there. [SDK integration](https://getstream.io/chat/docs/sdk/react/v14/ai-components/sdk-integration/#streamedmessagetext) explains the `StreamedMessageText` slot.

## Style spoken turns and interruptions

Register a `MessageUI` that adds classes from the custom fields and keeps the default one inside. The default draws a message with no text as an empty bubble, so it leaves out a cut-off reply whose text was cleared, and ignored speech that couldn't be deleted:

```tsx title="AgentMessage.tsx"
import clsx from "clsx";
import { MessageUI, useMessageContext } from "stream-chat-react";

export const AgentMessage = () => {
  const { message } = useMessageContext();

  // Nothing to show: leave it out of the transcript.
  if (!message.text && !message.attachments?.length) return null;

  return (
    <div
      className={clsx("agent-message", {
        "agent-message--speech": message.source === "speech",
        "agent-message--generating": message.generating,
        "agent-message--interrupted": message.interrupted,
      })}
    >
      <MessageUI />
      {message.interrupted && message.text ? (
        <div className="agent-message__note">Interrupted</div>
      ) : null}
    </div>
  );
};
```

```css
.agent-message--interrupted .str-chat__message-bubble {
  opacity: 0.6;
}

.agent-message__note {
  font-size: 0.75rem;
  opacity: 0.7;
}
```

Add it to the same overrides as `SpokenMessageText`:

```tsx
const overrides = {
  Attachment: TranscriptAttachment,
  EmptyStateIndicator: NothingSaidYet,
  MessageUI: AgentMessage,
  StreamedMessageText: SpokenMessageText,
};
```

To replace the reply text itself, render [`StreamingMessage`](https://getstream.io/chat/docs/sdk/react/v14/ai-components/sdk-integration/#streamingmessage) or `AIMarkdown` from `stream-chat-react/ai-components` inside your own message.

## Switch between typing and talking

Typed and spoken turns are one conversation, with one history and one channel. To turn voice on in a conversation the user has been typing in, start voice on its session and join with `VoiceSession.of`:

```tsx
import { VoiceSession } from "@stream-io/stream-agents/video";

// Puts the agent on the session's call.
await session.voice.start();
// `VoiceCall` joins it, as above.
const voice = VoiceSession.of(session);

// Back to typing: leave the call, then take the agent off it.
await voice.leave();
await session.voice.stop();
```

Leaving a session that came from `VoiceSession.of` leaves it running. `voice.end()` ends it, whoever started it.

While the call is off, show the composer, and send through the session. The agent doesn't answer a message posted to the channel, and `session.responses.create` writes the user's message into the channel itself:

```tsx title="SessionComposer.tsx"
import type { Session } from "@stream-io/stream-agents";
import { useMemo } from "react";
import { MessageComposer, WithComponents } from "stream-chat-react";

// The submit handler sends only the text, so leave out the attachment button.
const NoAttachments = () => null;

export const SessionComposer = ({ session }: { session: Session }) => {
  // The default Stop button sends `ai_indicator.stop`, which the agent
  // doesn't act on, so stop the reply through the session.
  const overrides = useMemo(
    () => ({
      AttachmentSelector: NoAttachments,
      StopAIGenerationButton: () => (
        <button
          aria-label="Stop"
          className="str-chat__stop-ai-generation-button"
          onClick={() => session.interrupt()}
          type="button"
        />
      ),
    }),
    [session],
  );

  return (
    <WithComponents overrides={overrides}>
      <MessageComposer
        overrideSubmitHandler={async ({ message }) => {
          await session.responses.create(message.text ?? "");
        }}
      />
    </WithComponents>
  );
};
```

Render it inside `Channel`, under `AIStateIndicator`, only while the call is off: `{!voiceOn && <SessionComposer session={session} />}`. Photos go to `responses.create` as `images`, data URIs the router uploads to the channel and shows on the user's message, not as the composer's attachments. The [React assistant](https://github.com/GetStream/Vision-Agents/tree/accelerate/examples/voice_agents/react_assistant) sample sends photos, and switches between typing and talking.

## Ask before a tool runs

A tool that runs in the browser can ask the user before each call. Register it with an `approval`, and pass the tools to `VoiceSession.start` in `startCall`:

```tsx
import { Tools } from "@stream-io/stream-agents";
import { VoiceSession } from "@stream-io/stream-agents/video";

export const tools = new Tools().register({
  name: "get_city",
  description:
    "The city the user is in. Call it only when the answer depends on it.",
  parameters: {
    type: "object",
    properties: {
      reason: {
        type: "string",
        description: "Why you need it, in a few words",
      },
    },
    required: ["reason"],
  },
  approval: {
    title: "Share your city?",
    message: "The agent gets your city, once.",
    // Shown beside the question: the model's own words for why it asks.
    reasonArgument: "reason",
  },
  // `lookUpCity` is your own code. It runs here, and only
  // its answer leaves the page.
  run: () => lookUpCity(),
});

// In startCall:
const voice = await VoiceSession.start(api.agent(agent), { tools });
```

On a call the agent asks out loud too, and a call the user already allowed by voice runs without asking again. A call that waits for an answer here sits in `session.conversation`'s `approvals` until `session.decide` answers it. Show it over the call with `AIToolApprovalCard`:

```tsx title="ToolQuestion.tsx"
import type { Session } from "@stream-io/stream-agents";
import { useSyncExternalStore } from "react";
import { AIToolApprovalCard } from "stream-chat-react/ai-components";

/** The oldest question a tool waits on. */
export const ToolQuestion = ({ session }: { session: Session }) => {
  const { approvals } = useSyncExternalStore(
    session.conversation.subscribe,
    session.conversation.get,
  );
  const request = approvals[0];
  if (!request) return null;

  return (
    <AIToolApprovalCard
      approval={{
        title: request.approval.title,
        message: request.approval.message,
        reason: request.reason,
        allowTitle: request.approval.allowTitle,
        declineTitle: request.approval.declineTitle,
      }}
      key={request.id}
      onDecide={(allowed) => session.decide(request.id, { allowed })}
    />
  );
};
```

Render `<ToolQuestion session={voice.session} />` in `VoiceCall`, under the glow. Allowed, the tool runs and its result goes back to the agent. Declined, it never runs, and the agent is told the user said no. [Tool approvals](https://getstream.io/chat/docs/sdk/react/v14/ai-components/reasoning-and-tool-calls/#tool-approvals) shows the same question in a typed conversation.

## Show the agent the camera

An agent with the `vision` skill can look through the browser's camera. Join with the camera on with `voice.join({ camera: true })`, or turn it on during the call with `voice.setCameraEnabled(true)`. It starts on the back lens where there is one, and the snapshot's `cameraEnabled` says whether it's on:

```tsx
const { cameraEnabled } = useSyncExternalStore(voice.subscribe, voice.get);

<Button
  appearance="outline"
  onClick={() =>
    void voice.setCameraEnabled(!cameraEnabled).catch(() => undefined)
  }
  size="md"
  type="button"
  variant="secondary"
>
  {cameraEnabled ? "Stop showing the camera" : "Show the camera"}
</Button>;
```

To show the user what the agent sees, play the `mediaStream` from the Video SDK's `useCameraState()` in a muted `<video>`. [Video](https://getstream.io/agents/docs/video/?lang=javascript) covers what the agent sees.

## Next steps

- [Personal assistant](https://getstream.io/chat/docs/sdk/react/v14/ai-components/use-cases/personal-assistant/): a typed assistant with tools that run in the browser.
- [Voice visualizations](https://getstream.io/chat/docs/sdk/react/v14/ai-components/voice-visualizations/): the options of `VoiceGlow`, `VoiceWave` and `useAudioLevel`.
- [Sessions](https://getstream.io/agents/docs/sessions/?lang=javascript#voice-ui-transcript-captions-and-status): the voice UI's states and how a voice session ends.
- [Purchase a phone number](https://getstream.io/agents/docs/telephony/purchase-number/?lang=javascript): the same agent on the phone.

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react/v14/ai-components/use-cases/voice-agent/](https://getstream.io/chat/docs/sdk/react/v14/ai-components/use-cases/voice-agent/).