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

Personal Assistant

A personal assistant knows what's on the user's plate. It knows where they are and what time it is for them, searches their email, keeps every conversation they've had with it, and listens when they'd rather talk than type.

Note:

What you'll build

  • A side menu with the user's conversations, titled and searchable
  • The user's local time and location, as tools the agent runs in the browser
  • A question before the agent reads the user's location
  • The user's Gmail, which they connect from the conversation
  • Photos the user attaches to a question
  • A voice mode in the same conversation

Starts from: the Quickstart, with api, openConversation and useAgentMessages in your project, on stream-chat-react 14.15.0 or later, and the VoiceCall view of the Voice agent use case.

Best practices

  • Run in the browser what only the browser has, such as the user's local time or location. Only a tool's answer leaves the page.
  • Let the browser ask for a permission when a tool first needs it, not when the page loads.
  • Ask before a tool that shares or changes something, such as the user's location.
  • Let users connect their own accounts, such as Gmail, from the conversation. The agent never sees their password.

Define the assistant

Make the agent from the voice template, so it can talk as well as write, and give it each user's Gmail as a connector:

getstream agents init assistant --template voice
agents/assistant/agent.yaml
name: assistant
mode: voice
llm: llm-fast
connectors:
  - gmail # each user signs in to their own Gmail

Write what the assistant is for in instructions.md, then sync it with getstream agents sync assistant. Each user's account covers connectors that act as the user, and Catalog lists the others, such as Google Calendar and Notion.

Give the agent the browser's tools

A tool the page gives the agent runs in the browser. When the model calls it, its run runs and returns the answer the model sees. get_location declares an approval, so the user is asked first, with the reason the model gives:

tools.ts
import { Tools } from "@stream-io/stream-agents";
import { z } from "zod";

export const tools = new Tools()
  .register({
    name: "get_local_context",
    description:
      "The user's current date, local time, timezone and language. Call it before " +
      "answering anything that depends on today or the time.",
    parameters: z.object({}),
    run: () =>
      `It is ${new Date().toString()}, in ` +
      `${Intl.DateTimeFormat().resolvedOptions().timeZone}. ` +
      `Their language is ${navigator.language}.`,
  })
  .register({
    name: "get_location",
    description:
      "Where the user is. Only call it when the answer depends on it, " +
      "such as the weather or places nearby.",
    parameters: z.object({
      reason: z.string().describe("why you need it, in a few words"),
    }),
    displayTitle: "Checking where you are",
    approval: {
      title: "Share your location?",
      reasonArgument: "reason",
      allowTitle: "Share",
      declineTitle: "Not now",
    },
    run: () =>
      new Promise<string>((resolve, reject) =>
        navigator.geolocation.getCurrentPosition(
          ({ coords }) =>
            resolve(
              `Latitude ${coords.latitude}, longitude ${coords.longitude}.`,
            ),
          () => reject(new Error("The browser couldn't tell where they are.")),
        ),
      ),
  });

The browser asks for the location the first time get_location runs, once the user has allowed the call. A tool that throws tells the model what went wrong, so the agent can say so. Tools that run in the browser covers them in full.

Open a conversation

Pass the tools every time a conversation opens, new or carried on, since the session runs them only while the page holds it. A new one also names where a connector's sign-in comes back to, a page of your app, and carries a label that the list finds the assistant's conversations by. openConversation from the quickstart then opens the session's channel:

assistant.ts
import { api, openConversation, type AgentChat } from "./agent";
import { tools } from "./tools";

export const returnUrl = `${window.location.origin}/connected.html`;

/** Marks the conversations this page opens, for the list to find. */
export const conversationLabel = { opened_from: "assistant" };

export async function openAssistant(
  sessionId: string | undefined,
): Promise<AgentChat> {
  const assistant = api.agent("assistant");
  const session = sessionId
    ? await assistant.sessions.resume(sessionId, { tools })
    : await assistant.sessions.create({
        tools,
        returnUrl,
        custom: conversationLabel,
      });
  return openConversation(session);
}

The sign-in comes back to connected.html, which reports how it went and closes:

connected.html
<script type="module">
  import { completeConnectorSignIn } from "stream-chat-react/ai-components";
  completeConnectorSignIn();
</script>

Build it as a second entry of your app, such as a second input of a Vite build.

List the user's conversations

