# Triggers

A trigger starts a session on a schedule or from a webhook.

Canonical page: https://kortix.com/docs/connect/triggers

A trigger starts a [session](/docs/work/sessions) with no person present. Use a trigger to automate recurring or event-driven work.

## Trigger types

Kortix supports two trigger types. There is no third type.

- **cron** — runs on a schedule you set.
- **webhook** — runs when an external service sends a signed request to the project's webhook URL.

You define triggers in the project manifest, `kortix.yaml`. Each trigger holds a prompt that renders as the fired session's first message. Runtime state — such as the last fire time and status — lives outside the manifest, in the database. Firing a trigger does not create a commit.

Creating, updating, or deleting a trigger through the API, SDK, or dashboard writes directly to the default branch. It does not go through a [change request](/docs/work/change-requests) (CR). Editing `kortix.yaml` inside a session and running `kortix ship` follows the normal branch and CR flow instead.

## Set up a cron trigger

### Add the trigger

```sh
kortix triggers add daily-digest --type cron \
--cron "0 0 9 * * 1-5" --timezone America/Los_Angeles \
--prompt "Summarize yesterday's activity and save it as a daily note."
```

`cron` is a 6-field expression: second, minute, hour, day, month, weekday. This command edits your local `kortix.yaml` only.

### Ship it

```sh
kortix ship
```

`kortix ship` commits `kortix.yaml` and pushes it. The schedule goes live once this lands on your project's default branch.

### Confirm it runs

```sh
kortix triggers ls
```

The list shows each trigger's slug, state, and when it last fired. To fire it now instead of waiting for the schedule, run `kortix triggers fire daily-digest`.

## Set up a webhook trigger

A webhook trigger needs a secret. Kortix uses it to check the signature on every incoming request.

### Add the secret

```sh
kortix secrets set WEBHOOK_SECRET=<a-random-value>
```

See [Secrets](/docs/project/secrets) for more on secrets.

### Add the trigger

```sh
kortix triggers add new-lead --type webhook \
--secret-env WEBHOOK_SECRET \
--prompt "A new lead arrived: {{ body.name }} ({{ body.email }}). Add it to the CRM."
```

`--secret-env` names the secret that signs requests to this trigger.

### Ship it

```sh
kortix ship
```

### Send it a request

Kortix builds the webhook URL from your project id and the trigger's slug:

```
POST /v1/webhooks/projects/<project-id>/<slug>
```

