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 refunds on your backend, with a question to the user on the phone before a refund
  • A ticket for your team when the agent can't help
  • A help screen that carries on the user's latest support conversation, with ratings and a button that calls support

Starts from: the Quickstart, with agents, AgentMessage and AgentTransport in your project, and the VoiceAgentScreen of the Voice agent use case. agents is the StreamAgents its signIn returns, kept where your screens reach it, such as final agents = signIn('jane'); in lib/agents.dart. The code below imports the others from lib/agent_transport.dart.

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 on the phone 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. Each call knows the user it's for, so a user only finds their own orders:

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

const agent = new Client().agent("support");
const order = z.object({ order_id: z.string() });

agent.tools.register({
  name: "lookup_order",
  description: "Look up one of the customer's orders",
  parameters: order,
  run: ({ order_id }, { user }) => orders.find(order_id, user.id),
});

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

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

orders and helpdesk stand for your own systems. 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 conversation asks the user, and once they allow it, the tool calls your backend, which checks the user and the order and issues the refund:

lib/support.dart
import 'package:stream_agents_core/stream_agents_core.dart';

final issueRefund = AgentTool(
  name: 'issue_refund',
  description: "Refund one of the customer's orders",
  parameters: AgentTool.strings(
    {
      'order_id': 'the order to refund',
      'summary': r'the order and the amount, such as Order 1042, $39.90',
    },
    required: ['order_id', 'summary'],
  ),
  displayTitle: 'Refunding your order',
  approval: const ToolApproval(
    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: (arguments) async {
    final orderId = arguments['order_id'];
    if (orderId is! String) throw StateError('No order to refund.');
    await yourBackend.refund(orderId);
    return 'Refunded order $orderId.';
  },
);

yourBackend.refund stands for a call to 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 session, so the agent has the history, or start one. Pass issueRefund either way, since the session runs it only while the app holds it. Add openSupport to lib/support.dart, and import agent_transport.dart and agents.dart there:

lib/support.dart
Future<AgentTransport> openSupport() async {
  final support = agents.agent('support');
  final latest = (await support.sessions.query(limit: 1)).items.firstOrNull;
  // A session that was only a call has no written conversation.
  if (latest != null && latest.conversationId.isNotEmpty) {
    return AgentTransport.open(await agents.attach(latest.id, tools: [issueRefund]));
  }
  return AgentTransport.open(await support.chat(SessionOptions(tools: [issueRefund])));
}

Build the support screen

The support screen is the quickstart's conversation with common questions while it's empty, and a button that calls support. Its actions keep rating, and leave out editing and regenerating:

lib/support_screen.dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter_ai/stream_chat_flutter_ai.dart';

import 'agent_transport.dart';
import 'support.dart';
import 'voice_agent_screen.dart';

class SupportView extends StatefulWidget {
  const SupportView({super.key, required this.transport});

  final AgentTransport transport;

  @override
  State<SupportView> createState() => _SupportViewState();
}

class _SupportViewState extends State<SupportView> {
  late final _approver = widget.transport.approver();
  late final _conversation = AIConversation(
    transport: widget.transport,
    approver: _approver,
    actions: AIConversationActions.rate,
  );

  @override
  void dispose() {
    _conversation.dispose();
    _approver.dispose();
    super.dispose();
  }

  void _call() => Navigator.of(context).push(
    MaterialPageRoute<void>(
      fullscreenDialog: true,
      // The call's session runs the refund too, so it needs the tool.
      builder: (context) => VoiceAgentScreen(agent: 'support', tools: [issueRefund]),
    ),
  );

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(
      title: const Text('Help'),
      actions: [
        IconButton(tooltip: 'Call support', icon: const Icon(Icons.phone), onPressed: _call),
      ],
    ),
    body: SafeArea(
      child: Column(
        children: [
          Expanded(
            child: AIConversationView.conversation(
              conversation: _conversation,
              emptyContent: AISuggestionsView(
                suggestions: const ['Where is my order?', 'I want to return an item', 'Talk to a person'],
                onSuggestionSelected: _conversation.send,
              ),
            ),
          ),
          ChatComposer.conversation(conversation: _conversation),
        ],
      ),
    ),
  );
}

Open it the way the quickstart's AssistantScreen does, with openSupport() in place of widget.agents.agent('my-agent').chat().then(AgentTransport.open), and SupportView in place of AssistantView. VoiceAgentScreen, in lib/voice_agent_screen.dart, is the call screen of the Voice agent use case, and talks to the same agent, with the same knowledge and tools. Add it, with voice_call_screen.dart, agent_glow.dart and approval_card.dart, which asks before a refund on the call, the permission_handler dependency and the microphone permission, to your app from there.

Everything else comes from the agent and the tools above:

  • Order lookups show in the reply as the agent makes them, so the user sees what it checked.
  • A refund shows "Refund this order?" under its call, with the order and amount and buttons to allow or decline it. The approver from the quickstart's AgentTransport answers it. Declined, the refund never runs, and the agent is told so.
  • 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

Ratings stay on the device unless the transport sends them. Add a rate to AgentTransport:

lib/agent_transport.dart
@override
Future<void> rate(AgentMessage reply, AIMessageRating? rating) =>
    yourBackend.rate(sessionId: session.id, messageId: reply.id, rating: rating);

rating is AIMessageRating.positive, AIMessageRating.negative, or null when the user takes their rating back. If rate throws, the reply goes back to the rating it had. The router also reviews and scores each session once it ends, which Sessions describes.

Next steps