Each conversation is a session, which the router titles after its first answers. ConversationList lists the conversations this page opened, by their label, most recent first, and searches them. A conversation the user switched to voice keeps its label, so it stays in the list, and a call started elsewhere, which has none, stays out. It loads again whenever version changes, which AssistantHome below bumps when a conversation is created or answered:

ConversationList.tsx
import type { Schemas } from "@stream-io/stream-agents";
import { useEffect, useState } from "react";
import { Button } from "stream-chat-react";

import { api } from "./agent";
import { conversationLabel } from "./assistant";

export const ConversationList = ({
  onSelect,
  selectedId,
  version,
}: {
  onSelect: (sessionId: string | undefined) => void;
  /** The open conversation, marked in the list. */
  selectedId?: string;
  /** Changes when the list may have: a conversation created, or answered. */
  version: number;
}) => {
  const [sessions, setSessions] = useState<Schemas["Session"][]>([]);
  const [search, setSearch] = useState("");

  useEffect(() => {
    let isCurrent = true;
    const all = api.agent("assistant").sessions;
    // The conversations this page opened, typed or switched to voice.
    const filters = { limit: 50, custom: conversationLabel };
    const page = search ? all.search(search, filters) : all.query(filters);
    page.then(
      ({ items }) => {
        // An incognito session keeps no conversation to open.
        if (isCurrent)
          setSessions(items.filter((each) => each.conversation_id));
      },
      () => undefined,
    );
    return () => {
      isCurrent = false;
    };
  }, [search, version]);

  return (
    <nav className="conversations">
      <Button
        appearance="solid"
        onClick={() => onSelect(undefined)}
        size="sm"
        type="button"
        variant="primary"
      >
        New conversation
      </Button>
      <input
        className="conversations__search"
        onChange={(event) => setSearch(event.target.value)}
        placeholder="Search"
        type="search"
        value={search}
      />
      {sessions.map((session) => (
        <button
          aria-current={session.id === selectedId}
          className="conversations__item"
          key={session.id}
          onClick={() => onSelect(session.id)}
          type="button"
        >
          {session.title || "New conversation"}
        </button>
      ))}
    </nav>
  );
};

Put it together

AssistantHome puts the list beside the open conversation, and opens a conversation when the user picks one in the list. Opening from the click rather than from an effect creates one session per New conversation, Strict Mode included, and a conversation that's no longer shown stops being watched. The conversation's approver answers the questions of the browser's tools, and gets an authorizer from createPopupAuthorizer, so when the agent first needs Gmail, the reply shows a Connect Gmail card that signs the user in, in a popup:

AssistantHome.tsx
import {
  type FormEvent,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  useSyncExternalStore,
} from "react";
import { Chat } from "stream-chat-react";
import {
  AIClientIdentity,
  AIConversation,
  AIMessageComposer,
  createPopupAuthorizer,
  type AIToolApprover,
} from "stream-chat-react/ai-components";

import type { AgentChat } from "./agent";
import { openAssistant, returnUrl } from "./assistant";
import { ConversationList } from "./ConversationList";
import { useAgentMessages } from "./useAgentMessages";

const messageOf = (error: unknown) =>
  error instanceof Error ? error.message : String(error);

export const AssistantHome = () => {
  const [chat, setChat] = useState<AgentChat>();
  const [isOpening, setIsOpening] = useState(false);
  const [failure, setFailure] = useState<string>();
  // Bumped when the list may have changed, so it loads again.
  const [listVersion, setListVersion] = useState(0);
  const refreshList = useCallback(() => setListVersion((n) => n + 1), []);
  // The latest pick, so an open that a later pick overtook is dropped.
  const latestPick = useRef(0);

  // Opens from the list's click, never from an effect, so New conversation
  // creates one session per click.
  const open = async (sessionId: string | undefined) => {
    const pick = ++latestPick.current;
    setChat(undefined);
    setFailure(undefined);
    setIsOpening(true);
    try {
      const opened = await openAssistant(sessionId);
      if (pick !== latestPick.current) {
        void opened.session.stopWatching();
        return;
      }
      setChat(opened);
      if (!sessionId) refreshList();
    } catch (error) {
      if (pick === latestPick.current) setFailure(messageOf(error));
    } finally {
      if (pick === latestPick.current) setIsOpening(false);
    }
  };

  // Stops watching a conversation once another one opens, or the page goes.
  // The conversation goes on; picking it again carries it on.
  useEffect(() => {
    if (!chat) return;
    return () => void chat.session.stopWatching();
  }, [chat]);

  return (
    // `str-chat` is the SDK's theme scope: its fonts, colors and spacing.
    <div className="str-chat assistant">
      <ConversationList
        onSelect={(sessionId) => void open(sessionId)}
        selectedId={chat?.session.id}
        version={listVersion}
      />
      {chat ? (
        <AssistantConversation
          chat={chat}
          key={chat.session.id}
          onReplied={refreshList}
        />
      ) : (
        <p
          className="assistant-placeholder"
          role={failure ? "alert" : undefined}
        >
          {failure ??
            (isOpening
              ? "Opening…"
              : "Pick a conversation, or start a new one.")}
        </p>
      )}
    </div>
  );
};