Send a signed `POST` request to this URL from the external service. Kortix checks the signature against `WEBHOOK_SECRET`, then starts a session with the request body available in the prompt. See [Webhook signature](#webhook-signature) below for the exact header and format.

By default, each fire starts a fresh session on a new branch. A trigger can instead reuse or pin a session, and a webhook trigger can filter which payloads start one — see [Session strategy](#session-strategy) and [Payload templating](#payload-templating) below.

## Config shape

```yaml
# kortix.yaml
triggers:
  - slug: daily-digest # required, lowercase + dashes, unique per project
    name: Daily digest # optional, defaults to slug
    type: cron # "cron" | "webhook", required
    agent: kortix # optional, defaults to "default"
    model: anthropic/claude-sonnet-4-5 # optional, resolves at fire time if unset
    enabled: true # optional, default true
    cron: "0 0 9 * * 1-5" # 6-field expression, mutually exclusive with run_at
    timezone: America/Los_Angeles # IANA name, default UTC
    session_mode: reuse # "fresh" | "reuse" | "pinned" | "keyed", default "fresh"
    filter: # optional, webhook payload guard
      "body.data.direction": "inbound"
    prompt: "Summarize {{ body.text }}" # required, template string
```

Legacy `kortix.toml` uses the same fields in a different container; see [legacy TOML](/docs/project/legacy-toml).

## Fields

| Field | Required | Default | Notes |
| --- | --- | --- | --- |
| `slug` | yes | — | `[a-z0-9][a-z0-9_-]{0,127}`, unique per project. |
| `type` | yes | — | `cron` or `webhook`. |
| `prompt` | yes | — | Template string. Renders as the session's first message. |
| `name` | no | `slug` | Human label. |
| `agent` | no | `default` | Must name a key in `agents:`, or fall back to `default_agent`. |
| `model` | no | resolves at fire time | Wire form `provider/model`, for example `anthropic/claude-sonnet-4-5`. |
| `enabled` | no | `true` | When `false`, the scheduler and the webhook receiver skip the entry. |
| `session_mode` | no | `fresh` | See [Session strategy](#session-strategy). |
| `session_id` | required for `pinned` | — | Exact session to re-prompt. |
| `session_key` | required for `keyed` | — | Template string. Setting it alone implies `session_mode: keyed`. |
| `filter` | no | — | Dotted path → expected string. A webhook delivery that does not match returns `200` and fires no session. |
| `cron` | one of `cron`/`run_at`, on `type: cron` | — | 6-field expression: second minute hour day month weekday. |
| `run_at` | one of `cron`/`run_at`, on `type: cron` | — | ISO-8601 timestamp. Fires once, then stays dormant. |
| `timezone` | no, cron only | `UTC` | IANA name. |
| `secret_env` | required, webhook only | — | Name of a project secret holding the webhook signing key. |

A cron trigger needs `cron` or `run_at`, never both. A webhook trigger without `secret_env` is rejected — there is no unauthenticated webhook.

## Session strategy

`session_mode` controls which session a fire re-prompts. Kortix tries the modes below in order and falls through on failure at each step.

1. **`pinned`** — re-prompt the exact `session_id`. If that session is gone or failed, fall through.
2. **`keyed`** — render `session_key` against the payload, then look up the most recent non-failed session previously stamped with that exact key. If the key renders empty, or no session matches, fall through to a fresh session. It never falls through to another key's session.
3. **`reuse`** — re-prompt the most recent non-failed session this trigger previously created. A pinned trigger falls back here too, before falling further.
4. **`fresh`** — create a new sandbox and branch. This is the default, and the final fallback for every mode. The new session becomes the trigger's session for future `reuse` and `keyed` fires.

Every trigger-fired session is created with project visibility (any project member can see it), not private to whoever configured or fired the trigger.

## Payload templating

`prompt` and `session_key` render with the same engine: `{{ token.dotted.path }}`. A missing value renders as an empty string — no error, no leftover `{{ }}`. Objects and arrays render as JSON. `session_key` is trimmed and truncated to 512 characters.

Every fire also gets `{{ trigger.slug }}`, `{{ trigger.type }}`, and `{{ trigger.kind }}` (always `git`). The rest of the variable set depends on how the trigger fired.

| Source | Variables |
| --- | --- |
| cron | `{{ cron.schedule }}`, `{{ cron.timezone }}`, `{{ cron.fired_at }}`, `{{ cron.last_fired_at }}`. No top-level `fired_at`. |
| webhook | `{{ fired_at }}`, `{{ body.* }}` (JSON-parsed; falls back to `{{ body.raw }}` if the body does not parse), `{{ headers.content_type }}`, `{{ headers.user_agent }}`, `{{ headers.forwarded_for }}`. |
| manual (dashboard "fire now" or the `fire` endpoint) | `{{ fired_at }}`, `{{ source }}` (`manual`), `{{ actor }}`, `{{ message.text }}`, `{{ message.source }}`. |

`{{ message.text }}` is hardcoded to an empty string on a manual fire, and `{{ message.source }}` to `manual_test`. A manual fire is not a way to inject test input into the prompt.

`filter` compares dotted paths as strings against the same payload the prompt sees. It exists to break loops. For example, a source that reports both sides of a conversation would otherwise re-fire the agent on its own reply.

## Webhook signature

Fires on `POST /v1/webhooks/projects/{projectId}/{slug}`. Kortix checks the request in this order, with a constant-time comparison:

1. **HMAC signature** — header `X-Kortix-Signature: sha256=<hmac>` (the `sha256=` prefix is optional) or the GitHub-compatible `X-Hub-Signature-256`. HMAC-SHA256 over the raw request body, using the secret named by `secret_env`.
2. **Static token**, only when no signature header is present, for senders that cannot HMAC-sign a body. Send the secret as `X-Kortix-Token: <secret>`, `Authorization: Bearer <secret>`, or `Authorization: Basic <base64(user:secret)>` (the password half is the token).

| Status | Meaning |
| --- | --- |
| 202 | Signature or token valid. Body is `{ status: "fired" \| "queued" \| "deduped", session_id, ... }`. |
| 200 | Valid, but skipped — the project is paused, or the delivery did not match `filter`. |
| 400 | Malformed project ID or slug in the URL. |
| 401 | Signature and token both missing or wrong. |
| 404 | Trigger not found, disabled, not a webhook, or the project is not active. |
| 409 | `secret_env` has no value set. |
| 500 | Auth passed, but the session failed to fire. |

## Endpoints

| Method + path | Needs | Notes |
| --- | --- | --- |
| `GET /v1/projects/{projectId}/triggers` | `project.trigger.read` | Lists triggers, runtime state, and manifest parse errors. A bad entry appears in `errors[]`; it does not break the other triggers. |
| `POST /v1/projects/{projectId}/triggers` | `project.trigger.create` | Creates a trigger. Commits to the manifest directly. |
| `PATCH /v1/projects/{projectId}/triggers/{slug}` | `project.trigger.update` | Partial update, merged onto the current entry. |
| `DELETE /v1/projects/{projectId}/triggers/{slug}` | `project.trigger.delete` | Also clears the trigger's runtime state. |
| `PATCH /v1/projects/{projectId}/triggers/activation` | `project.trigger.update` | Body `{ paused: boolean }`. See [Pause and resume](#pause-and-resume). |
| `POST /v1/projects/{projectId}/triggers/{slug}/fire` | `project.trigger.fire` | Manual fire. Any project member can fire a trigger — this permission does not require the editor or manager role. |
| `POST /v1/webhooks/projects/{projectId}/{slug}` | signature or token | Public URL, gated by the webhook secret. |

## Pause and resume

A project-level switch stops every trigger in the project at once, independent of each trigger's own `enabled` field. While paused, the scheduler skips the project and inbound webhooks return `200` with `{ status: "skipped" }` — no session fires. A manual fire still works. Use this when the same repository runs on two control planes (for example, dev and production) so cron does not fire twice. CLI: `kortix triggers pause` and `kortix triggers resume`. See [CLI](/docs/cli) for the full `kortix triggers` command group.

## Limits and reliability

- The scheduler polls every 60 seconds. Cron precision is best-effort to the minute, even though the expression has a seconds field.
- Each project allows 3 triggered sessions provisioning at once, by default. The account's plan-tier active-session cap can also apply. A fire past either limit returns `queued` (`202`) instead of failing, and runs once a slot frees up.
- A manual or webhook fire has a 45-second timeout. Loading the manifest has a 30-second timeout.
- A cron fire is keyed on the due schedule slot, so a fire that timed out but actually landed does not duplicate on retry. A webhook fire is keyed on the delivery ID header, or a hash of the body and signature when the sender sends no ID.
