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

# LuaTrigger

> Declarative triggers that wake your agent on external events — verify, filter, and transform, with no execute function

## Overview

`LuaTrigger` (created with `defineTrigger`) wakes your agent when an external service posts to a trigger URL. Unlike a webhook, a trigger has **no `execute` function** — the agent turn itself does the work. Your only customisation surface is three optional, declarative slots that run server-side before the agent is invoked:

1. **`verify`** — authenticate the request (e.g. an HMAC signature check). Return `false` and the request is rejected with HTTP 401; the agent never runs.
2. **`filter`** — decide whether this event matters. Return `false` and the sender gets HTTP 200, but no agent invocation happens.
3. **`transform`** — shape what the agent receives. Return a message string (or a full invocation input) instead of the raw payload.

```typescript theme={null}
import { defineTrigger } from 'lua-cli';
import { z } from 'zod';

export default defineTrigger({
  name: 'order-created',
  description: 'Fires when the shop reports a new order',
  inputSchema: z.object({
    type: z.string(),
    data: z.object({ orderId: z.string(), total: z.number() }),
  }),
  filter: (ctx) => ctx.body.type === 'order.created',
  transform: (ctx) => `New order ${ctx.body.data.orderId} for ${ctx.body.data.total}. Confirm it and notify the customer.`,
});
```

After `lua push`, every event that passes `verify` and `filter` starts an agent turn with the transformed message. There is no handler code to maintain — the agent's persona, skills, and tools take it from there.

<Note>
  A trigger needs at least one of `verify`, `filter`, or `transform` — a trigger with none of the three is just a paste-anywhere URL trigger, which you can create without any code via [`lua triggers create`](/cli/triggers-command).
</Note>

## Triggers vs Webhooks vs Jobs

|                       | **LuaTrigger**                                | **LuaWebhook**                               | **LuaJob**                            |
| --------------------- | --------------------------------------------- | -------------------------------------------- | ------------------------------------- |
| **Purpose**           | Wake the agent on an external event           | Full request/response control                | Scheduled or queued work              |
| **Handler code**      | None — declarative slots only                 | Your `execute` function                      | Your `execute` function               |
| **Who does the work** | The agent (persona, skills, tools)            | Your code                                    | Your code                             |
| **HTTP response**     | Framework-owned (200/401/…)                   | Whatever `execute` returns                   | n/a                                   |
| **Best for**          | "When X happens, have the agent deal with it" | Custom responses, side effects, syncing data | Cron schedules, background processing |

Reach for `LuaWebhook` when the caller needs a specific response body or you want to run code without involving the agent. Reach for `LuaTrigger` when the right reaction to an event is *an agent turn* — triage this PR, answer this ticket, follow up on this order. See [LuaWebhook](/api/luawebhook) and [LuaJob](/api/luajob).

## Use Cases

<CardGroup cols={2}>
  <Card title="Git Events" icon="code-branch">
    "A PR was assigned to me — triage it" with signature verification
  </Card>

  <Card title="Payment Events" icon="credit-card">
    Wake the agent on a successful Stripe payment, ignore the rest
  </Card>

  <Card title="Alerting" icon="bell">
    Point a monitoring tool at the trigger URL; the agent investigates
  </Card>

  <Card title="Automation Glue" icon="plug">
    Any service that can POST JSON can start an agent turn
  </Card>
</CardGroup>

## Defining a Trigger

Three authoring shapes are recognised — pick whichever fits your codebase:

<CodeGroup>
  ```typescript defineTrigger (recommended) theme={null}
  import { defineTrigger } from 'lua-cli';

  export default defineTrigger({
    name: 'order-created',
    description: 'Fires when the shop reports a new order',
    filter: (ctx) => ctx.body.type === 'order.created',
  });
  ```

  ```typescript new LuaTrigger theme={null}
  import { LuaTrigger } from 'lua-cli';

  export default new LuaTrigger({
    name: 'order-created',
    description: 'Fires when the shop reports a new order',
    filter: (ctx) => ctx.body.type === 'order.created',
  });
  ```

  ```typescript class extends LuaTrigger theme={null}
  import { LuaTrigger, TriggerContext } from 'lua-cli';

  export default class OrderCreatedTrigger extends LuaTrigger {
    name = 'order-created';
    description = 'Fires when the shop reports a new order';

    filter(ctx: TriggerContext) {
      return ctx.body.type === 'order.created';
    }
  }
  ```
