Skip to main content

Triggers

Bind a starter (manual, webhook, or schedule) to an executable target (an orchestration, an agent, or a tool) so work runs without a client making an API call at the moment it should happen.

Overview

A trigger is a first-class, project-scoped resource. It connects one starter type to one target and records every activation as an auditable trigger firing. This delivers a full activation matrix — any starter can activate any target:

Starter ↓ / Target →OrchestrationAgentTool
ManualPOST /api/v1/triggers/{id}/fire
Webhook — signed POST /hooks/triggers/{trigger_id}
Schedule — 5-field cron (UTC)

Firings execute in-process: a manual fire is synchronous and returns the terminal firing; webhook and schedule fires are fire-and-forget and the firing record is the source of truth for the outcome.

See the Permissions Reference for the IAM action strings for this module.

Data Model

Trigger

FieldTypeDescription
idstringPublic identifier (trg_…)
project_idstringID of the owning project (hard security boundary)
namestringHuman-readable name, unique per project
descriptionstring | nullOptional description
typemanual | webhook | scheduleStarter type. Immutable after creation
target_typeorchestration | agent | toolKind of resource activated
target_idstringPublic ID of the target; must exist in the same project at create/update time
actionstring | nullTool targets only: the action for soat/mcp tools (required for those, rejected otherwise)
inputobject | nullStatic input, shallow-merged under fire-time input (fire-time keys win)
cronstring | null5-field cron expression (UTC). Required iff type=schedule, rejected otherwise
activebooleanInactive triggers never fire
policy_idstring | nullOptional boundary policy that further restricts firings (see Run-as Identity)
secretstringWebhook type only. Returned only on create, rotate, and GET …/secret
next_fire_atstring | nullRead-only. Schedule type only. Server-computed next fire time
created_atstringISO 8601 creation timestamp
updated_atstringISO 8601 last-updated timestamp

Trigger Firing

FieldTypeDescription
idstringPublic identifier (trg_fire_…)
trigger_idstringPublic ID of the trigger that fired
project_idstringID of the owning project
sourcemanual | webhook | scheduleHow this firing started (manually firing a webhook trigger records manual)
statuspending | running | succeeded | failedFiring lifecycle status
inputobject | nullEffective (post-merge) input snapshot
resultobject | null{ target_type, result_id, status, output }result_id is the run/generation public ID; output truncated
errorobject | null{ code, message, meta } when the firing failed
started_atstring | nullISO 8601 timestamp when execution began
completed_atstring | nullISO 8601 timestamp when the firing reached a terminal status

Key Concepts

Trigger Types

TypeStarted byNotes
manualPOST /api/v1/triggers/{id}/fireSynchronous; the response is the terminal firing
webhookSigned POST /hooks/triggers/{trigger_id} (see below)Has a secret; verified with HMAC-SHA256
scheduleThe built-in scheduler on a cron cadenceRequires cron; next_fire_at is server-computed in UTC

The type is fixed at creation. To change how a trigger starts, create a new one.

Targets and Input

The effective input is a shallow merge — fire-time input wins over the trigger's static input:

effective_input = { ...trigger.input, ...fire_time_input }

How the effective input reaches each target:

  • Orchestration → passed as the run input. Validated against the orchestration's input_schema when declared (lightweight required + primitive-type checks); a violation returns 400 with details.
  • Agent → turned into messages: input.messages (an array of { role, content }) is used verbatim; otherwise input.message (a string) becomes a single user message; otherwise a non-empty object is JSON-encoded into a user message. Empty input returns 400 TRIGGER_INPUT_INVALID.
  • Tool → passed as the tool call input, with trigger.action forwarded for soat/mcp tools. client-type tools cannot execute server-side and are rejected at trigger creation time.

Firing Status Semantics

succeeded means the target invocation completed without throwing — a paused orchestration run or a requires_action agent generation still counts as a successful firing, and the target's own status is visible in result.status. failed records the error, including a failed orchestration run.

Run-as Identity

Every firing — manual, webhook, or schedule — executes as the trigger creator. At fire time the server mints a short-lived internal token that is threaded into the target execution so downstream SOAT-type tools authenticate as that identity. Permissions are resolved as:

creator's current policies (ceiling) ∩ optional attached policy_id (boundary), hard-confined to the trigger's project.

Because the check runs against the creator's current policies at every fire, revoking the creator's access takes effect immediately.

Security invariants:

  • No privilege escalation. Creating a trigger (or changing its target) also requires the caller to hold the target-start action — orchestrations:StartRun, agents:CreateAgentGeneration, or tools:CallTool — and the same check re-runs at every fire.
  • No recursion. Trigger-scoped credentials cannot call the fire endpoint (403), so a trigger cannot fire another trigger in an unbounded loop.
  • Fail closed. If the creator is deleted the trigger is kept but firing fails with 409 TRIGGER_CREATOR_UNAVAILABLE. An attached policy cannot be deleted while a trigger references it (409 POLICY_HAS_DEPENDENTS). A deleted target causes the firing to record the error.
  • Secret hygiene. Webhook secrets are 32 random bytes (hex), never returned in list/get responses, rotate on demand, and inbound signatures are compared timing-safe.

Inbound Webhook Endpoint

A webhook trigger is fired by an external caller through a public endpoint that lives outside /api/v1:

POST /hooks/triggers/{trigger_id}

This endpoint takes no bearer token, applies no snake→camel case transform to the payload, and is excluded from the generated SDK/CLI/MCP surface. The caller signs the raw request body:

X-Soat-Signature: sha256=<hex(HMAC-SHA256(secret, body))>

