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

# Build a support agent

> Turn the quickstart project into a support agent with two data-backed tools, a persona, knowledge, a webhook, a daily job, a release, and the web widget

In this tutorial you extend the quickstart agent into a support agent for an online shop. It looks up orders and opens tickets from data the platform stores for it, receives order events through a webhook, posts a daily ticket count, answers from uploaded policy documents, and runs on your website. Each step adds one primitive and tests it on its own; only the last two steps touch production. Finished, it answers like this in production:

```text Output theme={null}
Order A-1001 has shipped, as of a moment ago. Anything else you'd like me to check on it?
```

Plan about 30 minutes. You need the project from the [quickstart](/get-started/quickstart), signed in, with agent version 1 promoted.

*Verified against lua-cli 3.33.0.*

## What you'll build

* A `support` skill with two tools: `lookup_order` reads an order from [`Data`](/reference/sdk/data), and `create_ticket` writes one.
* An `order-status` webhook that stores each order event so `lookup_order` has something to find.
* A persona in object form, with separate instructions for text channels and voice calls.
* Knowledge Search over documents you upload in the admin dashboard.
* A `daily-summary` job that counts open tickets every morning.
* A released agent version, embedded on a web page with the chat widget.

<Steps>
  <Step title="Code: Add a support skill with two tools">
    Both tools use `Data`, the agent-scoped JSON store; collections (`orders`, `tickets`) are created on first write. `Data.get` takes the collection, a filter, a page number, and a page size, and returns `{ data, pagination }`; each entry keeps your fields under `data`. `lookup_order` returns `{ found: false }` instead of throwing, because the model reads the return value and the skill's `context` tells it what to say when the order is missing.

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

    export default class LookupOrderTool implements LuaTool {
      name = 'lookup_order';
      description = 'Look up the current status of an order by its order number';

      inputSchema = z.object({
        orderId: z.string().describe('Order number, for example "A-1001"'),
      });

      async execute(input: z.infer<typeof this.inputSchema>) {
        const result = await Data.get('orders', { orderId: input.orderId }, 1, 1);
        const order = result.data[0];
        if (!order) {
          return { found: false, orderId: input.orderId };
        }
        return { found: true, orderId: input.orderId, status: order.data.status, updatedAt: order.data.updatedAt };
      }
    }
    ```

    `create_ticket` writes an entry and returns its ID so the model can read it back to the end user. The zod `describe()` strings are part of what the model sees, so write them as instructions for filling the field.

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

    export default class CreateTicketTool implements LuaTool {
      name = 'create_ticket';
      description = 'Open a support ticket for an order problem';

      inputSchema = z.object({
        orderId: z.string().describe('Order number the ticket is about'),
        issue: z.string().describe('What went wrong, in the customer\'s words'),
      });

      async execute(input: z.infer<typeof this.inputSchema>) {
        const ticket = await Data.create('tickets', {
          orderId: input.orderId,
          issue: input.issue,
          status: 'open',
          createdAt: new Date().toISOString(),
        });
        return { ticketId: ticket.id, status: 'open' };
      }
    }
    ```

    The skill's `context` tells the model when to call each tool and what to do with a miss.

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

    export default new LuaSkill({
      name: 'support',
      description: 'Order status lookups and support tickets',
      context: `Use lookup_order when the user asks where an order is or what its status is.
    Ask for the order number if the user hasn't given one.
    If lookup_order returns found: false, say the order isn't in the system yet and offer to open a ticket.
    Use create_ticket when the user reports a problem with an order. Confirm the ticket id back to them.`,
      tools: [new LookupOrderTool(), new CreateTicketTool()],
    });
    ```

    Register the skill beside the weather skill.

    ```ts src/index.ts highlight={3,8} theme={null}
    import { LuaAgent } from 'lua-cli';
    import weatherSkill from './skills/weather.skill';
    import supportSkill from './skills/support.skill';

    const agent = new LuaAgent({
      name: 'docs-quickstart',
      persona: 'You are a weather assistant. Answer in one or two sentences.',
      skills: [weatherSkill, supportSkill],
    });
    ```

    Run `create_ticket` once. `lua test` executes your code on your machine, but `Data` calls reach the platform, so the ticket is stored on the agent.

    ```bash theme={null}
    lua test --ci skill --name create_ticket --input '{"orderId":"A-1001","issue":"Arrived damaged"}'
    ```

    ```text Output theme={null}
    ✅ Selected tool: create_ticket
    🚀 Executing tool...
    ✅ Tool execution successful!

    Tool returned: Object — fields: ticketId, status
    Output:
    { ticketId: '1d0cdc8a-49b3-4c43-8483-7da3cc240518', status: 'open' }
    ```

    Run `lookup_order` for the same order. You should see `found: false`: nothing has written to `orders` yet, which the next step fixes.

    ```bash theme={null}
    lua test --ci skill --name lookup_order --input '{"orderId":"A-1001"}'
    ```

    ```text Output theme={null}
    Tool returned: Object — fields: found, orderId
    Output:
    { found: false, orderId: 'A-1001' }
    ```
  </Step>

  <Step title="Code: Store order events with a webhook">
    A [webhook](/concepts/webhooks) is an HTTP endpoint your code handles outside any conversation; no model runs. This one validates the body with zod (a request that fails `bodySchema` is rejected before `execute` runs), then creates or updates the order's entry so the same order number never gets two entries.

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

    export default new LuaWebhook({
      name: 'order-status',
      description: 'Receives order status events from the order system and stores the latest status',
      bodySchema: z.object({
        orderId: z.string(),
        status: z.enum(['processing', 'shipped', 'delivered', 'cancelled']),
      }),
      execute: async ({ body }) => {
        const updatedAt = new Date().toISOString();
        const existing = await Data.get('orders', { orderId: body.orderId }, 1, 1);
        const order = existing.data[0];
        if (order) {
          await Data.update('orders', order.id, { ...order.data, status: body.status, updatedAt });
          return { orderId: body.orderId, status: body.status, created: false };
        }
        await Data.create('orders', { orderId: body.orderId, status: body.status, updatedAt });
        return { orderId: body.orderId, status: body.status, created: true };
      },
    });
    ```

    Register it on the agent.

    ```ts src/index.ts highlight={4,10} theme={null}
    import { LuaAgent } from 'lua-cli';
    import weatherSkill from './skills/weather.skill';
    import supportSkill from './skills/support.skill';
    import orderStatusWebhook from './webhooks/OrderStatusWebhook';

    const agent = new LuaAgent({
      name: 'docs-quickstart',
      persona: 'You are a weather assistant. Answer in one or two sentences.',
      skills: [weatherSkill, supportSkill],
      webhooks: [orderStatusWebhook],
    });
    ```

    `lua test webhook` takes the request as `{ "query", "headers", "body" }` and runs `execute` with it.

    ```bash theme={null}
    lua test --ci webhook --name order-status --input '{"body":{"orderId":"A-1001","status":"shipped"}}'
    ```

    ```text Output theme={null}
    ✅ Selected webhook: order-status
    🚀 Executing webhook...
    ✅ Webhook execution successful!

    Webhook returned: Object — fields: orderId, status, created
    Output:
    { orderId: 'A-1001', status: 'shipped', created: true }
    ```

    Run `lookup_order` again. You should see the status the webhook stored.

    ```bash theme={null}
    lua test --ci skill --name lookup_order --input '{"orderId":"A-1001"}'
    ```

    ```text Output theme={null}
    Tool returned: Object — fields: found, orderId, status, updatedAt
    Output:
    { found: true, orderId: 'A-1001', status: 'shipped', updatedAt: '2026-09-12T17:24:16.134Z' }
    ```

    In production, sign the calls: set `secret` on the webhook and send `x-lua-signature`; see [Handle a webhook](/build/handle-a-webhook).
  </Step>

  <Step title="Code: Write the persona for text and voice">
    The [persona](/concepts/persona) is the text the model reads before every conversation. In object form, `base` is rendered on every channel, then `text` is appended on text channels and `voice` on voice calls. The skill `context` stays where it is: the persona says who the agent is, the context says when to call which tool.

    ```ts src/index.ts highlight={8-14} theme={null}
    import { LuaAgent } from 'lua-cli';
    import weatherSkill from './skills/weather.skill';
    import supportSkill from './skills/support.skill';
    import orderStatusWebhook from './webhooks/OrderStatusWebhook';

    const agent = new LuaAgent({
      name: 'docs-quickstart',
      persona: {
        base: `You are the support agent for Acme, an online shop.
    Confirm the order number before you act on it. Never invent an order status.
    Answer in two sentences or fewer.`,
        text: 'Use plain sentences; no bullet lists or headings.',
        voice: 'Speak the order number digit by digit.',
      },
      skills: [weatherSkill, supportSkill],
      webhooks: [orderStatusWebhook],
    });
    ```

    Talk to the agent in the [sandbox](/concepts/environments). `lua chat -e sandbox` compiles your code and uploads it as sandbox versions the platform runs; the first run after a new skill registers it on the server and may answer without it (`Skipping skill support - no skillId found in lua.skill.yaml`), so run the command twice. You should see the model call `lookup_order` and report the shipped status.

    ```bash theme={null}
    lua chat --ci -e sandbox -m "Where is order A-1001?" -t
    ```

    ```text Output theme={null}
    💡 Sandbox mode: uses your locally compiled code — no lua push needed.
    …
    ✅ Pushed 2 skills to sandbox
    …
    Order A-1001 has shipped, as of a few minutes ago. Anything else you'd like me to check on it?
    ```
  </Step>

  <Step title="Dashboard: Add knowledge">
    [Knowledge](/concepts/knowledge-and-features) is the set of documents the agent retrieves from while the Knowledge Search feature (`rag`) is on; it lives on the server, not in your code. Open the agent in the admin dashboard (`lua admin` opens it), select the **Knowledge** tab, and upload a document such as your returns policy. You should see the document in the tab's list once its processing finishes. From the terminal, `lua resources list` prints the same list.

    `rag` retrieves passages from your uploads and puts them in front of the model when a question matches. `lua init` asks for every capability feature, so the agent normally starts with `rag` on; confirm from the terminal, where the first entry should read `Active`.

    ```bash theme={null}
    lua features list
    ```

    ```text Output theme={null}
    1. ✅ Knowledge Search (RAG)
       Name: rag
       Status: Active
    …
    ```

    If it reads `Inactive`, turn it on with `lua features enable --feature-name rag`. Ask the sandbox agent a question the document answers, such as "What is your returns policy?"; it answers from the upload.
  </Step>

  <Step title="Code: Schedule a daily summary job">
    A [job](/concepts/jobs) runs on a schedule with no conversation attached, so there is no end user, no thread, and no model unless your code calls one. `schedule` is `cron` (with an optional IANA `timezone`), `interval` in seconds, or `once` at a date; `timeout` is in seconds. This one counts open tickets at 09:00 London time, stores the total, and returns it.

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

    export default new LuaJob({
      name: 'daily-summary',
      description: 'Counts open tickets every morning and stores the total',
      schedule: { type: 'cron', expression: '0 9 * * *', timezone: 'Europe/London' },
      timeout: 60,
      execute: async () => {
        const open = await Data.get('tickets', { status: 'open' }, 1, 1);
        const summary = { openTickets: open.pagination.totalCount, date: new Date().toISOString().slice(0, 10) };
        await Data.create('summaries', summary);
        return summary;
      },
    });
    ```

    Register it; this is the agent's final shape.

    ```ts src/index.ts highlight={5,18} theme={null}
    import { LuaAgent } from 'lua-cli';
    import weatherSkill from './skills/weather.skill';
    import supportSkill from './skills/support.skill';
    import orderStatusWebhook from './webhooks/OrderStatusWebhook';
    import dailySummaryJob from './jobs/DailySummaryJob';

    const agent = new LuaAgent({
      name: 'docs-quickstart',
      persona: {
        base: `You are the support agent for Acme, an online shop.
    Confirm the order number before you act on it. Never invent an order status.
    Answer in two sentences or fewer.`,
        text: 'Use plain sentences; no bullet lists or headings.',
        voice: 'Speak the order number digit by digit.',
      },
      skills: [weatherSkill, supportSkill],
      webhooks: [orderStatusWebhook],
      jobs: [dailySummaryJob],
    });
    ```

    `lua test job` runs `execute` now instead of waiting for the schedule. You should see the ticket from step 1 counted.

    ```bash theme={null}
    lua test --ci job --name daily-summary
    ```

    ```text Output theme={null}
    ✅ Selected job: daily-summary
    📅 Schedule: Cron: 0 9 * * * (Europe/London)
    ✅ Job execution successful!

    Job returned: Object — fields: openTickets, date
    Output:
    { openTickets: 1, date: '2026-09-12' }
    ```
  </Step>

  <Step title="Terminal: Release the agent">
    `lua push all` compiles, bumps the patch version of every primitive, uploads each one plus the persona and a source backup, and records the new versions in `lua.skill.yaml`. The skills, webhook, and job change nothing for end users until you promote; the persona is served from the next message. `--force` skips the confirmation, and `--ci` fails instead of prompting.

    ```bash theme={null}
    lua push all --ci --force
    ```

    ```text Output theme={null}
    📦 Pushing 2 skill(s)...
      ✅ weather v1.0.2 pushed
      ✅ support v1.0.1 pushed

    🪝 Pushing 1 webhook(s)...
      ✅ order-status v1.0.1 pushed

    ⏰ Pushing 1 job(s)...
      ✅ daily-summary v1.0.1 pushed

    🌙 Pushing persona...
      ✅ Persona version 2 created
    …
    ✅ Push All Complete!
    ```

    Snapshot the pushed state as [agent version](/concepts/releases-and-versions) 2, then promote it. A version records which pushed version of each primitive, which persona version, and which model belong together; promoting switches every primitive in one step, and promoting `1` again would roll the code back. `lua version list` shows which version is active.

    ```bash theme={null}
    lua version create --ci -m "Support skill, order-status webhook, daily summary"
    lua version promote 2
    ```

    ```text Output theme={null}
    ✓ Created v2 (staged). Run `lua version promote v2` to deploy.
    ✓ Promoted v2. Previous active: v1.
    ```

    <Warning>
      From this moment every end user gets version 2, and the job fires at its next 09:00. Roll back with `lua version promote 1`; see [Release an agent to production](/ship/releasing).
    </Warning>
  </Step>

  <Step title="Code: Put the agent on your website">
    The web widget is the [channel](/concepts/channels) end users reach from a browser: one script tag, no build step, and it talks to the same promoted version as `lua chat -e production`. Set `environment: "production"` and replace `agent_abc123` with the `agentId` in `lua.skill.yaml`.

    ```html index.html theme={null}
    <script src="https://lua-ai-global.github.io/lua-pop/lua-pop.umd.js"></script>
    <script>
      window.LuaPop.init({
        agentId: "agent_abc123",
        environment: "production",
      });
    </script>
    ```

    Open the page. You should see a chat button in the bottom-right corner, and "Where is order A-1001?" gets the same answer as `lua chat -e production`. Colors, position, and welcome text are covered in the [web widget quickstart](/channels/web-widget/quickstart).
  </Step>
</Steps>

<Check>
  Ask production about the order. The answer comes from `lookup_order` reading the entry your webhook wrote, under the new persona.

  ```bash theme={null}
  lua chat --ci -e production -m "Where is order A-1001?" -t
  ```

  ```text Output theme={null}
  ℹ️  Thread: 28d6eeaa-fc87-451d-92b6-ebf96d364da6
  …
  Order A-1001 has shipped, as of a moment ago. Anything else you'd like me to check on it?
  ```
</Check>

## Remove the test records

`Data` is shared by the sandbox and production, so the ticket, order, and summary you created stay on the agent. There is no CLI command for entries; delete them from code with `Data.delete(collection, entryId)`. Add this tool to the support skill, run it once with `lua test`, then remove it before your next push.

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

export default class ClearTestDataTool implements LuaTool {
  name = 'clear_test_data';
  description = 'Delete every entry in the orders, tickets, and summaries collections';
  inputSchema = z.object({});

  async execute() {
    let deleted = 0;
    for (const collection of ['orders', 'tickets', 'summaries']) {
      const page = await Data.get(collection, {}, 1, 100);
      for (const entry of page.data) {
        await Data.delete(collection, entry.id);
        deleted += 1;
      }
    }
    return { deleted };
  }
}
```

