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 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¶
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 sendsX-Hub-Signature-256: sha256=<hex>; Outpost verifies withhmac.compare_digestover 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 thev0:{ts}:{body}signed material. - Generic — a working
curlone-liner with theX-Kin-Signature-256: sha256=<hex>header computed against the exact body bytes, so you can verify locally withopenssl/hmac.
What happens during a fire¶
POST /api/v1/triggers/{id} walks this path:
- 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. - Event filter — your allowlist. A miss returns
200 {filtered: true}(webhook sources need 2xx to stop retrying) and creates no job. - Render the template into the agent prompt, prefixed with the
untrusted-data banner. A missing key → 400
template-error, no job. - Create a one-shot job (
kind=at, run now, alwaysmode=auto— there's no way to fire a triggerstrict) withtrigger_idstamped on both the job row and the run row. - 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'sX-Slack-Event-Id, or yourIdempotency-Key— each falling back tosha256(body)when the source omits its header (a slash command never carriesX-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_JOBScap — four independent bounds. - Trigger cap. A service may hold at most 32 triggers
(
MAX_TRIGGERS_PER_SERVICE); creating past the cap 400strigger_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.