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 page that carries on the user's latest support conversation, with ratings and a way to call support

Starts from: the Quickstart, with api, openConversation and useAgentMessages in your project, and the VoiceAgent view 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 browser 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 browser. 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 with your API key and secret, so it never runs in the browser. 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 page gives the session. The page 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 { z } from "zod";

const tools = new Tools().register({
  name: "issue_refund",
  description: "Refund one of the customer's orders",
  parameters: z.object({
    order_id: z.string(),
    summary: z
      .string()
      .describe("the order and the amount, such as Order 1042, $39.90"),
  }),
  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 }) => {
    const response = await fetch("/api/refunds", {
      method: "POST",
      headers: { "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}.`;
  },
});

/api/refunds stands for your backend. 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 page is a session of its own, which modality: "text" leaves out. Pass the tools either way, since the session runs them only while the page holds it. openConversation from the quickstart then opens the session's channel:

support.ts
import { api, openConversation, type AgentChat } 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 page load, 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 page that opens it from an effect calls it twice in Strict Mode, and without that would start two sessions for a user who has none yet.

Build the support page

The support page is the quickstart's conversation, with common questions while it's empty and a way to call support. Its reply actions keep rating, and leave out regenerating. The approver sends the user's answer to the refund's question back to the session:

SupportPage.tsx
import {
  type FormEvent,
  useEffect,
  useMemo,
  useState,
  useSyncExternalStore,
} from "react";
import { Chat } from "stream-chat-react";
import {
  AIClientIdentity,
  AIConversation,
  AIMessageComposer,
  AISuggestions,
  type AIMessageRating,
  type AIToolApprover,
} from "stream-chat-react/ai-components";

import { api, type AgentChat } from "./agent";
import { openSupport } from "./support";
import { useAgentMessages } from "./useAgentMessages";
// The call view of the Voice agent use case.
import { VoiceAgent } from "./VoiceAgent";

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

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

  useEffect(() => {
    openSupport().then(setChat, (error: unknown) =>
      setFailure(error instanceof Error ? error.message : String(error)),
    );
  }, []);

  if (failure) return <p role="alert">{failure}</p>;
  return chat ? <SupportConversation chat={chat} /> : null;
};

const SupportConversation = ({ chat }: { chat: AgentChat }) => {
  const { session, client, channel } = chat;
  const messages = useAgentMessages(channel);
  const [ratings, setRatings] = useState<Record<string, AIMessageRating>>({});
  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 approver = useMemo<AIToolApprover>(
    () => ({
      userId: client.userId ?? "",
      clientId: AIClientIdentity.installId(),
      decide: async (call, allowed) => session.decide(call.id, { allowed }),
    }),
    [client, session],
  );

  // Resolves once the agent starts answering. Says so if the question was refused.
  const ask = async (text: string) => {
    setFailure(undefined);
    try {
      await session.responses.create(text);
      return true;
    } catch (error) {
      setFailure(error instanceof Error ? error.message : String(error));
      return false;
    }
  };

  const rate = (messageId: string, rating: AIMessageRating | null) =>
    setRatings(({ [messageId]: _, ...rest }) =>
      rating ? { ...rest, [messageId]: rating } : rest,
    );

  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;
    // Cleared once it's sent, so a refused question stays in the composer.
    void ask(text.trim()).then((sent) => sent && form.reset());
  };

  return (
    <Chat client={client}>
      {/* `str-chat` is the SDK's theme scope: its fonts, colors and spacing. */}
      <div className="str-chat support">
        <aside className="support-call">
          <VoiceAgent agent="support" api={api} />
        </aside>
        <div className="support-conversation">
          <AIConversation
            approver={approver}
            emptyContent={
              <AISuggestions
                onSelect={(text) => void ask(text)}
                suggestions={starters}
              />
            }
            messages={messages}
            replyActions={(message) => ({
              rating: ratings[message.id] ?? null,
              onRate: (rating) => rate(message.id, rating),
            })}
          />
        </div>
        <div className="support-composer">
          {failure && <p role="alert">{failure}</p>}
          <AIMessageComposer onSubmit={handleSubmit}>
            <AIMessageComposer.TextInput placeholder="Ask about an order" />
            <div className="support-composer__actions">
              <AIMessageComposer.SubmitButton />
            </div>
          </AIMessageComposer>
        </div>
      </div>
    </Chat>
  );
};

The page renders inside an element with the str-chat class, the SDK's theme scope. Inside it, the AI components and the SDK's Button take the SDK's fonts, colors and spacing, which they leave unset outside it.

Lay the page out with the conversation as the growing child of a column the height of the page, since the conversation scrolls inside itself. The composer's form grows to fill a flex container it's placed in, so it sits in .support-composer, which takes only the height it needs. The composer stacks what's inside it, with the text field on top, so put its buttons in a row of their own:

.support {
  display: flex;
  flex-direction: column;
  height: 100dvh;
}

.support-call {
  display: flex;
  justify-content: flex-end;
  padding: 0.75rem 1rem;
}

.support-conversation {
  flex: 1;
  min-height: 0;
}

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

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

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

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.

VoiceAgent is the call view of the Voice agent use case: its button puts the user on a call 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 page:

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

For a support assistant that floats over the rest of your app, the AI chat widget cookbook puts the conversation in a small window behind a chat bubble.

Send ratings to your backend

Ratings stay in the page unless you send them. Send each one in rate, with the session and the reply it's for:

const rate = (messageId: string, rating: AIMessageRating | null) => {
  setRatings(({ [messageId]: _, ...rest }) =>
    rating ? { ...rest, [messageId]: rating } : rest,
  );
  void fetch("/api/ratings", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ sessionId: session.id, messageId, rating }),
  });
};

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