Skip to content

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 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 Developers-tab principal) and lives as a row in the Outpost's database. It is managed from the dashboard's Developers → Triggers card; the receiver is the single machine-door endpoint POST /api/v1/triggers/{id}.

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 URLtrg_<16 random hex> (64 bits) the capability URL pasted into your source's webhook settings
The secrettrgsec_<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

On the dashboard's Developers tab, click a service to expand its triggers list (newest first), then Create trigger. 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)
Workspace one of the service's allowed_workspaces
Template the prompt, written with {{dotted.path}} placeholders

The template form renders a live preview against a per-source sample payload, so a {{ typo.in.path }} is caught at create time — not on the first real fire (which would 400 and create no job). 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 live preview validates every {{ }} reference against a fixed sample payload that does not include event_type — a template referencing {{event_type}} fails live-preview 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, run now, always mode=auto — 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.

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.
  • Idempotency — keyed on the delivery id.

Trigger fires show in the 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 (Developers tab) 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.

Verifying

tests/test_outpost/test_triggers_*.py (in task verify) drives the signature verifiers (all three sources, good / bad / missing), the event filter, the template renderer, idempotency from delivery ids, and the error taxonomy headlessly. The bundled issue-triage recipe is a trigger-bound job you can enable end-to-end from the dashboard.