Skip to content

Customer support agent

KB lookup + ticket creation + escalation, all in one function.

View .md

Customer support agent

An agent that handles tier-1 support: answers FAQs from a knowledge base, creates support tickets when it can't, and escalates urgent cases to a human.

Architecture

Code
Customer message | v +----------------+ | AI Agent | +----------------+ | tools: ├─ search_kb → returns relevant FAQ snippets ├─ create_ticket → opens a ticket in your help desk ├─ check_ticket → status lookup └─ escalate_to_human → pages on-call via Slack

Setup

terminal
npx zavudev fn init --slug support -y cd support

Replace index.ts:

TypeScript
import { defineAgent, defineTool } from "@zavudev/functions" defineAgent({ senderId: process.env.SENDER_ID!, name: "Aria", provider: "zavu", model: "anthropic/claude-3-5-haiku-20241022", // good at instruction-following channels: ["whatsapp"], prompt: `Eres Aria, asistente de soporte de Acme Corp. Tu misión: 1. Resolver preguntas frecuentes usando search_kb antes de cualquier otra cosa. 2. Si la pregunta es específica del cliente (cuenta, factura, pedido), crea un ticket con create_ticket. 3. Si el cliente está enojado o pide "hablar con alguien", llama escalate_to_human y promete que alguien responderá en 15 minutos. Reglas: - Respuestas cortas (WhatsApp). - Cita la fuente del KB cuando uses información de ahí. - NO inventes información. Si search_kb no devuelve nada relevante, di "no tengo esa información" y crea un ticket.`, }) defineTool({ name: "search_kb", description: "Search the knowledge base for FAQs, policies, how-tos. Call FIRST for any informational question.", parameters: { type: "object", properties: { query: { type: "string", description: "Customer's question, paraphrased if needed." }, topK: { type: "number", description: "Max results (default 3, max 10)." }, }, required: ["query"], }, handler: async ({ query, topK = 3 }) => { // Replace with your real vector search. Here we use the Convex action exposed // via the SDK (Zavu's built-in agent knowledge base). const results = await searchKnowledgeBase(query, topK) return { results: results.map((r) => ({ title: r.title, excerpt: r.excerpt, source: r.url, })), count: results.length, } }, }) defineTool({ name: "create_ticket", description: "Open a support ticket when you can't resolve the issue with KB lookup. " + "Use for account-specific questions (billing, orders, refunds, account changes).", parameters: { type: "object", properties: { subject: { type: "string", description: "Short title, 5-10 words." }, details: { type: "string", description: "Full context of the issue, what the customer said." }, priority: { type: "string", enum: ["low", "normal", "high", "urgent"], description: "low = informational, urgent = revenue/account at risk", }, }, required: ["subject", "details", "priority"], }, handler: async ({ subject, details, priority }, ctx) => { const ticket = await fetch(`${process.env.HELPDESK_URL}/tickets`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${process.env.HELPDESK_API_KEY}`, }, body: JSON.stringify({ subject, details, priority, contactPhone: ctx?.contactPhone, source: "whatsapp", }), }).then((r) => r.json()) return { ticketId: ticket.id, eta: priority === "urgent" ? "15 minutos" : "1 día hábil", summary: `Ticket #${ticket.id} creado. Te respondemos en ${ priority === "urgent" ? "15 min" : "1 día hábil" }.`, } }, }) defineTool({ name: "check_ticket", description: "Look up the status of an existing ticket by ID.", parameters: { type: "object", properties: { ticketId: { type: "string" } }, required: ["ticketId"], }, handler: async ({ ticketId }, ctx) => { const res = await fetch(`${process.env.HELPDESK_URL}/tickets/${ticketId}`, { headers: { authorization: `Bearer ${process.env.HELPDESK_API_KEY}` }, }) if (!res.ok) { return { error: "not_found", message: `No encuentro el ticket #${ticketId}.` } } const ticket = await res.json() // Privacy guard: only return if the contact owns this ticket. if (ticket.contactPhone !== ctx?.contactPhone) { return { error: "not_yours", message: "Ese ticket no está asociado a tu número." } } return { ticketId, status: ticket.status, lastUpdate: ticket.updatedAt, summary: ticket.publicSummary, } }, }) defineTool({ name: "escalate_to_human", description: "Page the on-call team via Slack. Call when the customer is frustrated, " + "explicitly asks for a human, or the issue is time-sensitive and outside your scope.", parameters: { type: "object", properties: { reason: { type: "string", description: "Why escalating (frustration, complexity, urgency)." }, summary: { type: "string", description: "What the customer needs, in 1-2 sentences." }, }, required: ["reason", "summary"], }, handler: async ({ reason, summary }, ctx) => { await fetch(process.env.SLACK_WEBHOOK_URL!, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: `:rotating_light: *Customer needs human support*\n` + `*From:* ${ctx?.contactPhone}\n` + `*Reason:* ${reason}\n` + `*Summary:* ${summary}\n` + `<https://dashboard.zavu.dev/inbox?contact=${ctx?.contactPhone}|Open conversation>`, }), }) return { escalated: true, eta: "15 minutos", summary: "Un agente humano responderá en aproximadamente 15 minutos.", } }, })

Knowledge base implementation

Several options for searchKnowledgeBase:

Option A: Zavu's built-in agent knowledge base

If you already use AI Agents knowledge bases, you do not need this tool at all. Retrieval runs automatically on every inbound message: Zavu pulls the relevant chunks and puts them in the agent's context before the model answers. Attach the knowledge base to the agent and delete searchKnowledgeBase.

To confirm an answer was actually grounded in your documents, read knowledgeChunksUsed on the execution:

terminal
npx zavudev agents executions --sender "$SENDER_ID" --json

A zero there on an agent that has documents attached means the reply was not grounded in them.

Option B: Your own vector store (Pinecone, Qdrant, pgvector)

TypeScript
async function searchKnowledgeBase(query: string, topK: number) { const embedding = await openai.embeddings.create({ model: "text-embedding-3-small", input: query, }) const results = await pinecone.index("kb").query({ vector: embedding.data[0].embedding, topK, includeMetadata: true, }) return results.matches.map((m) => ({ title: m.metadata.title, excerpt: m.metadata.excerpt, url: m.metadata.url, })) }

Set the secrets:

terminal
npx zavudev fn secrets set OPENAI_API_KEY sk-... npx zavudev fn secrets set PINECONE_API_KEY ...

Option C: Static FAQ (good for small KBs)

For < 50 FAQs, embedded text search is fine:

TypeScript
const FAQS = [ { title: "Refund policy", text: "We refund unused items within 30 days. Original receipt required.", url: "https://help.acme.com/refunds", }, // ... ] async function searchKnowledgeBase(query: string, topK: number) { const tokens = query.toLowerCase().split(/\s+/) const scored = FAQS.map((faq) => ({ ...faq, score: tokens.filter((t) => faq.text.toLowerCase().includes(t)).length, })) return scored .filter((f) => f.score > 0) .sort((a, b) => b.score - a.score) .slice(0, topK) }

Bumps to vector search later are a few lines of change.

Deploy

terminal
npx zavudev fn secrets set SENDER_ID jn76vnxet8g5nq661by3v06y1581bmmn npx zavudev fn secrets set HELPDESK_URL https://helpdesk.acme.com/api npx zavudev fn secrets set HELPDESK_API_KEY ... npx zavudev fn secrets set SLACK_WEBHOOK_URL https://hooks.slack.com/services/... npx zavudev deploy

Sample conversation

Code
👤 cuánto tiempo tengo para devolver? 🤖 [search_kb(query="refund policy")] 🤖 Tienes 30 días desde la compra para devolver productos sin usar. Necesitas el recibo original. 👤 ok ahora dime el estado de mi pedido #4521 🤖 [check_ticket(ticketId="4521") → not_yours] 🤖 No tengo registro de un pedido vinculado a tu número. ¿Lo compraste con otra cuenta? 👤 sí, lo compró mi pareja 🤖 [create_ticket(subject="Order status request", details="Customer asking about order #4521 made by their partner. Need verification.", priority="normal")] 🤖 Ticket #8842 creado. Un agente verificará la info de cuenta y te responde dentro de 1 día hábil. 👤 esto es ridículo, llevo 3 horas esperando! 🤖 [escalate_to_human(reason="Customer frustration", summary="Asking about partner's order #4521. Felt previous interactions too slow.")] 🤖 Lamento la espera. Acabo de notificar al equipo y alguien te escribe en los próximos 15 minutos. ¿Necesitas algo más mientras tanto?

Improvements you can layer

Sentiment-driven escalation

Detect frustration from message text without relying on the LLM's judgment:

TypeScript
const FRUSTRATION_TOKENS = [ "ridículo", "ridiculous", "horrible", "terrible", "harto", "fed up", "cancelo", "cancel my", "refund now" ] // Add a trigger export default defineFunction(async (event) => { if (event.type !== "message.inbound") return const text = (event.data.text ?? "").toLowerCase() if (FRUSTRATION_TOKENS.some(t => text.includes(t))) { await pageOnCall({ phone: event.data.from, text }) } })

Then npx zavudev fn triggers add --events message.inbound. This runs IN ADDITION to the agent — observer pattern.

Business hours awareness
TypeScript
defineTool({ name: "escalate_to_human", ..., handler: async ({ reason, summary }, ctx) => { const isWorkHours = checkWorkHours() if (!isWorkHours) { await pageOnCall(/* ... */) return { escalated: true, summary: "Estamos fuera de horario (Lun-Vie 9-18). Te respondemos mañana " + "a primera hora, o llama urgencias al +1-800-xxx.", } } // normal flow }, })
Customer language detection

Don't hard-code Spanish. Tell the agent:

TypeScript
prompt: `… Always reply in the language the customer wrote in. Default to Spanish if unclear.`

Tool descriptions can stay in English — the LLM translates the natural responses. Tool return values (like ticket summaries) can be templates you i18n yourself.

Daily metrics summary

Add a scheduled trigger (when we support cron in Functions) or rely on a nightly external cron that calls a function-only HTTP path:

TypeScript
export default defineFunction(async (event) => { if (event.type === "scheduled.daily-report") { const stats = await summarizeYesterday() await zavu.messages.send({ to: process.env.MANAGER_PHONE!, channel: "whatsapp", text: `Soporte ayer:\n• ${stats.handled} resueltos por la IA\n• ${stats.escalated} escalados\n• ${stats.avgResponseSec}s avg response`, }) } })

Tuning

SymptomFix
Agent creates tickets for things it could answerBeef up the KB. Add the missing FAQ. Test with npx zavudev fn invoke.
Agent answers from "memory" / hallucinatesReinforce the prompt: "ONLY use information returned by search_kb".
Tickets are too verbose / wrong priorityAdd examples in the prompt: Priority guide:\n- urgent: customer mentions money lost\n- high: account locked\n- normal: question about a feature\n- low: general info.
Customers ping pong between tools without resolutionAdd a max_turns heuristic in the prompt: "If after 3 tool calls you don't have an answer, escalate_to_human".

Next