Approval Flows
Gate high-stakes actions behind human approval — list, approve, and reject runs waiting at an approval gate via the runtime API or MCP.
Approval Flows
Approvals let a workflow pause for a human decision before it takes a costly or irreversible step — sending an external message, deleting a record, moving money, applying an autonomous remediation. A workflow is configured to require approval for specific actions or steps; at run time those runs sit in a waiting_for_approval state until a human (or a scoped agent) approves or rejects them. This page covers the decision surface exposed to agents: listing pending runs, approving or rejecting via REST or MCP, and how expiry is handled.
Lifecycle
Two records move together when a run hits an approval gate: the runs row that tracks the execution, and an approval_requests row that carries the decision state, comment, and audit trail.
The approval request itself has four terminal states:
approve_run / POST approve (decision=approved)
┌──────────────────────────────────────────────────────▶ approved
│
pending ──────┤
│ POST approve (decision=rejected)
├──────────────────────────────────────────────────────▶ rejected
│
│ 1h TTL, approval-expiry worker
└──────────────────────────────────────────────────────▶ expiredThe parent run moves in lockstep: waiting_for_approval → running on approve, cancelled on reject, timed_out on expiry. The transition only commits after the resume job has been enqueued, so a Redis outage will not strand a run in running with no executor job behind it.
Listing runs that need a decision
GET /api/v1/runtime/runs with needs_approval=true narrows the list to runs currently paused at an approval gate. The endpoint requires the runs:read scope and accepts these filters — all optional:
| Query param | Type | Meaning |
|---|---|---|
needs_approval | true | false | When true, returns only runs in waiting_for_approval. |
action_slug | string | Filter to one published action. |
status | string | Explicit status filter (for example waiting_for_approval, failed). |
source | string | action, replay, scheduler, etc. |
has_incident | true | false | Runs linked to an incident or not. |
date_from, date_to | ISO-8601 | Bound by created_at. |
limit | integer, 1–100, default 20 | Page size. |
offset | integer, default 0 | Page offset. |
curl -sS 'https://api.triggo.ai/api/v1/runtime/runs?needs_approval=true&limit=20' \
-H "Authorization: Bearer $TRIGGO_API_KEY"The response carries one row per run:
{
"runs": [
{
"run_id": "3f2e…",
"action_slug": "send_refund",
"source": "action",
"status": "waiting_for_approval",
"duration_ms": null,
"created_at": "2026-04-13T09:41:12.004Z"
}
],
"total": 1,
"limit": 20,
"offset": 0
}To fetch the full approval context (the pending approval_requests row, resolved inputs, description, expiry), call GET /api/v1/runtime/runs/:runId for the run you intend to decide on.
Approving or rejecting via REST
The same endpoint handles both decisions. The body's decision field selects the outcome.
POST /api/v1/runtime/runs/:runId/approve
Authorization: Bearer <key> (scope: approvals:decide)
Content-Type: application/json
{
"decision": "approved" | "rejected",
"comment": "optional, up to 1000 chars"
}curl -sS -X POST https://api.triggo.ai/api/v1/runtime/runs/3f2e…/approve \
-H "Authorization: Bearer $TRIGGO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"decision":"approved","comment":"verified refund with support thread"}'On success you get:
{
"run_id": "3f2e…",
"status": "running",
"decision": "approved",
"decided_at": "2026-04-13T09:42:03.118Z"
}Rejection returns "status": "cancelled". The comment is truncated server-side to 1000 characters and recorded on the approval_requests row along with the acting userId and decided_via: "api", which feeds the audit log (approval.decided).
Approving via MCP
MCP exposes the same operation as the approve_run tool. Same scope (approvals:decide), same payload shape, same result.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "approve_run",
"arguments": {
"run_id": "3f2e…",
"decision": "approved",
"comment": "verified refund with support thread"
}
}
}decision must be "approved" or "rejected"; comment is optional and capped at 1000 characters by the tool's Zod schema. The tool result mirrors the REST response.
Expiration
Every approval request is created with a 1-hour TTL. Creation schedules a BullMQ approval-expiry job whose jobId is derived from the approval id, so a decision inside the window removes the expiry job cleanly — there is no race where a user-approved run gets expired a moment later.
If the TTL elapses with no decision, approval-expiry.worker resolves the request as expired, writes an approval.decided audit entry with decision: "expired", and — if the approval is tied to a pipeline run — journals an APPROVAL_EXPIRED step failure and transitions the run to timed_out. The user is notified in their configured channel that the request expired and was auto-rejected.
Practically, an expired approval is equivalent-to-rejected for the run: the step does not execute, the workflow does not resume. The distinction matters for analytics and incident review — expired means "nobody decided in time", not "a human said no".
Errors
The endpoint and the MCP tool share the same error codes. HTTP status is derived from each error code by the runtime; MCP returns isError: true with the same code string.
| Code | Status | When |
|---|---|---|
UNAUTHORIZED | 401 | Missing, malformed, or revoked Bearer key. |
FORBIDDEN | 403 | Key is valid but lacks approvals:decide. |
RUN_NOT_FOUND | 404 | No run with that id in this workspace. |
BAD_REQUEST | 400 | decision is not "approved" or "rejected"; run is not in waiting_for_approval; or the approval was already resolved by a concurrent caller. |
RATE_LIMIT_EXCEEDED | 429 | Sliding-window limit hit — back off per the retry-after header. |
The "already resolved" case (BAD_REQUEST with message "Approval already resolved") is the one to handle gracefully: two agents racing on the same run, or a user and an agent both deciding. The update is atomic — UPDATE … WHERE status = 'pending' — so exactly one caller wins. Losers should re-fetch the run and reconcile against its new status.
Related
- API Keys — minting a key with
approvals:decide. - MCP quickstart — transport for the
approve_runtool. - Publishing workflows as actions — where approval policies are attached to actions.
- Workflow error handling — failure paths for rejected and expired runs.