Triggers
Cron and signed-webhook entries that spawn fresh sessions — fields, template variables, and gotchas.
Field-level reference for triggers. For the plain-language version, see Automations.
A trigger fires and spawns a new session that runs the rendered prompt as its initial message. The platform creates a session branch like an interactive session; the agent works, commits, pushes. Landing on main goes through a change request.
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
# 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.# 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
# 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 }}"# 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/<project_id>/<slug>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=<hmac>. Thesha256=prefix is optional — the receiver strips it if present. - GitHub-compatible:
X-Hub-Signature-256is 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: <secret>, orAuthorization: Bearer <secret>, orAuthorization: Basic <base64(user:secret)>— 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; falls back to a fresh session if none exists or the last one is dead. Case-insensitive. Alias: sessionMode. |
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). 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_envis rejected. There is no unauthenticated webhook surface — by design. - A cron trigger must declare either
cronor a one-offrun_at— having neither is rejected. - Bad entries surface in the listing's
errorsarray next to the good ones — they don't break the whole manifest.