</CodeGroup>

## Configuration Parameters

### Required Fields

<ParamField path="name" type="string" required>
  Unique trigger name, used as the server-side identifier.

  **Format**: URL-safe — a lowercase letter followed by lowercase letters, digits, and hyphens (e.g. `'order-created'`, `'github-pr-assigned'`). Other names compile with a warning.
</ParamField>

<ParamField path="description" type="string" required>
  Short description shown in trigger listings. It is a note for you — it is **not** sent to the agent.
</ParamField>

### Slots (at least one required)

<ParamField path="verify" type="function">
  Authentication gate. Return `false` (or a promise of `false`) to reject the request with **HTTP 401** and skip the agent entirely. Put HMAC signature checks here.

  **Signature:** `(ctx: TriggerContext) => boolean | Promise<boolean>`
</ParamField>

<ParamField path="filter" type="function">
  Relevance gate, run after `verify`. Return `false` to acknowledge the event with **HTTP 200** but skip the agent — the way to intentionally ignore events you don't care about, without making the sender think delivery failed.

  **Signature:** `(ctx: TriggerContext) => boolean | Promise<boolean>`
</ParamField>

<ParamField path="transform" type="function">
  Shapes the agent input, run after `filter`. Return either:

  * a **string** — becomes the agent's message for the turn, or
  * a **full invocation input object** (`{ messages | prompt, userId?, threadId?, systemPrompt?, ... }`) — you own the whole turn.

  Returning `null`/`undefined` is an error (the delivery fails with HTTP 500) — use `filter` to skip events, not `transform`.

  **Signature:** `(ctx: TriggerContext) => string | AgentInvocationInput | Promise<string | AgentInvocationInput>`

  Omit `transform` to use the default payload-forwarding format described in [Default Agent Message](#default-agent-message).
</ParamField>

### Optional Fields

<ParamField path="source" type="'webhook'" default="'webhook'">
  Event source. Only `'webhook'` (an HTTP POST to the trigger URL) is currently supported.
</ParamField>

<ParamField path="inputSchema" type="ZodType">
  Optional Zod schema describing the event body. To type `ctx.body` in your slots, also pass the payload type as the generic: `defineTrigger<MyEvent>({ ... })`.
</ParamField>

## TriggerContext

Every slot receives the same context object:

```typescript theme={null}
interface TriggerContext<T = any> {
  body: T;                       // Parsed request body (type it via defineTrigger<T>)
  rawBody?: string;              // Exact unparsed request bytes (utf8)
  headers: Record<string, any>;  // Request headers — keys are lowercased
  query: Record<string, any>;    // Parsed query-string parameters
  triggerName: string;           // This trigger's name
  source: string;                // Event source ('webhook')
}
```

<Warning>
  **Compute HMAC signatures over `ctx.rawBody`, never over `JSON.stringify(ctx.body)`.** Providers like GitHub (`x-hub-signature-256: sha256=…`), Stripe, and Slack sign the exact bytes they send. Re-stringifying the parsed body does not reproduce those bytes (key order, whitespace, and unicode escapes all differ), so a signature computed from `ctx.body` will fail even for genuine requests. `rawBody` exists precisely for this.
</Warning>

<Note>
  Header keys arrive **lowercased** — read `ctx.headers['x-hub-signature-256']`, not `ctx.headers['X-Hub-Signature-256']`.
</Note>

## Slot Semantics

Slots run server-side, in order, on every delivery. Their outcome decides the HTTP response the sender sees:

| Outcome                                | HTTP response | Agent invoked?            | Logged status            |
| -------------------------------------- | ------------- | ------------------------- | ------------------------ |
| All slots pass                         | `200`         | ✅ Yes (in the background) | `accepted` → `completed` |
| `verify` returns `false`               | `401`         | ❌ No                      | `rejected_unverified`    |
| `filter` returns `false`               | `200`         | ❌ No                      | `skipped_filtered`       |
| Any slot throws                        | `500`         | ❌ No                      | `failed`                 |
| `transform` returns `null`/`undefined` | `500`         | ❌ No                      | `failed`                 |
| Trigger is deactivated                 | `200`         | ❌ No                      | `skipped_inactive`       |
| Unknown URL or token                   | `404`         | ❌ No                      | —                        |

A few facts worth knowing:

* **Slots run in a sandbox with a 15-second budget.** Keep them fast and computational — signature checks, field comparisons, string building. Don't make network calls from slots; if the agent needs external data, let the agent fetch it with its tools during the turn.
* **`verify` failures return 401, not 500.** Webhook providers treat a 401 as a configuration error and won't retry-storm you the way they would on a 5xx.
* **`filter` failures return 200.** The sender sees a successful delivery, so it won't retry an event you deliberately ignored.
* **Node's built-in `crypto` module is available** in slots (for `createHmac`, `timingSafeEqual`), and [`env('KEY')`](/api/environment) reads your agent's environment variables — the sanctioned way to get secrets into a `verify` check.
* **The agent turn is fire-and-forget.** Once the slots pass, the sender gets its 200 immediately; the agent runs in the background and the outcome lands in the execution log.

## Default Agent Message

When you omit `transform`, the agent receives the event in a standard format. With an **instruction** set (via `lua triggers create --instruction "..."`):

```
[Trigger: <name>] <instruction>

Payload:
<JSON-stringified body>
```

Without an instruction, the JSON payload directly follows the prefix — there is no `Payload:` label:

```
[Trigger: <name>] <JSON-stringified body>
```

* The `[Trigger: <name>]` prefix is always present, so the agent (and any follow-up turns) can tell what started the conversation.
* The instruction is the place to tell the agent what to do with the event.
* The payload is **capped at 50,000 characters**; anything longer is truncated.

Providing a `transform` overrides all of this:

* **Return a string** → the agent's message becomes `[Trigger: <name>] <your string>`. The trigger's instruction is *not* applied — your transform owns the message. This is the way to forward hand-picked fields from large payloads instead of hitting the 50k cap.
* **Return an invocation input object** → you own the entire turn: message content, `userId` (to run as a specific user with conversation history), `threadId`, `systemPrompt`, and so on. The channel is always recorded as `trigger`.

```typescript theme={null}
// Full-control transform: run the turn as a specific user
transform: (ctx) => ({
  prompt: `Order ${ctx.body.orderId} was refunded. Apologise and offer a discount code.`,
  userId: ctx.body.customerId,   // conversation history is stored for this user
})
```

## Trigger URLs and Token Security

Every trigger gets a URL of the form:

```
https://trigger.heylua.ai/trigger/{agentId}/{token}
```

* The **token is the secret** — anyone who has the full URL can fire the trigger (subject to your `verify` slot). Treat the URL like a credential.
* Unknown agent, unknown token, or a mismatched pair all return the **same 404** — the URL shape leaks nothing about which part was wrong.
* If a URL leaks, rotate it: `lua triggers rotate-token --trigger <name>`. The old URL stops working immediately and the CLI prints the new one.

<Warning>
  A trigger **without** a `verify` slot accepts any request that has the URL. That is fine for low-stakes automation glue, but for anything that causes real side effects, add a `verify` slot with a proper signature check — don't rely on URL secrecy alone.
</Warning>

## Complete Example: GitHub Pull Request Trigger

A production-shaped trigger: HMAC signature verification over the raw bytes, a filter that only lets through PRs assigned to a configured user, and a compact transform (a raw GitHub PR payload is enormous and mostly noise for the agent).

```typescript theme={null}
import { defineTrigger, env } from 'lua-cli';
import { createHmac, timingSafeEqual } from 'crypto';

interface GitHubPullRequestEvent {
  action?: string;
  assignee?: { login?: string };
  pull_request?: {
    number?: number;
    title?: string;
    html_url?: string;
  };
  repository?: { full_name?: string };
}

/** Constant-time comparison; length mismatch short-circuits (length is not secret). */
function safeEqual(expected: string, provided: string): boolean {
  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(provided, 'utf8');
  if (a.length !== b.length) return false;
  return timingSafeEqual(a, b);
}

export default defineTrigger<GitHubPullRequestEvent>({
  name: 'github-pr-assigned',
  description: 'Wakes the agent when a GitHub PR is assigned to the configured user',
  source: 'webhook',

  // GitHub signs the exact wire bytes → verify against ctx.rawBody
  verify: (ctx) => {
    const secret = env('GITHUB_WEBHOOK_SECRET');
    const signature = ctx.headers['x-hub-signature-256'];
    if (!secret || !ctx.rawBody || typeof signature !== 'string') return false;
    const expected = 'sha256=' + createHmac('sha256', secret).update(ctx.rawBody, 'utf8').digest('hex');
    return safeEqual(expected, signature);
  },

  // Only wake the agent when a PR is assigned to our user
  filter: (ctx) => {
    const body = ctx.body ?? {};
    if (body.action !== 'assigned') return false;
    const configured = (env('GITHUB_USERNAME') ?? '').trim().toLowerCase();
    return !!configured && body.assignee?.login?.toLowerCase() === configured;
  },

  // Hand-pick fields — the raw payload is huge and would just waste the
  // agent's context (and risk the ~50k default-payload cap)
  transform: (ctx) => {
    const pr = ctx.body.pull_request ?? {};
    const repo = ctx.body.repository?.full_name ?? 'unknown/unknown';
    return [
      `PR assigned on GitHub: ${repo}#${pr.number ?? '?'}`,
      `Title: ${pr.title ?? '(no title)'}`,
      `URL: ${pr.html_url ?? '(no url)'}`,
      `Review it now and post a summary of the risk areas.`,
    ].join('\n');
  },
});
```

Configure the trigger URL and the same secret in your repository's webhook settings (Repository → Settings → Webhooks, content type `application/json`), and set `GITHUB_WEBHOOK_SECRET` and `GITHUB_USERNAME` with [`lua env`](/cli/env-command).

## Minimal Example: Filter Only

The smallest useful trigger — no verification, no transform, just a relevance gate. The agent receives the default `[Trigger: …] Payload: …` message.

```typescript theme={null}
import { defineTrigger } from 'lua-cli';

