# Triggers Cron and signed-webhook entries that spawn, reuse, or pin sessions — fields, template variables, and gotchas. Canonical page: https://kortix.com/docs/reference/triggers Field-level reference for triggers. For the plain-language version, see [Automations](/docs/concepts/triggers). A trigger fires a session that runs the rendered prompt as its initial message. By default the platform creates a fresh session branch like an interactive session; `session_mode` can instead reuse that trigger's prior session or pin the fire to a specific session. The agent works, commits, pushes. Landing on `main` goes through a [change request](/docs/reference/change-requests). Triggers live in the manifest — `triggers:` (a YAML list) in v2 `kortix.yaml`, `[[triggers]]` (a TOML array of tables) in legacy v1 `kortix.toml`. Same fields either way, just a different container. The manifest is the source of truth for *config*; runtime state (`last_fired_at` only — there is no fire count) lives in `project_trigger_runtime` so a fire doesn't commit on every tick. For when a trigger last fired, check the dashboard, not the repo. ## Cron triggers ```yaml # kortix.yaml (v2) triggers: - slug: daily-digest name: Daily digest type: cron agent: kortix enabled: true cron: "0 0 9 * * 1-5" timezone: America/Los_Angeles prompt: | Summarize yesterday's commits across the repo. Save the result to notes/digest-{{ cron.fired_at }}.md and open a CR against main. ``` ```toml # kortix.toml (legacy v1) — same fields, TOML array of tables [[triggers]] slug = "daily-digest" name = "Daily digest" type = "cron" agent = "kortix" enabled = true cron = "0 0 9 * * 1-5" timezone = "America/Los_Angeles" prompt = """ Summarize yesterday's commits across the repo. Save the result to notes/digest-{{ cron.fired_at }}.md and open a CR against main. """ ``` `cron` is a **6-field croner expression**: `second minute hour day month weekday`. `timezone` is an IANA name (default `UTC`). The scheduler polls every 60 s (`KORTIX_TRIGGER_SCHEDULER_INTERVAL_MS`), so sub-minute precision is best-effort. ## Webhook triggers ```yaml # kortix.yaml (v2) triggers: - slug: slack-hook name: Slack handler type: webhook agent: kortix enabled: true secret_env: WEBHOOK_SLACK_SECRET prompt: "Slack event: {{ body.text }}" ``` ```toml # kortix.toml (legacy v1) [[triggers]] slug = "slack-hook" name = "Slack handler" type = "webhook" agent = "kortix" enabled = true secret_env = "WEBHOOK_SLACK_SECRET" prompt = "Slack event: {{ body.text }}" ``` Fires on signed `POST` requests to: ``` POST /v1/webhooks/projects// ``` The secret value lives in `project_secrets`; the manifest references it by name. Declare it in `env.optional` (v2) or `[env].optional` (v1 legacy) so it shows up in the dashboard's Environment variables page. ### Signature - Primary header: `X-Kortix-Signature: sha256=`. The `sha256=` prefix is optional — the receiver strips it if present. - GitHub-compatible: `X-Hub-Signature-256` is also accepted, so GitHub webhooks point straight at this URL with no adapter. - Algorithm: HMAC-SHA256 over the **raw** request body using the secret named by `secret_env`. - Format: exactly 64 hex chars (mixed case accepted). - Compared with constant-time `timingSafeEqual`. **Fallback: static token auth.** If neither `X-Kortix-Signature` nor `X-Hub-Signature-256` is present on the request, the receiver falls back to a static shared-token check against the same `secret_env` value — for senders that can't HMAC-sign a body (e.g. Better Stack error webhooks, which only support custom headers or basic auth). Send the token as: - `X-Kortix-Token: `, or - `Authorization: Bearer `, or - `Authorization: Basic ` — the password half is used as the token. The token is compared to `secret_env` with constant-time `timingSafeEqual`, same as the HMAC path. If a signature header is present, the token headers are ignored — signature auth always takes priority. ### Response codes | Status | Meaning | | --- | --- | | 202 | Signature or token valid — the session was fired or queued. The body is `{ "status": "fired", "session_id": … }` or `{ "status": "queued", "reason": … }` (queued when you're at a concurrency cap). | | 400 | Malformed project id or slug in the URL. | | 401 | Signature/token missing or mismatched. | | 404 | Trigger not found, disabled, or not a webhook (also when the project isn't active). | | 409 | `secret_env` value is not configured in the dashboard's Environment variables page. | | 500 | Auth succeeded but the session failed to fire. | ## Field reference ### Common fields | Field | Required | Type | Default | Notes | | --- | --- | --- | --- | --- | | `slug` | yes | string | — | `[a-z0-9][a-z0-9_-]{0,127}`, unique among triggers. | | `type` | yes | string | — | `"cron"` or `"webhook"`. | | `prompt` | yes | string | — | Mustache-templated body. May be multi-line via `"""…"""`. Alias: `prompt_template`. | | `name` | no | string | `slug` | Human label. | | `agent` | no | string | `"default"` | Agent name. In v2 (`kortix.yaml`), must name a key in `agents:` or be omitted to fall back to `default_agent`. Legacy v1 projects with no `[[agents]]` resolve it through OpenCode discovery instead. Alias: `agent_name`. | | `model` | no | string | — (resolves at fire time) | Per-trigger model override, wire form `provider/model` (e.g. `"anthropic/claude-sonnet-4-5"`). When unset, resolves through the chain agent → project → account → platform `auto` at fire time — catalog availability is validated when the trigger runs, not when the manifest is parsed. | | `session_mode` | no | string | `"fresh"` | `"fresh"`: every fire mints a brand-new session (new sandbox + branch). `"reuse"`: re-prompts the trigger's most recent session (resuming its sandbox) so one long-lived session accumulates context across fires. `"pinned"`: first tries the exact `session_id` below, then falls back to the trigger's reusable session or a fresh one if the pin is gone or unavailable. Case-insensitive. Alias: `sessionMode`. | | `session_id` | required only for `session_mode: "pinned"` | string | — | Exact project session to re-prompt for pinned mode. Alias: `sessionId`. | | `enabled` | no | bool | `true` | When `false`, the scheduler / receiver skip the entry. | ### Cron-only fields | Field | Required | Type | Default | Notes | | --- | --- | --- | --- | --- | | `cron` | one of `cron` / `run_at` | string | — | 6-field croner expression. Mutually exclusive with `run_at`. Alias: `schedule`. | | `run_at` | one of `cron` / `run_at` | string | — | ISO-8601 datetime for a one-off ("run once") schedule — the trigger fires once at/after this instant and then stays dormant. Mutually exclusive with `cron`. Alias: `runAt`. | | `timezone` | no | string | `"UTC"` | IANA name, e.g. `"America/Los_Angeles"`. | ### Webhook-only fields | Field | Required | Type | Notes | | --- | --- | --- | --- | | `secret_env` | yes | string | Name of a `project_secrets` entry holding the HMAC secret (and the static-token fallback secret — see [Signature](#signature)). Manifest-side regex is `^[A-Z_][A-Z0-9_]*$` (unbounded). Alias: `secretEnv`. | The parser also accepts the camelCase / alternate aliases noted above (`prompt_template`, `agent_name`, `sessionMode`, `schedule`, `runAt`, `secretEnv`) — the manifest-schema validator mirrors this same tolerance so a manifest that materializes fine never fails validation. ## Template variables `prompt` renders with a mustache-style engine: `{{ token.dotted.path }}`. Missing values render as empty strings — no error, no leftover `{{ x }}`. Objects and arrays render as JSON. The variables differ by **how the trigger fired** — there is no single combined set. On every fire you get `{{ trigger.slug }}`, `{{ trigger.type }}`, and `{{ trigger.kind }}` (always `"git"`). **Cron fires** — note there is no top-level `fired_at`; use `cron.fired_at`: | Variable | Source | | --- | --- | | `{{ cron.schedule }}` | The croner expression that fired. | | `{{ cron.timezone }}` | Configured tz (default `"UTC"`). | | `{{ cron.fired_at }}` | ISO-8601 timestamp of this fire. | | `{{ cron.last_fired_at }}` | Previous fire timestamp (or empty). | **Webhook fires:** | Variable | Source | | --- | --- | | `{{ fired_at }}` | ISO-8601 timestamp of this fire. | | `{{ body.* }}` | JSON-parsed request body (dotted access). Unparseable body → `{{ body.raw }}`. | | `{{ headers.content_type }}` · `{{ headers.user_agent }}` · `{{ headers.forwarded_for }}` | Request headers. | **Manual fires** (dashboard "fire now"): | Variable | Source | | --- | --- | | `{{ fired_at }}` | ISO-8601 timestamp. | | `{{ source }}` | `"manual"`. | | `{{ actor }}` | User id that fired it. | | `{{ message.text }}` · `{{ message.source }}` | Manual-fire context. | Missing values render as empty strings — no error, no leftover `{{ x }}`. Objects and arrays render as JSON. ## Common gotchas - In legacy `kortix.toml`, `[triggers]` (single brackets) is wrong — must be `[[triggers]]` (array of tables). The parser surfaces a clear error. - Slugs must be lowercase + URL-safe. Uppercase or spaces fail. - A webhook trigger without `secret_env` is rejected. There is no unauthenticated webhook surface — by design. - A cron trigger must declare either `cron` or a one-off `run_at` — having neither is rejected. - Bad entries surface in the listing's `errors` array next to the good ones — they don't break the whole manifest.