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>. 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: <secret>, or
  • Authorization: Bearer <secret>, or
  • Authorization: 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

StatusMeaning
202Signature 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).
400Malformed project id or slug in the URL.
401Signature/token missing or mismatched.
404Trigger not found, disabled, or not a webhook (also when the project isn't active).
409secret_env value is not configured in the dashboard's Environment variables page.
500Auth succeeded but the session failed to fire.

Field reference

Common fields

FieldRequiredTypeDefaultNotes
slugyesstring[a-z0-9][a-z0-9_-]{0,127}, unique among triggers.
typeyesstring"cron" or "webhook".
promptyesstringMustache-templated body. May be multi-line via """…""". Alias: prompt_template.
namenostringslugHuman label.
agentnostring"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.
modelnostring— (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_modenostring"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.
enablednobooltrueWhen false, the scheduler / receiver skip the entry.

Cron-only fields

FieldRequiredTypeDefaultNotes
cronone of cron / run_atstring6-field croner expression. Mutually exclusive with run_at. Alias: schedule.
run_atone of cron / run_atstringISO-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.
timezonenostring"UTC"IANA name, e.g. "America/Los_Angeles".

Webhook-only fields

FieldRequiredTypeNotes
secret_envyesstringName 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:

VariableSource
{{ 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:

VariableSource
{{ 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"):

VariableSource
{{ 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.
Triggers – Kortix Docs