# Triggers

> A trigger is a webhook endpoint on the Outpost that lets an external system — GitHub, Slack, or anything that can curl — fire a governed agent job without holding a bearer token. Webhook sources can't attach credentia…

A **trigger** is a webhook endpoint on the Outpost that lets an *external*
system — GitHub, Slack, or anything that can `curl` — fire a governed agent
job **without holding a bearer token**. Webhook sources can't attach
credentials, so a trigger's authentication *is* the per-source signature on
the request body. Wire GitHub up once and every opened pull request can flip
a switch that runs a real `kin` job.

Each trigger belongs to a **service** (the Advanced-area principal) and
lives as a row in the [Outpost](/docs/kin/guide/outpost/)'s database. It is managed from
the [dashboard's **More → Advanced → Triggers**](/docs/kin/guide/outpost/#more-advanced-services-and-triggers-card-services-card-triggers)
card; the receiver is the single machine-door endpoint
`POST /api/v1/triggers/{id}`.

<!-- SOURCE: container/dashboard/v1_triggers.py, container/dashboard/triggers_db.py, container/dashboard/services_api.py, container/dashboard/template.py, container/dashboard/app.py, container/dashboard/templates/landing_body.py, container/dashboard/templates/landing_js.py -->

## Outcome

Give GitHub, Slack, or another webhook source one narrow way to launch a
governed Outpost job without sharing an operator bearer token.

## Fast path

1. Register an Outpost service and allow only the intended workspace.
2. Open **More → Advanced → Triggers** and choose **Create trigger**.
3. Select the service, source, event filter, and workspace.
4. Write a bounded prompt template using only the offered dotted payload keys.
5. Save, then copy the generated URL and show-once signing secret to the
   source's webhook settings.
6. Send the source's test event and inspect the resulting run in Activity.

> **A trigger is a row, not a script**
>
> The prompt a trigger fires is built by a **restricted renderer** —
> `{{dotted.path}}` substitution only, no Jinja, no expressions, no
> attribute access, every value sanitized and capped. A hostile source
> cannot get the model to execute code or rewrite its own prompt; the
> renderer's whole defense is its narrowness. The rendered prompt also
> carries an **untrusted-data banner** as its first line, so the kin
> harness treats the webhook payload as data, not instruction.

## The two halves of a key

A trigger is gated by **two halves that must both match** — the URL is the
capability, the secret is the signature:

| Half | What it is | Where it goes |
|---|---|---|
| **The URL** — `trg_<16 random hex>` (64 bits) | the capability URL | pasted into your source's webhook settings |
| **The secret** — `trgsec_<base64url>` | the HMAC signing key | pasted into your source's secret / signing field |

- Know the URL but not the secret → **401** (`bad-signature`).
- Know the secret but not the URL → **404** (`not-found`).

This mirrors the signature primitive each source already publishes —
GitHub's `X-Hub-Signature-256`, Slack's `v0=` signing, a generic
`X-Kin-Signature-256` — so there's no new operator education. Outpost plays
the receiver side.

## Creating a trigger

In the dashboard's **More → Advanced → Triggers** card, choose **Create
trigger**. The button becomes available once at least one registered service
has an allowed workspace; services with an empty workspace ACL cannot produce
a valid trigger and are omitted from the form. The service choices refresh in
place after registration or ACL changes while preserving a still-valid
selection. The form takes:

| Field | Value |
|---|---|
| **Name** | `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` — unique per service |
| **Source** | `github`, `slack`, or `generic` |
| **Event filter** | a per-source allowlist (see [Event filter](#event-filter)) |
| **Workspace** | one of the service's `allowed_workspaces` |
| **Template** | the prompt, written with `{{dotted.path}}` placeholders |

On create, Outpost validates the template against a per-source sample payload,
so a `{{ typo.in.path }}` is rejected before the trigger row is stored — not on
the first real fire. The keys you can reference:

| Source | Available keys |
|---|---|
| **github** | `event`, `action`, `sender`, `repo`, `ref`, plus `number`/`title`/`body`/`html_url`/`base_ref`/`head_ref` for PR events, or `number`/`title`/`body`/`html_url` for issue events |
| **slack** | `user_name`, `channel_name`, `command`, `text` (and `event_type` for event-callback triggers) |
| **generic** | every scalar key in your JSON body, flattened to dotted paths — nested dicts/lists recurse to depth ≤ 3 |

For a slack **event-callback** fire, `command` is present but always empty
(`command` only carries a value on a slash-command fire). The create-time
sample validation checks every `{{ }}` reference against a fixed sample
payload that does **not** include `event_type` — a template referencing
`{{event_type}}` fails create-time validation even though the key IS
populated on a real event-callback fire (the sample payload is a known gap,
not a documented restriction).

On create, the workspace must be in the service's live `allowed_workspaces`
ACL — which is **re-checked at fire time**, so a grant revoked between create
and fire fails closed.

## Wiring up your source

The create response carries the `trgsec_<base64url>` signing secret
**exactly once** (mirroring `kin service create`'s show-once discipline).
Copy it immediately — subsequent reads never expose it. The panel renders
the exact setup for each source:

- **GitHub** — paste the payload URL
  (`https://outpost.kinra.ai/api/v1/triggers/trg_…`) and the secret into
  your repo's Webhook settings. GitHub sends
  `X-Hub-Signature-256: sha256=<hex>`; Outpost verifies with
  `hmac.compare_digest` over the SHA-256 digest.
- **Slack** — paste the URL and secret into your Slack app's Event
  Subscriptions / Slash Commands signing key. Slack sends
  `X-Slack-Signature: v0=<hex>` + `X-Slack-Request-Timestamp`; the receiver
  enforces a 300s timestamp tolerance (the documented Slack window) and
  verifies the `v0:{ts}:{body}` signed material.
- **Generic** — a working `curl` one-liner with the
  `X-Kin-Signature-256: sha256=<hex>` header computed against the exact body
  bytes, so you can verify locally with `openssl` / `hmac`.

## What happens during a fire

`POST /api/v1/triggers/{id}` walks this path:

1. **Signature verify** — constant-time `hmac.compare_digest`, per-source
   scheme, with a prefix check *before* the compare (algorithm-confusion
   defense). Fail → 401, nothing else runs.
2. **Event filter** — your allowlist. A miss returns `200 {filtered: true}`
   (webhook sources need 2xx to stop retrying) and creates **no job**.
3. **Render the template** into the agent prompt, prefixed with the
   untrusted-data banner. A missing key → 400 `template-error`, no job.
4. **Create a one-shot job** ([`kind=at`](/docs/kin/guide/scheduled-jobs/#creating-a-job),
   run now, always [`mode=auto`](/docs/kin/guide/modes-and-permissions/#the-two-modes) —
   there's no way to fire a trigger `strict`) with `trigger_id` stamped on
   both the job row and the run row.
5. **Return 202** —
   `{job_id, run_id, state, submitted_at, status_url, report_url, trigger_id}`.

The receiver makes **no outbound calls** (no SSRF surface). The fire drops
into the **same internal v1 submit path** as any one-shot job, so it
inherits the full gate stack — see [What a fire inherits](#what-a-fire-inherits).

## Event filter

The filter is a per-trigger allowlist:

| Source | Filter shape | Behavior |
|---|---|---|
| **github** | `{"events": ["pull_request", ...], "actions": ["opened", ...]}` | matches `X-GitHub-Event` against `events[]` and `payload.action` against `actions[]`; `actions` is optional (omit = all actions of the listed events) |
| **slack** | `{"type": "command", "name": "/deploy"}` or `{"type": "event", "name": "message"}` | `command` matches `payload.command` against `name`; `event` matches `payload.event.type` |
| **generic** | `{}` | no filter — every event passes |

## The safety model

- **At-most-once from the delivery id.** The receiver keys the existing v1
  idempotency store on the per-source delivery id — GitHub's
  `X-GitHub-Delivery`, Slack's `X-Slack-Event-Id`, or your `Idempotency-Key`
  — each falling back to `sha256(body)` when the source omits its header
  (a slash command never carries `X-Slack-Event-Id`, only event-callback
  retries do). A replay of the same delivery returns the **same 202
  verbatim** — no double-fire.
- **Flood defense in layers.** A pre-auth door ceiling (600/min, global), a
  per-trigger token bucket (30/min default), the governor's queue depth,
  and the global `MAX_JOBS` cap — four independent bounds.
- **Trigger cap.** A service may hold at most 32 triggers
  (`MAX_TRIGGERS_PER_SERVICE`); creating past the cap 400s `trigger_error`.
- **Disable = 404.** A disabled trigger returns the identical response to a
  nonexistent id, so an attacker can't enumerate which trigger IDs are live.
- **Regenerate re-mints the secret** and the old one stops authenticating
  immediately — a leaked secret is a seconds-fix, not an incident.
- **Show-once secret.** The `trgsec_` plaintext appears exactly once, at
  create (or regenerate); it is never logged, never re-exposed in a GET, and
  the audit row carries only the trigger id + service id.
- **Exact-path exemption.** The receiver is exempt from bearer auth, rate
  limit, and idempotency middleware *only* on the exact path
  `^/api/v1/triggers/trg_[0-9a-f]{16}$`. A trailing slash, longer suffix, or
  prefix mismatch falls through to the standard chain and 401s — the door
  ceiling, body cap (256 KiB), request-id, problem renderer, and CORS still
  apply.

## Per-trigger actions

From the Triggers card:

- **Regenerate** — re-mints the secret; the old one stops authenticating
  immediately.
- **Disable** — the receiver 404s (indistinguishable from a missing id).
- **Delete** — physical delete; the audit log keeps a row for forensic
  reconstruction.

## What a fire inherits

A trigger fire is **not a special path** — it reuses the internal v1 submit
transaction, so it inherits exactly what a v1 one-shot does:

- **ACL** — the service's live `allowed_workspaces` (re-checked at fire
  time).
- **Quota** — the service's `max_concurrent_jobs`.
- **Governor** — admission class `api` (priority 5), so a trigger flood
  can't starve a live interactive TUI under GPU contention. See
  [the governor](/docs/kin/guide/scheduled-jobs/#the-governor-cross-run-gpu-fairness).
- **Idempotency** — keyed on the delivery id.

Trigger fires show in [**Activity**](/docs/kin/guide/outpost/#activity-needs-you-running-now-and-recent-card-inbox-card-activity)
feed with the `trigger` source chip (the chip color follows the per-source
grammar). One caveat: the out-of-the-box §10 Standard Webhooks outbox
**never** fires for trigger jobs — the receiver stamps every trigger-created
job's `v1_webhook_url` empty unconditionally, regardless of whether the
parent service has a `webhook_url` registered.

## API surface

The human door (More → Advanced) manages the rows; the machine door
(signature-auth) is where they fire.

**Human door — `/api/services/{service_id}/...`** (CSRF + SSO):

| Route | What |
|---|---|
| `GET /api/services/{id}/triggers` | masked listing for the service (never the secret) |
| `POST /api/services/{id}/triggers` | create — returns the show-once `trgsec_` secret |
| `POST /api/services/{id}/triggers/{trg}/disable` | soft-disable (receiver 404s) |
| `POST /api/services/{id}/triggers/{trg}/enable` | re-enable |
| `POST /api/services/{id}/triggers/{trg}/regenerate` | re-mint secret (old one dies immediately) |
| `DELETE /api/services/{id}/triggers/{trg}` | physical delete |

**Machine door — the receiver:**

| Route | What |
|---|---|
| `POST /api/v1/triggers/{id}` | fire — signature-auth; 202 on accept, `200 {filtered:true}` on a filter miss |

Every non-2xx response is `application/problem+json` (RFC 9457). See
`PROTOCOL.md §10A` for the full wire contract — signature schemes,
idempotency derivation, the complete error taxonomy, and the capability-URL
posture.

## Troubleshooting

- **Create is unavailable:** the chosen service needs at least one allowed
  workspace.
- **The source receives 401:** the URL exists but the signature or secret does
  not match.
- **The source receives 404:** the capability URL/id is wrong or disabled.
- **No run appears:** check the event filter and delivery id; a filtered or
  duplicate event is acknowledged without creating another job.
- **A template key is rejected:** use only the source-specific dotted keys
  shown by the form.

## Reference

- [Automations](/docs/kin/guide/scheduled-jobs/)
- [Outpost overview](/docs/kin/guide/outpost/)
- [Protocol](/docs/kin/protocol/)
- [Outpost operations](/docs/kin/internals/outpost-operations/)
