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).

Voice Agent

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, 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:

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

STT and TTS & Voices list the models and voices.

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

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:

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:

<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:

.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:

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:

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 lists the options of both, and the Video SDK's 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:

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:

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:

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 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:

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:

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 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:

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>
  );
};
.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:

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

To replace the reply text itself, render 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:

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:

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 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:

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:

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 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:

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 covers what the agent sees.

Next steps