Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.zavu.dev/llms.txt

Use this file to discover all available pages before exploring further.

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

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

zavu fn init --slug support -y
cd support
Replace index.ts:
import { defineAgent, defineTool } from "@zavu/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 can search them from your function:
async function searchKnowledgeBase(query: string, topK: number) {
  const res = await zavu.senders.agent.knowledgeBases.search(
    process.env.SENDER_ID!,
    { query, topK }
  )
  return res.results
}

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

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:
zavu fn secrets set OPENAI_API_KEY sk-...
zavu fn secrets set PINECONE_API_KEY ...

Option C: Static FAQ (good for small KBs)

For < 50 FAQs, embedded text search is fine:
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

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

Sample conversation

👤 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

Detect frustration from message text without relying on the LLM’s judgment:
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 zavu fn triggers add --events message.inbound. This runs IN ADDITION to the agent — observer pattern.
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
  },
})
Don’t hard-code Spanish. Tell the agent:
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.
Add a scheduled trigger (when we support cron in Functions) or rely on a nightly external cron that calls a function-only HTTP path:
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 zavu 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

Ecommerce example

Order tracking, recommendations, cart recovery.

Define agents in depth

Provider selection, prompts, advanced config.