> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Customer support agent

> Answer from a searchable help center, open Zendesk tickets for the rest, notify customers when a ticket is solved, and follow up every morning

This agent answers questions from help articles stored in [`Data`](/reference/sdk/data), opens a Zendesk ticket when the articles don't help, tells the customer on their own channel when Zendesk marks the ticket solved, and reminds customers with open tickets every morning. It is six files, and every TypeScript file compiles against `lua-cli` 3.33.0; run it with the steps on [Running any example](/examples/overview#running-any-example).

*Verified against lua-cli 3.33.0.*

## The conversation

1. The customer asks a question. The model calls `search_knowledge_base`, which runs a semantic search over the `help_articles` collection and returns the closest articles with a score.
2. If nothing relevant comes back, the model offers a ticket and calls `create_ticket`. The tool takes the requester's email from the profile the channel verified, creates the ticket through Zendesk's REST API, and stores a copy in `support_tickets`.
3. When a support agent solves the ticket, Zendesk calls the `zendesk-ticket-events` webhook, which finds the customer by email and sends a message on the channel they last used.
4. Every day at 10:00, the `ticket-follow-up` job messages every customer whose stored ticket is still open.

## Primitives and channels

* [Skill and tools](/concepts/skills-and-tools): `support`, with `search_knowledge_base` and `create_ticket`.
* [Webhook](/concepts/webhooks): `zendesk-ticket-events`, authenticated with the bearer token you set on the Zendesk webhook.
* [Job](/concepts/jobs): `ticket-follow-up`, cron `0 10 * * *` in `Europe/London`, two attempts.
* Runtime objects: `Data.search`, `Data.create`, `Data.get`, `User.get`, `user.send`, and `env` for `ZENDESK_SUBDOMAIN`, `ZENDESK_EMAIL`, `ZENDESK_API_TOKEN`, and `ZENDESK_WEBHOOK_TOKEN`.
* Channels: any inbound channel. `user.send` delivers where the customer last wrote when that is WhatsApp, Messenger, Instagram, Teams, SMS, or MessageBird, and resolves `true` whether or not it delivered, so confirm with `lua logs`; for WhatsApp, [Send proactive messages](/build/send-proactive-messages) explains the 24-hour window.

## The code

The search tool returns the top five articles above a similarity of 0.7.

```ts src/skills/tools/SearchKnowledgeBaseTool.ts theme={null}
import { LuaTool, Data } from 'lua-cli';
import { z } from 'zod';

export default class SearchKnowledgeBaseTool implements LuaTool {
  name = 'search_knowledge_base';
  description =
    'Search the help articles for an answer. Use it before offering to create a ticket.';

  inputSchema = z.object({
    query: z.string().describe("The customer's question in their own words"),
  });

  async execute(input: z.infer<typeof this.inputSchema>) {
    const results = await Data.search('help_articles', input.query, 5, 0.7);
    return {
      articles: results.map((entry) => ({
        title: entry.data.title,
        excerpt: String(entry.data.content).slice(0, 300),
        url: entry.data.url,
        score: entry.score,
      })),
    };
  }
}
```

The ticket tool prefers the email address the channel verified, and only falls back to one the model collected when the profile has none.

