Info:
This is documentation for Stream Chat React Native SDK v9, which is no longer actively maintained. For up-to-date documentation, see the latest version (v10).

Customer Support Agent

A support agent answers the questions your support team answers every day, at any hour, and hands the rest over to your team.

Note:

What you'll build

  • A support agent that answers from your help center and stays on topic
  • Order lookups and tickets on your backend
  • Refunds the user allows first, from a question in the reply
  • A help screen that carries on the user's latest support conversation, with common questions, ratings and a way to talk to support

Starts from: the Quickstart, with api, openConversation and createAgentTransport in your project, on stream-chat-react-native or stream-chat-expo 9.14.0 or later, and the VoiceMode button of the Voice agent use case.

Best practices

  • Give the agent your help center as knowledge, so it answers from what you published rather than from what the model guesses.
  • Keep what reaches your systems, such as orders and refunds, on your backend. A tool in the app only calls your API, which checks who is signed in.
  • Ask before anything that can't be undone, such as a refund.
  • Always offer a way to reach a person.

Define the support agent

Make the agent from the text template:

getstream agents init support --template text

Its instructions say what it's for:

agents/support/instructions.md
You are Acme's support assistant. Answer questions about Acme orders, shipping,
returns and billing, briefly.

- Look up an order before you answer a question about it.
- When you can't help, or the customer asks for a person, open a ticket.

Its knowledge is your help center, and its guardrail turns away what isn't about Acme:

agents/support/knowledge/urls.yaml
- https://help.acme.com/shipping
- https://help.acme.com/returns
agents/support/guardrail.md
---
refusal: I can only help with questions about your Acme account and orders.
---

Answer questions about Acme orders, billing, shipping and returns. Refuse anything else.

Sync it, and try it in the terminal:

getstream agents test support --sync

Knowledge & Skills and Guardrails cover both in full.

Host its tools on your backend

The tools that reach your systems run on your backend, in a dispatch worker. Every session of the agent can use them, including the ones your users open from the app. Each call knows the user it's for, so a user only finds their own orders:

worker.ts
import { Client, Dispatch, type ToolCall } from "@stream-io/stream-agents";
import { z } from "zod";

const agent = new Client().agent("support");

// The user whose turn made the call. It's empty when the router didn't say,
// so refuse those rather than look up orders for nobody in particular.
const customerOf = ({ user }: ToolCall) => {
  if (!user.id) throw new Error("The call doesn't say who the customer is.");
  return user.id;
};

agent.tools.register({
  name: "lookup_order",
  description: "Look up one of the customer's orders",
  parameters: z.object({ order_id: z.string() }),
  run: ({ order_id }, signal, call) => orders.find(order_id, customerOf(call)),
});

agent.tools.register({
  name: "open_ticket",
  description: "Hand the conversation over to the support team",
  parameters: z.object({ summary: z.string() }),
  run: ({ summary }, signal, call) => helpdesk.open(customerOf(call), summary),
});

await new Dispatch().host(agent).run();

orders and helpdesk stand for your own systems. A call that doesn't name a user fails, and the model is told why, so nothing runs without a customer. The worker runs on your server with your API key and secret, and never in the app. Tool calling & Dispatch shows the same worker in other languages.

Ask before a refund

A tool the worker hosts asks its question only on a voice call. A refund should ask in writing too, so it's a tool the app gives the session. The app asks the user, and once they allow it, the tool calls your backend, which checks the user and the order and issues the refund:

support.ts
import { Tools } from "@stream-io/stream-agents";

import { fetchStreamToken } from "./agent";

export const tools = new Tools().register({
  name: "issue_refund",
  description: "Refund one of the customer's orders",
  parameters: {
    type: "object",
    properties: {
      order_id: { type: "string" },
      summary: {
        type: "string",
        description: "the order and the amount, such as Order 1042, $39.90",
      },
    },
    required: ["order_id", "summary"],
  },
  displayTitle: "Refunding your order",
  approval: {
    title: "Refund this order?",
    message: "The money goes back to the card the order was paid with.",
    reasonArgument: "summary",
    allowTitle: "Refund",
    declineTitle: "Not now",
  },
  run: async ({ order_id }: { order_id: string }) => {
    const response = await fetch("https://your-backend.example.com/refunds", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${await fetchStreamToken()}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ order_id }),
    });
    if (!response.ok) throw new Error("The refund didn't go through.");
    return `Refunded order ${order_id}.`;
  },
});

fetchStreamToken is the function you signed the user in with in the Quickstart, exported from agent.ts. The backend checks the token, so a refund is only ever for the signed-in user. The question shows the model's summary under its title, and a tool that throws tells the model what went wrong, so the agent can say so.

Open the user's support conversation

Carry on the user's latest support conversation held in writing, so the agent has the history, or start one. A call from the support screen is a session of its own, which modality: "text" leaves out. Pass the tools either way, since the session runs them only while the app holds it. openConversation from the quickstart then opens the session's channel:

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

async function open(): Promise<AgentChat> {
  const support = api.agent("support");
  // The newest conversation held in writing. Calls are sessions of their own.
  const { items } = await support.sessions.query({
    limit: 1,
    modality: "text",
  });
  const latest = items[0];
  const session = latest
    ? await support.sessions.resume(latest.id, { tools })
    : await support.sessions.create({ tools });
  return openConversation(session);
}

let opening: Promise<AgentChat> | undefined;

