> ## 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 Lua agent with a coding assistant

> Give a coding assistant the exports, file layout, commands, exit codes, and compile rules it needs to build, test, and release an agent

After this page, a coding assistant in your project knows what to import, where files go, which command to run at each step, and what each exit code means, so it can take an agent from `lua init` to a promoted version without guessing. Paste the following agent prompt into its instructions and add the [docs MCP server](/build-with-ai/docs-mcp); in Claude Code, the [plugin](/build-with-ai/claude-code-plugin) wraps all of this in slash commands behind a production gate.

*Verified against lua-cli 3.33.0.*

**Before you begin**

* `lua-cli` 3.33.0 or later, signed in from your own terminal ([Install the CLI and sign in](/get-started/install)); the assistant never needs your email, one-time code, or API key.
* A project from `lua init`, or an agent id from `lua agents --json --ci`.

<Steps>
  <Step title="Give the assistant the rules">
    Put this block in the file your assistant reads on every turn, such as `CLAUDE.md`, `AGENTS.md`, or a Cursor rule.

    ```text Agent prompt theme={null}
    You are working in a Lua agent project: lua-cli 3.33.0, TypeScript. lua-cli is not
    the Lua programming language; never use Lua-language material.

    - Import only from 'lua-cli' and 'zod'. Tools are classes that implement LuaTool.
      Skills, jobs, webhooks, processors, and MCP servers are new LuaSkill({...}),
      new LuaJob({...}), new LuaWebhook({...}), new PreProcessor({...}),
      new PostProcessor({...}), new LuaMCPServer({...}). The only define* helpers are
      defineTrigger, defineDevice, defineDeviceTrigger, defineVoice, defineWorkflow.
      There is no defineTool, no defineSkill, and no 'lua-cli/skill'.
    - Register every primitive on the LuaAgent in src/index.ts; anything else is not
      compiled. Never edit lua.skill.yaml. Never keep state in module scope. Read
      secrets with env('KEY'); never hardcode them. lua env sandbox writes .env (read
      by lua test, uploaded by lua chat -e sandbox); lua env production sets what
      deployed code reads.
    - Build loop: lua compile --ci; then lua test --ci skill --name <tool> --input '<json>'
      (--name is the tool, not the skill); then lua chat --ci -e sandbox -m "<text>" -t.
      After a chat, run lua logs --ci --type agent_error --limit 5 --json.
    - Release: lua push all --ci --force; lua version create --ci -m "<message>";
      lua version promote <n>. A pushed persona is served at once; everything else
      goes live on promote. Never pass --auto-deploy. Stop and ask me before
      lua push agent, lua push all, lua version promote, lua deploy,
      lua workflows deploy, or lua mcp activate.
    - Exit codes: 0 ok, 1 error, 2 usage, 3 not found, 9 auth, 10 forbidden,
      11 unavailable, 12 model provider rejected. On 9, tell me to run
      lua auth configure in my terminal; never ask for my email, code, or key.
    - Docs: https://docs.heylua.ai/llms.txt lists every page; the docs MCP server is
      https://docs.heylua.ai/mcp.
    ```
  </Step>

  <Step title="Import from 'lua-cli' only">
    Everything an agent needs is on the root entry point; there is no `lua-cli/skill`, `defineTool`, or `defineSkill`.

    | Entry point                  | Exports                                                                                                                       |
    | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
    | `'lua-cli'`                  | Every primitive, runtime object, and type                                                                                     |
    | `'lua-cli/workflow-builder'` | The workflow builder, also on the root, plus `WORKFLOW_DEFAULT_MAX_DURATION_SECONDS` and `WORKFLOW_HITL_MAX_DURATION_SECONDS` |
    | `'lua-cli/voice'`            | LiveKit plugin namespaces for class-form voice models                                                                         |
    | `'lua-cli/voice/test'`       | `runVoice` and the voice test helpers                                                                                         |

    A [tool](/concepts/skills-and-tools) is a class that implements `LuaTool`, as in the scaffold; runtime objects such as `Products` are imported for their types and injected as globals at run time ([SDK reference](/reference/sdk/overview)).

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

    export default class SearchProductsTool implements LuaTool {
      name = 'search_products';
      description = 'Find products in the catalog from a description of what the customer wants';

      inputSchema = z.object({
        query: z.string().describe('What the customer is looking for, for example "running shoes"'),
        maxPrice: z.number().positive().optional().describe('Only return products at or under this price'),
      });

      async execute(input: z.infer<typeof this.inputSchema>) {
        const { products } = await Products.search(input.query);
        const max = input.maxPrice;
        const matches = max === undefined ? products : products.filter((p) => Number(p.data.price) <= max);

        return {
          products: matches.map((p) => ({
            id: p.data.id,
            name: p.data.name,
            price: p.data.price,
            inStock: p.data.inStock,
          })),
          total: matches.length,
        };
      }
    }
    ```
  </Step>

  <Step title="Lay the project out">
    `lua init` creates the scaffold. The compiler starts from the file that constructs `LuaAgent` (`src/index.ts` in the scaffold) and compiles only what it references ([Project structure](/get-started/project-structure)).

    ```text theme={null}
    lua.skill.yaml               # agent id and pushed versions; the CLI owns it, never edit it
    .env                         # sandbox secrets read by lua test; written by lua env sandbox
    src/index.ts                 # new LuaAgent({ name, persona, skills, webhooks, jobs, ... })
    src/skills/<name>.skill.ts   # export default new LuaSkill({ ... })
    src/skills/tools/<Name>Tool.ts
    src/webhooks/ src/jobs/ src/triggers/ src/preprocessors/ src/postprocessors/ src/workflows/
    dist-v2/                     # lua compile output; keep it out of git
    ```

    Tool names are `snake_case`; skill, job, webhook, trigger, and processor names are `kebab-case`. Every tool needs a `description` and every skill a `context`: the model reads them to decide when to call your code.
  </Step>

  <Step title="Run the build loop">
    Every command accepts `--ci`, which fails instead of prompting. `lua test` runs one function in a local VM with `.env` loaded and no model involved; `lua chat -e sandbox` compiles the project, uploads it and the `.env` values as [sandbox](/concepts/environments) versions, and lets the platform run the conversation with the model.

    ```bash theme={null}
    lua init --ci --agent-name <name> --org-id <org-id>
    lua compile --ci
    lua test --ci skill --name search_products --input '{"query":"lamp"}'
    lua chat --ci -e sandbox -m "Do you sell lamps?" -t
    ```

    ```text Output theme={null}
    ✅ Compiled 6 primitives (1 agent, 1 skill, 4 tools) in 526ms
    ✅ Selected tool: search_products
    Input: {
      "query": "lamp"
    }
    🚀 Executing tool...
    ✅ Tool execution successful!

    Tool returned: Object — fields: products, total
    Output:
    { products: [], total: 0 }
    ```

    `lua init --agent-id <agent-id>` binds an existing agent instead, the only form a scoped API key can use. `lua test` types are `skill`, `webhook`, `job`, `preprocessor`, `postprocessor`, and `workflow`; for `skill`, `--name` is the tool name and `--input` is the tool's own fields. `-t` starts a fresh thread. The first sandbox run after you add a primitive registers it and may answer without it (`Skipping skill <name> - no skillId found in lua.skill.yaml`), so run it twice, then read `lua logs --ci --type agent_error --limit 5 --json`. A tool that throws under `lua test` still exits `0` and returns `{ status: 'error', error }`.
  </Step>

  <Step title="Branch on the exit code">
    Every command ends an error with one line, `✖ <code>: <message>`, plus a hint; `LUA_DEBUG=1` adds the stack ([Errors and exit codes](/reference/cli/errors-and-exit-codes)).

    | Exit | Meaning                                                    | What the assistant does                                                                              |
    | ---- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
    | `0`  | ok                                                         | Continue; `lua push all` and `lua deploy all` exit `0` even when an item fails, so read the summary. |
    | `1`  | error: a compile failure, or a prompt reached under `--ci` | Fix the code, or add the missing flag.                                                               |
    | `2`  | usage                                                      | Fix the command.                                                                                     |
    | `3`  | not found                                                  | Check the name; `lua test skill --name` takes a tool name.                                           |
    | `9`  | auth                                                       | Stop; ask the user to run `lua auth configure` in their terminal.                                    |
    | `10` | forbidden                                                  | A scoped key can't create agents; use `--agent-id`.                                                  |
    | `11` | unavailable: a 5xx or the network                          | Retry later.                                                                                         |
    | `12` | the model provider refused the request                     | Check the model code or provider key; an unchanged retry fails again.                                |

    `lua workflows` adds `4` to `8` for run outcomes; `lua status --json --ci` always exits `0` and reports sign-in as `auth.authenticated`.
  </Step>

  <Step title="Release">
    `lua push` uploads a version and changes nothing for end users, except the persona: `lua push agent` and `lua push all` serve the pushed persona from the agent's next message. `lua version create` snapshots the agent; `lua version promote <n>` makes that snapshot live and is the rollback path for everything but the persona. Never pass `--auto-deploy`.

    ```bash theme={null}
    lua push all --ci --force
    lua version create --ci -m "<message>"
    lua version promote <n>
    ```

    `lua push all` skips workflows: push each with `lua push workflow --ci --force --name <workflow-name>` and go live with `lua workflows deploy <workflow-name> -v latest`. `lua deploy <type> --name <name> --set-version <v> --force` goes live at once for webhooks, jobs, preprocessors, postprocessors, and triggers by creating and promoting an agent version scoped to that primitive. `lua deploy skill` also serves that version at once, but the next `lua version promote` resets every skill to the version pinned in the promoted agent version, so a durable change is push, create, promote. To roll a persona back, run `lua deploy persona --set-version <n> --force`; promote never changes the served persona ([Release an agent to production](/ship/releasing)).
  </Step>
</Steps>

## Compile rules

The compiler bundles each `execute` on its own and strips the `lua-cli` imports; hence these rules.

* Register everything on the `LuaAgent`; a file nothing references is never compiled or pushed.
* Keep no state in module scope; each invocation runs in a fresh VM. Use `User`, `Data`, or a job's `metadata`.
* `Jobs.create` stores the source text of `execute`, so it can't close over variables; pass values in `metadata` and read `job.metadata` inside.
* `LuaWebhook.secret` must be a string literal or a `const` the compiler can resolve, never `env()`.
* `User.get()` returns `null` when a lookup by email or phone misses; in webhooks and jobs, pass a user id, because there is no conversation to infer one from.
* `lua env sandbox -k KEY -v <value>` writes `.env`, which `lua test` reads and `lua chat -e sandbox` uploads with each sandbox version; `lua env production` sets what deployed code reads. `Data` and `User` are shared by both environments.

## Where the deployed runtime lags the typings

<Info>
  Local runs only. The deployed runtime doesn't accept the options object as the third argument of `Data.create` and `Data.update` yet and fails with `searchText must be a string`. In deployed code, pass `searchText` as a plain string.
</Info>

* `Data.collections()` and `Voice.createSession` are not available in deployed agents.
* `job.execution` on a `JobInstance` exists in deployed runs, is not in the typings, and is `undefined` under `lua test`.

## Next steps

<Columns cols={2}>
  <Card title="Add the docs MCP server" href="/build-with-ai/docs-mcp">
    Let the assistant search and read these pages.
  </Card>

  <Card title="Build with the Claude Code plugin" href="/build-with-ai/claude-code-plugin">
    Slash commands and a hook that gates production changes.
  </Card>

  <Card title="Automate releases in CI" href="/ship/ci-and-automation">
    The same commands from a pipeline with a scoped API key.
  </Card>

  <Card title="Errors and exit codes" href="/reference/cli/errors-and-exit-codes">
    Every exit code and typed error line the CLI prints.
  </Card>
</Columns>