Responses:

ConditionStatusBody
Unknown or non-webhook trigger404Existence is not leaked
Missing or bad signature401
Inactive trigger (after a valid signature)409
Invalid JSON body400
Orchestration input_schema violation400With details
Accepted202{ firing_id, trigger_id, status }

The request body becomes the fire-time input (a non-object JSON value is wrapped as { "payload": … }); the body is capped at 1 MiB. The firing then executes in the background — poll the firing record for the outcome.

Before wiring this endpoint to a real external system, use soat listen to receive and verify signed deliveries on your local machine.

Verifying a signature on the receiving side mirrors the outbound webhooks convention:

const crypto = require('crypto');

const sign = (secret, rawBody) =>
'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

Schedules and Misfire Coalescing

A schedule trigger is evaluated by a DB-driven poller. Cron expressions are strictly 5-field and evaluated in UTC; an invalid expression is rejected at create/update with 400 INVALID_CRON_EXPRESSION. The scheduler is multi-instance safe — each due trigger is claimed with an atomic conditional update, so exactly one instance fires it.

Misfire coalescing: next_fire_at is recomputed from now after each claim. Firings that were missed while the server was down coalesce into at most one catch-up firing on restart, and then the normal schedule resumes — there is no unbounded catch-up storm.

Common Errors

CodeStatusCauseWhat to do
INVALID_CRON_EXPRESSION400cron is missing a field or otherwise not a valid 5-field expressionFix the expression; it is always evaluated in UTC
TRIGGER_TARGET_NOT_FOUND400target_id does not exist in the trigger's projectVerify the target ID and that it belongs to the same project as the trigger
TRIGGER_ACTION_NOT_ALLOWED400An invalid field combination for type/target_type — e.g. cron on a non-schedule trigger, action on a non-tool target, or a client-type tool as the targetCheck the Trigger Types and Targets and Input rules
TRIGGER_INPUT_INVALID400Fire-time input doesn't satisfy the target — empty agent input, or a field missing/mismatched against an orchestration's input_schemaSupply the required input fields for the target type
TRIGGER_NOT_ACTIVE409The trigger's active field is falsePATCH the trigger with active: true before firing
TRIGGER_CREATOR_UNAVAILABLE409The user who created the trigger no longer existsThe trigger cannot fire under a deleted user's identity — recreate it under a live user
TRIGGER_RECURSION_FORBIDDEN403A trigger-scoped run-as credential tried to call the fire endpointFire the trigger with a user/API-key credential; a trigger cannot fire another trigger
NAME_CONFLICT409A trigger with that name already exists in the projectChoose a different name
POLICY_HAS_DEPENDENTS409Attempted to delete a policy while a trigger's policy_id still references itDetach the policy from the trigger first, or delete the trigger
RESOURCE_NOT_FOUND404The trigger or firing ID doesn't exist (or isn't in the caller's project)Check the ID and project scope

For the inbound webhook endpoint's error responses (bad signature, oversized body, inactive trigger, …), see the table above.

A schedule trigger never fires: confirm active is true, that next_fire_at is set (a null value means the trigger isn't schedulable), and that the server wasn't started with SOAT_TRIGGER_SCHEDULER_DISABLED=true. If the server was down past a fire time, expect at most one coalesced catch-up firing on restart, not one per missed occurrence — see Schedules and Misfire Coalescing.

A firing's status never leaves pending/running: webhook and schedule firings execute fire-and-forget in the background; poll GET /trigger-firings/{id} for the terminal status. There is no automatic retry of a failed firing — inspect error.code/error.message on the firing record and re-fire manually (or fix the underlying target) if it failed.

Formation Support

Triggers can be declared in a Formation template as the trigger resource type, so an Agent Squad ships with its schedule. Template properties are name, description, type, target_type, target_id, action, input, cron, active, and policy_id. Use { "ref": "LogicalId" } for target_id/policy_id to wire a trigger to another resource in the same template, and capture a webhook trigger's server-generated secret as an output with ref_attr:

{
"resources": {
"DailyFlow": { "type": "orchestration", "properties": { "...": "..." } },
"DailyCycle": {
"type": "trigger",
"properties": {
"name": "daily-cycle",
"type": "schedule",
"target_type": "orchestration",
"target_id": { "ref": "DailyFlow" },
"cron": "0 8 * * *",
"input": { "cycle": "daily" },
"active": true
}
}
}
}

Configuration

Environment VariableRequiredDescription
SOAT_TRIGGER_SCHEDULER_INTERVAL_MSNoScheduler poll interval in milliseconds (default 30000)
SOAT_TRIGGER_SCHEDULER_DISABLEDNoSet to true to disable the schedule poller
SOAT_TRIGGER_TOKEN_TTLNoTTL of the minted run-as token (default 1h)

Examples

Create a schedule trigger

soat create-trigger \
--project-id proj_ABC \
--name "Daily Cycle" \
--type schedule \
--target-type orchestration \
--target-id orch_XYZ \
--cron "0 8 * * *"

Fire a trigger manually

soat fire-trigger --trigger-id trg_ABC --input '{"reason":"manual run"}'

Call the inbound webhook endpoint

BODY='{"event":"push","ref":"main"}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"

curl -X POST https://api.example.com/hooks/triggers/trg_ABC \
-H "Content-Type: application/json" \
-H "X-Soat-Signature: $SIG" \
-d "$BODY"
# → 202 { "firing_id": "trg_fire_...", "trigger_id": "trg_ABC", "status": "pending" }

List a trigger's firings

soat list-trigger-firings --trigger-id trg_ABC