> ## 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

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

## 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

```sh theme={null}
zavu fn init --slug support -y
cd support
```

Replace `index.ts`:

```ts theme={null}
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](/guides/ai-agents/knowledge-base),
you can search them from your function:

```ts theme={null}
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)

```ts theme={null}
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:

```sh theme={null}
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:

```ts theme={null}
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

```sh theme={null}
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

<AccordionGroup>
  <Accordion title="Sentiment-driven escalation">
    Detect frustration from message text without relying on the LLM's judgment:

    ```ts theme={null}
    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.
  </Accordion>

  <Accordion title="Business hours awareness">
    ```ts theme={null}
    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
      },
    })
    ```
  </Accordion>

  <Accordion title="Customer language detection">
    Don't hard-code Spanish. Tell the agent:

    ```ts theme={null}
    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.
  </Accordion>

  <Accordion title="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:

    ```ts theme={null}
    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`,
        })
      }
    })
    ```
  </Accordion>
</AccordionGroup>

## Tuning

| Symptom                                              | Fix                                                                                                                                                                     |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Agent creates tickets for things it could answer     | Beef up the KB. Add the missing FAQ. Test with `zavu fn invoke`.                                                                                                        |
| Agent answers from "memory" / hallucinates           | Reinforce the prompt: "ONLY use information returned by search\_kb".                                                                                                    |
| Tickets are too verbose / wrong priority             | Add 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 resolution | Add a `max_turns` heuristic in the prompt: "If after 3 tool calls you don't have an answer, escalate\_to\_human".                                                       |

## Next

<CardGroup cols={2}>
  <Card title="Ecommerce example" icon="bag-shopping" href="/guides/functions/examples/ecommerce">
    Order tracking, recommendations, cart recovery.
  </Card>

  <Card title="Define agents in depth" icon="brain" href="/guides/functions/defining-agents">
    Provider selection, prompts, advanced config.
  </Card>
</CardGroup>