```ts src/skills/tools/CreateTicketTool.ts expandable theme={null}
import { LuaTool, Data, User, env } from 'lua-cli';
import { z } from 'zod';

export default class CreateTicketTool implements LuaTool {
  name = 'create_ticket';
  description =
    'Create a Zendesk ticket for the customer when the help articles do not answer their question.';

  inputSchema = z.object({
    subject: z.string().describe('One-line summary of the problem'),
    description: z.string().describe('What the customer reported, in full'),
    priority: z.enum(['low', 'normal', 'high', 'urgent']).default('normal'),
    email: z
      .string()
      .email()
      .optional()
      .describe('Only when the customer is not signed in with an email address'),
  });

  async execute(input: z.infer<typeof this.inputSchema>) {
    const subdomain = env('ZENDESK_SUBDOMAIN');
    const agentEmail = env('ZENDESK_EMAIL');
    const token = env('ZENDESK_API_TOKEN');
    if (!subdomain || !agentEmail || !token) {
      throw new Error('Zendesk is not configured');
    }

    // Prefer the email the channel verified over one the model collected.
    const user = await User.get();
    const requesterEmail = user?._luaProfile.emailAddresses[0] ?? input.email;
    if (!requesterEmail) {
      return { created: false, reason: 'Ask the customer for an email address first' };
    }

    const res = await fetch(`https://${subdomain}.zendesk.com/api/v2/tickets.json`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Basic ${btoa(`${agentEmail}/token:${token}`)}`,
      },
      body: JSON.stringify({
        ticket: {
          subject: input.subject,
          comment: { body: input.description },
          priority: input.priority,
          requester: { name: user?._luaProfile.fullName ?? requesterEmail, email: requesterEmail },
          tags: ['agent_created'],
        },
      }),
    });
    if (!res.ok) {
      throw new Error(`Zendesk responded ${res.status}`);
    }
    const { ticket } = await res.json();

    // Keep a copy so the follow-up job can find open tickets without calling Zendesk.
    await Data.create(
      'support_tickets',
      {
        zendeskId: ticket.id,
        subject: input.subject,
        customerEmail: requesterEmail,
        status: 'open',
        createdAt: new Date().toISOString(),
      },
      `${input.subject} ${input.description}`,
    );

    return { created: true, ticketId: ticket.id, url: ticket.url };
  }
}
```

The skill's `context` tells the model to search before it offers a ticket.

```ts src/skills/support.skill.ts theme={null}
import { LuaSkill } from 'lua-cli';
import SearchKnowledgeBaseTool from './tools/SearchKnowledgeBaseTool';
import CreateTicketTool from './tools/CreateTicketTool';

export default new LuaSkill({
  name: 'support',
  description: 'Answer questions from the help articles and open Zendesk tickets',
  context: `Always call search_knowledge_base first and answer from the articles it returns,
citing the article url. If nothing relevant comes back, or the customer says the answer
did not help, offer to open a ticket and call create_ticket with a clear subject.
Never promise a response time shorter than one business day.`,
  tools: [new SearchKnowledgeBaseTool(), new CreateTicketTool()],
});
```

The webhook checks the bearer token you configure on the Zendesk webhook, validates the body from its payload template, and messages the customer when the event is `ticket.solved`.

```ts src/webhooks/ZendeskWebhook.ts theme={null}
import { LuaWebhook, User, env } from 'lua-cli';
import { z } from 'zod';

// The JSON body you configure in the Zendesk webhook's payload template.
const bodySchema = z.object({
  type: z.string(),
  ticket: z.object({
    id: z.number(),
    status: z.string(),
    requester_email: z.string().email(),
  }),
});

export default new LuaWebhook({
  name: 'zendesk-ticket-events',
  description: 'Tells the customer on their channel when Zendesk marks their ticket solved',
  bodySchema,

  execute: async ({ body, headers }) => {
    // Zendesk signs with its own header, so check the bearer token you set on
    // the Zendesk webhook instead of a Lua secret.
    if (headers?.authorization !== `Bearer ${env('ZENDESK_WEBHOOK_TOKEN')}`) {
      throw new Error('Unauthorized');
    }
    const event = bodySchema.parse(body);
    if (event.type !== 'ticket.solved') {
      return { received: true, notified: false };
    }

    const user = await User.get({ email: event.ticket.requester_email });
    if (!user) {
      return { received: true, notified: false };
    }

    await user.send([
      {
        type: 'text',
        text: `Your ticket #${event.ticket.id} is solved. Reply here if you need anything else.`,
      },
    ]);
    return { received: true, notified: true };
  },
});
```

The job reads the stored tickets and messages each customer; it runs outside any conversation, so it looks every user up by email.

```ts src/jobs/TicketFollowUpJob.ts theme={null}
import { LuaJob, Data, User } from 'lua-cli';