const AssistantConversation = ({
  chat,
  onReplied,
}: {
  chat: AgentChat;
  /** Called when a reply ends, which is when the router titles a new conversation. */
  onReplied: () => void;
}) => {
  const { session, client, channel } = chat;
  const messages = useAgentMessages(channel);
  const [failure, setFailure] = useState<string>();
  // The router refuses a question while the agent is still answering one.
  const { kind } = useSyncExternalStore(
    session.state.subscribe,
    session.state.get,
  );
  const isAnswering = kind === "responding" || kind === "working";

  const wasAnswering = useRef(false);
  useEffect(() => {
    if (wasAnswering.current && !isAnswering) onReplied();
    wasAnswering.current = isAnswering;
  }, [isAnswering, onReplied]);

  const approver = useMemo<AIToolApprover>(
    () => ({
      userId: client.userID ?? "",
      clientId: AIClientIdentity.installId(),
      decide: async (call, allowed) => session.decide(call.id, { allowed }),
      authorizer: createPopupAuthorizer({
        returnUrl,
        decline: async (call) => session.declineLogin(call.id),
      }),
    }),
    [client, session],
  );

  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    // The draft waits until the reply is done, as the send button does.
    if (isAnswering) return;
    const form = event.currentTarget;
    const text = new FormData(form).get("message");
    if (typeof text !== "string" || !text.trim()) return;
    setFailure(undefined);
    // Cleared once it's sent, so a refused question stays in the composer.
    session.responses.create(text.trim()).then(
      () => form.reset(),
      (error: unknown) => setFailure(messageOf(error)),
    );
  };

  return (
    <Chat client={client}>
      <main className="assistant-conversation">
        <div className="assistant-messages">
          <AIConversation approver={approver} messages={messages} />
        </div>
        <div className="assistant-composer">
          {failure && <p role="alert">{failure}</p>}
          <AIMessageComposer onSubmit={handleSubmit}>
            <AIMessageComposer.TextInput placeholder="Ask anything" />
            <div className="assistant-composer__actions">
              <AIMessageComposer.SubmitButton />
            </div>
          </AIMessageComposer>
        </div>
      </main>
    </Chat>
  );
};

Lay the page out as a row: the list in a fixed-width column, and the conversation in a column the height of the page, with its messages as the growing child, since the conversation scrolls inside itself. The page renders inside an element with the str-chat class, the SDK's theme scope, so the AI components and the SDK's Button take the SDK's fonts, colors and spacing. The composer's form grows to fill a flex container it's placed in, so it sits in .assistant-composer, which takes only the height it needs. The composer stacks what's inside it, so its buttons go in a row of their own:

.assistant {
  display: flex;
  height: 100dvh;
}

.conversations {
  display: flex;
  flex: none;
  flex-direction: column;
  width: 260px;
  overflow-y: auto;
}

.assistant-placeholder,
.assistant-conversation {
  flex: 1;
  min-width: 0;
}

.assistant-conversation {
  display: flex;
  flex-direction: column;
}

.assistant-messages {
  flex: 1;
  min-height: 0;
}

.conversations {
  gap: 0.25rem;
  padding: 0.75rem;
  background: var(--str-chat__background-core-surface-subtle);
}

.conversations__search {
  padding: 0.375rem 0.75rem;
  border: 1px solid var(--str-chat__border-core-default);
  border-radius: var(--str-chat__radius-max);
  font: inherit;
}

.conversations__item {
  padding: 0.5rem 0.75rem;
  border: none;
  border-radius: var(--str-chat__radius-md);
  background: none;
  color: var(--str-chat__text-primary);
  font: inherit;
  text-align: start;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
}

.conversations__item:hover {
  background: var(--str-chat__background-utility-hover);
}

.conversations__item[aria-current="true"] {
  background: var(--str-chat__background-utility-selected);
}