```bash theme={null}
lua test --ci skill --name clear_test_data --input '{}'
```

## What you learned

* A skill's `context` plus each tool's `description` is how the model decides when to call your code, and `lua test` runs a tool without a model ([About skills and tools](/concepts/skills-and-tools)).
* `Data` is the agent-scoped store tools, webhooks, and jobs share, which is how a webhook can feed a tool ([`Data` reference](/reference/sdk/data)).
* A webhook handles HTTP outside any conversation, and `lua test webhook` replays one request locally ([About webhooks](/concepts/webhooks)).
* The persona's object form gives text channels and voice calls different instructions on top of a shared `base`, and a pushed persona is served at once ([About persona](/concepts/persona)).
* Knowledge lives on the server and needs the `rag` feature; uploads and features are managed from the admin dashboard or `lua features` ([About knowledge and features](/concepts/knowledge-and-features)).
* A job runs on a cron, interval, or one-time schedule; `lua test job` runs it immediately ([About jobs](/concepts/jobs)).
* `lua push` uploads, `lua version create` snapshots, and `lua version promote` makes a snapshot live or rolls back ([About releases and versions](/concepts/releases-and-versions)).

## Next steps

<Columns cols={3}>
  <Card title="Handle a webhook" href="/build/handle-a-webhook">
    Sign requests with `secret`, verify `x-lua-signature`, and make handlers idempotent.
  </Card>

  <Card title="About execution contexts" href="/concepts/execution-contexts">
    What `User`, `Data`, and `env` resolve to in a tool, a webhook, and a job.
  </Card>

  <Card title="LuaAgent reference" href="/reference/sdk/luaagent">
    Every field you can register on the agent, with types and defaults.
  </Card>
</Columns>