export default new LuaJob({
  name: 'ticket-follow-up',
  description: 'Every morning, tell customers with an open ticket that it is still being worked on',
  schedule: { type: 'cron', expression: '0 10 * * *', timezone: 'Europe/London' },
  timeout: 120,
  retry: { maxAttempts: 2, backoffSeconds: 300 },

  execute: async () => {
    const open = await Data.get('support_tickets', { status: 'open' }, 1, 50);
    let notified = 0;

    for (const ticket of open.data) {
      const user = await User.get({ email: ticket.data.customerEmail });
      if (!user) continue;
      await user.send([
        {
          type: 'text',
          text: `Ticket #${ticket.data.zendeskId} is still open. We will message you here as soon as there is news.`,
        },
      ]);
      notified += 1;
    }

    return { open: open.data.length, notified };
  },
});
```

The agent registers all three; anything not listed here is not compiled.

```ts src/index.ts theme={null}
import { LuaAgent } from 'lua-cli';
import supportSkill from './skills/support.skill';
import zendeskWebhook from './webhooks/ZendeskWebhook';
import ticketFollowUp from './jobs/TicketFollowUpJob';

const agent = new LuaAgent({
  name: 'acme-support',
  persona: `You are Alex, the support assistant for Acme.
Answer from the help articles first. Open a ticket only when the articles do not help,
and tell the customer the ticket number. Be brief and warm; never invent policy.
Escalate billing disputes over $500 and account security issues to a ticket marked urgent.`,
  skills: [supportSkill],
  webhooks: [zendeskWebhook],
  jobs: [ticketFollowUp],
});
```

## First run

Set the Zendesk values for local runs, then run the search tool. With an empty `help_articles` collection it returns no articles, which is the branch that leads to a ticket.

```bash theme={null}
lua env sandbox -k ZENDESK_SUBDOMAIN -v <subdomain>
lua env sandbox -k ZENDESK_EMAIL -v <support-agent-email>
lua env sandbox -k ZENDESK_API_TOKEN -v <api-token>
lua env sandbox -k ZENDESK_WEBHOOK_TOKEN -v <webhook-token>
lua test --ci skill --name search_knowledge_base --input '{"query":"how do I reset my password"}'
```

```text Output theme={null}
✅ Compiled 6 primitives (1 agent, 1 skill, 2 tools, 1 webhook, 1 job) in 520ms
✅ Selected tool: search_knowledge_base
Input: {
  "query": "how do I reset my password"
}
🚀 Executing tool...
✅ Tool execution successful!

Tool returned: Object — fields: articles
Output:
{ articles: [] }
```

Run the webhook with the bearer token and a solved-ticket body, then the job once; `User.get` returns `null` for an email no end user has, so neither sends anything.

```bash theme={null}
lua test --ci webhook --name zendesk-ticket-events --input '{"query":{},"headers":{"authorization":"Bearer <webhook-token>"},"body":{"type":"ticket.solved","ticket":{"id":4821,"status":"solved","requester_email":"user@example.com"}}}'
lua test --ci job --name ticket-follow-up
```

For the full loop with the model, send `lua chat --ci -e sandbox -m "How do I reset my password?" -t`; it uploads the `.env` values with the sandbox version, so the same four variables apply. Then release it with `lua push all --ci --force`, `lua version create --ci -m "<message>"`, and `lua version promote <n>`; [Release an agent to production](/ship/releasing) explains what each command changes.

## Ways to make it yours

* Fill `help_articles` with `Data.create('help_articles', article, searchText)`, where `searchText` is the title and body joined; [Store and search data](/build/store-and-search-data) shows the pattern. To skip the search tool entirely, upload documents in the admin dashboard and use the [knowledge feature](/build/add-knowledge).
* In Zendesk, create a webhook that posts the `bodySchema` JSON from its payload template, with bearer-token authentication set to the value of `ZENDESK_WEBHOOK_TOKEN`; [Handle a webhook](/build/handle-a-webhook) explains where the URL comes from and when to use a Lua `secret` instead.
* Swap Zendesk for another ticketing system by changing `create_ticket` and the webhook's `bodySchema`; the skill, job, and persona stay the same.
* Escalation rules such as the \$500 threshold live in the persona, so an operator can change them without a release.

## Next steps

<Columns cols={2}>
  <Card title="Handle a webhook" href="/build/handle-a-webhook">
    Secret, signature check, and idempotent handling for events from your systems.
  </Card>

  <Card title="Schedule a job" href="/build/schedule-a-job">
    Cron, interval, and one-time schedules, retries, and how to run a job by hand.
  </Card>

  <Card title="Store and search data" href="/build/store-and-search-data">
    Collections, filters, and semantic search with `Data`.
  </Card>
</Columns>