.assistant-placeholder {
  align-self: center;
  text-align: center;
  color: var(--str-chat__text-secondary);
}

.assistant-composer {
  padding: 0 1rem 1rem;
}

.assistant-composer__actions {
  display: flex;
  justify-content: flex-end;
  gap: 0.25rem;
}

/* One centered column for the messages and the composer. */
.assistant-messages .str-chat__ai-conversation__turns,
.assistant-composer {
  box-sizing: border-box;
  width: 100%;
  max-width: 48rem;
  margin-inline: auto;
}

The list sits on the SDK's subtle surface, with the open conversation marked, and the last rule keeps the messages and the composer in one centered column, so lines stay short enough to read on a wide window. The conversation still scrolls across the full width, so its scrollbar stays at the window's edge.

Chat gives the components their translations: outside it, their accessible names read as raw keys, such as aria/Send. On a narrow window, show the list in a drawer the user opens from a menu button instead.

The reply shows the question of get_location under its call, and the Connect Gmail card when a Gmail call waits for the user. Not now calls session.declineLogin, so the call runs nothing and the agent is told the user didn't connect. Tool approvals and Tool sign-ins cover both cards.

Send photos

The composer above sends only the text. To send the photos the user attaches, keep them as the user picks them, as Composer shows, and pass them to responses.create as images. A page has nowhere public to put a photo, so send each one inline, as a data URI, shrunk to a size the model needs:

photos.ts
import type { ImageSource } from "@stream-io/stream-agents";

export async function imageSource(file: File): Promise<ImageSource> {
  const bitmap = await createImageBitmap(file);
  const scale = Math.min(1, 1280 / Math.max(bitmap.width, bitmap.height));
  const canvas = document.createElement("canvas");
  canvas.width = Math.round(bitmap.width * scale);
  canvas.height = Math.round(bitmap.height * scale);
  canvas.getContext("2d")?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
  return { url: canvas.toDataURL("image/jpeg", 0.7) };
}
const send = async (text: string, photos: File[], thinkHarder: boolean) => {
  const images = await Promise.all(
    photos.filter((file) => file.type.startsWith("image/")).map(imageSource),
  );
  await session.responses.create(text, {
    images,
    reasoningEffort: thinkHarder ? "high" : undefined,
  });
};

Add <AIMessageComposer.FileInput accept="image/*" name="attachments" /> to the composer for the user to pick them. Pick a model that reads images in agent.yaml. reasoningEffort is for an option such as "Think harder", which the composer's chat options add, as Composer shows.

Talk instead of typing

A Talk button switches the open conversation to voice. The agent joins a call with everything said so far, and what's said on the call is written into the same conversation. Start voice on the session, and show the call with VoiceCall while it's on:

AssistantHome.tsx
import { VoiceSession } from "@stream-io/stream-agents/video";
// The call view of the Voice agent use case.
import { VoiceCall } from "./VoiceCall";

// In AssistantConversation, with its other hooks:
const [voice, setVoice] = useState<VoiceSession>();

// Off the call when the call view closes: on Hang up, or when the
// conversation closes mid-call, such as when the user opens another one.
useEffect(() => {
  if (!voice) return;
  return () => {
    void voice.leave().catch(() => undefined);
    // Takes the agent off the call. The conversation goes on in writing.
    void session.voice.stop().catch(() => undefined);
  };
}, [voice, session]);

// From the Talk button's click, since the browser plays audio and asks for the
// microphone only after one.
const talk = async () => {
  setFailure(undefined);
  try {
    await session.voice.start();
    setVoice(VoiceSession.of(session));
  } catch (error) {
    setFailure(messageOf(error));
  }
};

// And before its return, after every hook:
if (voice) {
  return (
    <VoiceCall
      channel={channel}
      chatClient={client}
      onHangUp={() => setVoice(undefined)}
      voice={voice}
    />
  );
}

Add a Talk button that calls talk to the composer's row, before the send button: <Button appearance="outline" onClick={() => void talk()} size="sm" type="button" variant="secondary">Talk</Button>, with Button from stream-chat-react. VoiceCall joins the call, shows the agent as a glowing globe with what's said written out under it, and leaves the call when the user hangs up, which keeps the session running. The effect then takes the agent off the call with session.voice.stop(), and the conversation goes on in writing. It does the same when the conversation closes mid-call, and leaving twice does nothing, as the Voice agent use case describes.

Next steps