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.
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 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, AgentChat and AgentMessage in your project.
Best practices
- Give the agent your help center as knowledge, so it answers from what you published rather than from what the model guesses.
- Run the tools that reach your systems, such as orders and refunds, on your backend, not on the phone.
- 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 textIts instructions say what it's for:
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:
- https://help.acme.com/shipping
- https://help.acme.com/returns---
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 --syncKnowledge & 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. issue_refund declares an approval, so it runs only once the user allows it:
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 }, signal, { user }) => orders.find(order_id, user.id),
});
agent.tools.register({
name: "issue_refund",
description: "Refund an order",
parameters: order,
approval: { title: "Refund order {order_id}?" },
run: ({ order_id }, signal, { user }) => refunds.issue(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 }, signal, { user }) => helpdesk.open(user.id, summary),
});
await new Dispatch().host(agent).run();orders, refunds and helpdesk stand for your own systems. Tool calling & Dispatch shows the same worker in other languages.
Open the user's support conversation
Carry on the user's latest support session, so the agent has the history, or start one:
import io.getstream.agents.android.core.AgentSession
import io.getstream.agents.android.core.SessionQuery
suspend fun openSupport(): AgentSession {
val support = agents.agent("support")
val latest = support.sessions.query(SessionQuery(limit = 1)).items.firstOrNull()
// A session that was only a call has no written conversation.
if (latest != null && latest.conversationId.isNotEmpty()) {
return agents.attach(latest.id)
}
return support.chat()
}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. Open its chat the way the quickstart's AssistantViewModel does, with openSupport() in place of agents.agent("my-agent").chat():
class SupportViewModel(application: Application) : AndroidViewModel(application) {
private val _chat = MutableStateFlow<AgentChat?>(null)
val chat: StateFlow<AgentChat?> = _chat.asStateFlow()
init {
viewModelScope.launch {
_chat.value = AgentChat.open(application, openSupport(), viewModelScope)
}
}
}The reply actions under each answer keep rating, and leave out regenerating:
import androidx.compose.runtime.Composable
import io.getstream.chat.android.ai.compose.conversation.AIMessageRating
import io.getstream.chat.android.ai.compose.conversation.AIReplyActions
import io.getstream.chat.android.ai.compose.ui.component.AIConversationReplyActionsParams
import io.getstream.chat.android.ai.compose.ui.component.AIReplyActionsRow
import io.getstream.chat.android.ai.compose.ui.component.ChatAiComponentFactory
class SupportReplyActions(
private val ratings: Map<String, AIMessageRating>,
private val onRate: (messageId: String, rating: AIMessageRating?) -> Unit,
) : ChatAiComponentFactory {
@Composable
override fun AIConversationReplyActions(params: AIConversationReplyActionsParams) {
AIReplyActionsRow(
text = params.text,
actions = AIReplyActions(
rating = ratings[params.message.id],
onRate = { rating -> onRate(params.message.id, rating) },
),
modifier = params.modifier,
)
}
}The screen puts them together:
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import io.getstream.chat.android.ai.compose.conversation.AIMessageRating
import io.getstream.chat.android.ai.compose.conversation.AIMessageRole
import io.getstream.chat.android.ai.compose.ui.component.AIConversation
import io.getstream.chat.android.ai.compose.ui.component.AISuggestions
import io.getstream.chat.android.ai.compose.ui.component.ChatComposer
import io.getstream.chat.android.ai.compose.ui.component.LocalChatAiComponentFactory
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SupportScreen(viewModel: SupportViewModel = viewModel()) {
val chat by viewModel.chat.collectAsStateWithLifecycle()
var isCalling by remember { mutableStateOf(false) }
if (isCalling) {
VoiceAgentScreen(agent = "support", onClose = { isCalling = false })
return
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Help") },
actions = {
IconButton(onClick = { isCalling = true }) {
Icon(Icons.Default.Phone, contentDescription = "Call support")
}
},
)
},
) { padding ->
val current = chat
if (current == null) {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
SupportConversation(current, Modifier.padding(padding))
}
}
}
@Composable
fun SupportConversation(chat: AgentChat, modifier: Modifier = Modifier) {
val messages by chat.messages.collectAsStateWithLifecycle()
val answering by chat.isAnswering.collectAsStateWithLifecycle()
// How the user rated each reply, by message.
val ratings = remember { mutableStateMapOf<String, AIMessageRating>() }
val replyActions = remember {
SupportReplyActions(ratings) { id, rating ->
if (rating == null) ratings.remove(id) else ratings[id] = rating
}
}
CompositionLocalProvider(LocalChatAiComponentFactory provides replyActions) {
Column(modifier) {
AIConversation(
messages = messages,
modifier = Modifier.weight(1f),
isThinking = answering && messages.lastOrNull()?.role != AIMessageRole.Assistant,
approver = chat.approver,
emptyContent = {
AISuggestions(
suggestions = listOf("Where is my order?", "I want to return an item", "Talk to a person"),
onSuggestionClick = chat::send,
)
},
)
ChatComposer(
onSendClick = { chat.send(it.text) },
onStopClick = chat::stop,
isGenerating = answering,
)
}
}
}VoiceAgentScreen is the call screen of the Voice agent use case, and talks to the same agent, with the same knowledge and tools.
Everything else comes from the agent, with nothing more to add in the app:
- Order lookups show in the reply as the agent makes them, so the user sees what it checked.
- A refund shows "Refund order 1042?" under its call, with buttons to allow or decline it. The quickstart's
chat.approveranswers 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 you send them. In SupportConversation, send each one from onRate:
val scope = rememberCoroutineScope()
val replyActions = remember {
SupportReplyActions(ratings) { id, rating ->
if (rating == null) ratings.remove(id) else ratings[id] = rating
scope.launch { yourBackend.rate(sessionId = chat.session.id, messageId = id, rating = rating) }
}
}rating is AIMessageRating.Positive, AIMessageRating.Negative, or null when the user takes their rating back. The router also reviews and scores each session once it ends, which Sessions describes.
Next steps
- Voice agent: the call screen the Call support button opens.
- Purchase a phone number and Transfer to a person: the same agent on the phone.
- Tool calling & Dispatch: tools, approvals, timeouts and progress updates.