/** Opens the support conversation once per launch, however often it's asked. */
export function openSupport(): Promise<AgentChat> {
  opening ??= open().catch((error: unknown) => {
    // A failed open can be tried again.
    opening = undefined;
    throw error;
  });
  return opening;
}

openSupport hands every caller the same conversation. A screen that opens it from an effect can run that effect twice, and without it would start two sessions for a user who has none yet.

Build the support screen

The support screen is the quickstart's conversation, with common questions while it's empty, a way to try again when a question fails, and a way to talk to support. Its replies keep rating, and leave out regenerating and editing. The transport is the quickstart's, with a rate that sends each rating to your backend:

SupportScreen.tsx
import { useEffect, useState } from "react";
import {
  ActivityIndicator,
  Pressable,
  StyleSheet,
  Text,
  View,
} from "react-native";
import {
  AIClientIdentity,
  type AIConversationControllerState,
  AIConversationController,
  AISuggestions,
  ComposerView,
  useAIConversationState,
} from "stream-chat-react-native/ai-components";
import { AIConversation } from "stream-chat-react-native/ai-components/conversation";

import { chatClient, type AgentChat } from "./agent";
import { createAgentTransport } from "./agentTransport";
import { openSupport } from "./support";
// The voice mode button of the Voice agent use case.
import { VoiceMode } from "./VoiceMode";

const starters = [
  "Where is my order?",
  "I want to return an item",
  "Talk to a person",
];

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

export const SupportScreen = () => {
  const [chat, setChat] = useState<AgentChat>();
  const [failure, setFailure] = useState<string>();

  useEffect(() => {
    openSupport().then(setChat, (error: unknown) =>
      setFailure(messageOf(error)),
    );
  }, []);

  if (failure) return <Text style={styles.failure}>{failure}</Text>;
  return chat ? <SupportConversation chat={chat} /> : <ActivityIndicator />;
};

const SupportConversation = ({ chat }: { chat: AgentChat }) => {
  const { channel, session } = chat;
  const [conversation] = useState(
    () =>
      new AIConversationController({
        actions: { edit: false, regenerate: false },
        approver: {
          clientId: AIClientIdentity.installId(),
          // The refund's question is answered here, through the session.
          decide: async (call, allowed) => session.decide(call.id, { allowed }),
          userId: chatClient.userID ?? "",
        },
        transport: {
          ...createAgentTransport(session, channel),
          // Ratings stay on the device unless the transport sends them.
          rate: async (reply, rating) => {
            await fetch("https://your-backend.example.com/ratings", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({
                sessionId: session.id,
                messageId: reply.id,
                rating,
              }),
            });
          },
        },
      }),
  );
  useEffect(() => () => conversation.dispose(), [conversation]);

  return (
    <View style={styles.container}>
      <AIConversation conversation={conversation} />
      <ComposerView
        conversation={conversation}
        header={
          <>
            <Failure conversation={conversation} />
            <Starters conversation={conversation} />
          </>
        }
        trailing={<VoiceMode conversation={conversation} session={session} />}
      />
    </View>
  );
};

const emptySelector = ({
  messages,
  pendingMessage,
}: AIConversationControllerState) => ({
  isEmpty: messages.length === 0 && !pendingMessage,
});

/** Common questions, until the conversation has a first one. */
const Starters = ({
  conversation,
}: {
  conversation: AIConversationController;
}) => {
  const { isEmpty } = useAIConversationState(conversation, emptySelector);
  if (!isEmpty) return null;
  return (
    <AISuggestions
      onSelect={(text) => conversation.send({ text })}
      suggestions={starters}
    />
  );
};

const errorSelector = ({ error }: AIConversationControllerState) => ({ error });

/** Why the latest question failed, with a way to ask again. */
const Failure = ({
  conversation,
}: {
  conversation: AIConversationController;
}) => {
  const { error } = useAIConversationState(conversation, errorSelector);
  if (!error) return null;
  return (
    <Pressable accessibilityRole="button" onPress={() => conversation.retry()}>
      <Text style={styles.failure}>{error.message}</Text>
      <Text style={styles.retry}>Try again</Text>
    </Pressable>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1 },
  failure: { color: "#b00020", paddingHorizontal: 24, textAlign: "center" },
  retry: { color: "#005fff", fontWeight: "600", textAlign: "center" },
});

The screen renders inside StreamTheme and KeyboardProvider, as in the Quickstart. With them above it, the conversation makes room for the keyboard and the composer rides it, and the header of the composer rides the keyboard with it, so the common questions and the error stay in view while the user types. VoiceMode is the button of the Voice agent use case: it opens a hands-free conversation with the same agent, with the same knowledge and the tools your worker hosts.

Everything else comes from the agent, with nothing more to add in the screen:

  • Order lookups answer from your systems, and find only the signed-in user's orders.
  • A refund shows "Refund this order?" under its call, with the order and amount and buttons to allow or decline it. Declined, the refund never runs, and the agent is told so. Tool approvals covers the card. Answer it while the screen is open: a session answers the questions it was asked on its own connection, so one left over from before the app was reopened can't be answered.
  • A handover opens a ticket with the agent's summary, so nobody asks the user to start again. The Slack connector lets your team follow along.

Send ratings to your backend

The transport's rate sends each rating, with the session and the reply it's for. The controller calls it in order, shows the rating at once, and takes it back if the request fails, so the user sees what your backend has. rating is null when the user takes their rating back. Reply actions covers the buttons under each reply.

The router also reviews and scores each session once it ends, which Sessions describes.

Next steps