export default defineTrigger({
  name: 'deployment-finished',
  description: 'Wakes the agent when a deployment completes',
  filter: (ctx) => ctx.body?.status === 'succeeded' || ctx.body?.status === 'failed',
});
```

<Warning>
  This trigger accepts any POST that has the URL — there is no `verify` slot. Acceptable for internal tooling behind URL secrecy; not acceptable for anything a third party could abuse. Rotate the token immediately if the URL leaks.
</Warning>

## Using with LuaAgent

Register triggers on your agent configuration:

```typescript theme={null}
import { LuaAgent } from 'lua-cli';
import githubPrAssigned from './triggers/pr-assigned.trigger';

export const agent = new LuaAgent({
  name: 'my-agent',
  persona: '...',
  skills: [...],

  triggers: [
    githubPrAssigned,
  ],
});
```

On `lua push`, each trigger is compiled, versioned, and deployed like any other primitive, and your `lua.skill.yaml` gains a `triggers:` section tracking the deployed name, ID, and version:

```yaml theme={null}
triggers:
  - name: github-pr-assigned
    triggerId: 5f4c9a1e-2b7d-4c03-9e88-1a2b3c4d5e6f
    version: 1.0.1
```

## Observability

Every delivery — invoked, filtered, rejected, or failed — is recorded as an execution. Inspect them with the CLI:

```bash theme={null}
lua triggers logs --trigger github-pr-assigned
lua triggers logs --trigger github-pr-assigned --limit 5 --json
```

| Status                | Meaning                                                                      |
| --------------------- | ---------------------------------------------------------------------------- |
| `accepted`            | Slots passed; the agent turn is in flight                                    |
| `completed`           | The agent turn finished; the response text is stored on the execution        |
| `failed`              | A slot threw, the transform returned nothing, or the agent invocation failed |
| `rejected_unverified` | `verify` returned `false` (sender saw 401)                                   |
| `skipped_filtered`    | `filter` returned `false` (sender saw 200)                                   |
| `skipped_inactive`    | Trigger was deactivated at delivery time                                     |

See the [Triggers Command](/cli/triggers-command) for the full management workflow — listing, creating URL triggers, activating, rotating tokens, and deleting.

## Related APIs

<CardGroup cols={2}>
  <Card title="Triggers Command" href="/cli/triggers-command" icon="terminal">
    Manage triggers from the CLI — list, logs, rotate-token
  </Card>

  <Card title="LuaWebhook" href="/api/luawebhook" icon="webhook">
    Full request/response control with your own execute function
  </Card>

  <Card title="LuaJob" href="/api/luajob" icon="clock">
    Scheduled and queued background work
  </Card>

  <Card title="Environment Variables" href="/api/environment">
    env('KEY') — secrets for your verify slot
  </Card>
</CardGroup>

## See Also

* [LuaAgent](/api/luaagent) - Registering triggers on your agent
* [LuaWebhook](/api/luawebhook) - When you need to own the HTTP response
* [Triggers Command](/cli/triggers-command) - CLI management and the paste-anywhere URL workflow
