Human-in-the-loop approvals
An approval step parks its workflow until a person answers it. The request and every response are durable, auditable, and safe to retry.
There are two shapes. Reach for the first unless you actually need the second.
| Answered by | Use when | |
|---|---|---|
| Operator gate | the dashboard or apiary approve |
apiary runs on your machine — the default |
| Multi-party gate | named approvers, quorum, signed webhook | several people must sign off, or approvals arrive from another system |
The operator gate
No approvers, no source signals — just a question:
workflows:
- id: production-release
steps:
- id: authorize
type: approval
message: Approve the production release.
timeout: 24h
- id: deploy
agent: release-engineer
action_class: deploy
The workflow parks at authorize and stays parked across daemon restarts.
Nothing resumes it but an answer or the timeout, so a gate without a timeout
waits indefinitely — usually what you want, and apiary validate warns about it
so it is never accidental.
Answering from the terminal
$ apiary approvals
REQUEST WORKFLOW STEP PARKED EXPIRES
wf-8a31:authorize release authorize 12m in 23h
$ apiary approve wf-8a31:authorize
✓ Approved — workflow resuming
apiary approvals <request-id> shows one request in detail, including the
fields it expects. apiary reject <request-id> --comment "..." refuses it.
Exit codes make the commands scriptable:
| Code | Meaning |
|---|---|
0 |
the gate is resolved; the workflow is resuming |
3 |
recorded, but the gate still waits (a quorum gate) |
4 |
unknown request, or one that was already answered |
1 |
anything else — transport or validation |
Answering from the dashboard
On a parked instance — in the task detail or the workflow monitor:
| Key | Action |
|---|---|
y |
approve (opens the form instead when the step declares fields) |
n |
reject |
a |
open the form |
Asking a question, not just yes/no
Declare fields and the gate collects typed answers alongside the decision:
- id: pick-rollout
type: approval
message: Release 2.4 is staged. How should it go out?
fields:
- name: strategy
label: Rollout strategy
type: choice
options: [canary, blue_green, full]
required: true
- name: change_ticket
type: string
required: true
timeout: 24h
Field types are string, text, boolean, number, and choice.
Submitted values enter workflow memory as memory.<field>, together with
memory.approval_decision and memory.approval_feedback — so a choice field
decides what runs next:
- id: canary-deploy
agent: release-engineer
if: ${{ memory.strategy == 'canary' }}
- id: full-deploy
agent: release-engineer
if: ${{ memory.strategy == 'full' }}
In the dashboard, a opens a form: arrow keys move between fields, a choice is
selected with ←/→ or its number, a boolean toggles with space, ⏎ approves,
^r rejects, esc cancels.
From the CLI, fields are prompted for on a terminal and passed as flags off one:
# interactive — walks the fields
apiary approve wf-8a31:pick-rollout
# scripted — every field supplied, no prompts
apiary approve wf-8a31:pick-rollout \
--field strategy=canary --field change_ticket=OPS-482
Off a terminal, a missing required field is an error rather than a prompt, so an unattended approval fails fast instead of hanging on stdin.
A rejection never collects fields. Refusing a change should not require
filling in its change ticket; only --comment is recorded, as
memory.approval_feedback.
Multi-party gates
Naming approvers changes the gate's character: source signals are ignored
(source comments carry no verified author), and responses are authorized against
the list.
- id: authorize
type: approval
message: Approve the production release.
approvers: [alice, carol]
required_approvals: 2
delegates:
alice: [bob]
remind_after: 2h
escalate_after: 8h
escalate_to: [release-managers]
timeout: 24h
A rejection ends the gate immediately; approvals wait until
required_approvals distinct approver slots respond. A delegate answers the slot
named by for_approver, so a delegate and their principal cannot both count
toward the quorum.
settings.approvals.require_for is a validation-time policy: a step carrying a
listed action_class must directly follow or depend on an approval step, and an
unsafe configuration is rejected before execution.
Signed webhooks
For approvals arriving from another system, the daemon socket exposes:
GET /approvals?status=pendingPOST /approvals/<request-id>/respond— the local dashboard/CLI channelPOST /approvals/<request-id>/webhook— signed integrations
{
"decision": "approve",
"actor": "bob",
"for_approver": "alice",
"idempotency_key": "provider-delivery-0192",
"feedback": "Proceed during the scheduled window.",
"values": {
"change_ticket": "OPS-482",
"maintenance_window": "scheduled"
}
}
Sign the exact request body with HMAC-SHA256 using webhook_secret and send the
hex digest as X-Apiary-Signature: sha256=<digest>. Apiary rejects missing or
invalid signatures, unknown actors, invalid delegation, missing required fields,
and invalid field types/options.
Who answered
Every response records an actor — $USER from the dashboard and the CLI — and it
appears in the execution timeline.
On the local channel this is provenance, not authentication: apiary runs on
your machine, and anyone who can reach the daemon socket can already control the
daemon. The actor is checked against a list only when the step declares
approvers. Cross a trust boundary with the signed webhook, and put the socket
behind an authenticated proxy with TLS and request-size/rate limits before
exposing it.
Idempotency, recovery, and audit
Every provider delivery needs a globally unique idempotency_key; the CLI and
dashboard derive a stable one, so a retried command records an answer once rather
than twice. Responses are unique per request and approver slot, and SQLite
transactions ensure concurrent channels cannot count the same approver twice or
advance the workflow past its quorum more than once.
A response is durable the moment it is persisted. The workflow advance that follows runs in the background — a gate followed by a long agent step returns to the caller immediately rather than holding the connection open — and a response persisted immediately before a crash is applied once the parked workflow is rehydrated.
A gate inside a rework loop
A gate can sit inside a loop — on_fail.goto or on_reject.restart_from pointing
at a step before the gate — and the workflow will re-enter it on every lap. Each
visit opens its own request, so every lap is answerable on its own; answering lap 1
never closes lap 2.
The request id carries the lap. The first visit is <instance>:<step>, and each
later one appends its attempt:
$ apiary approvals
REQUEST WORKFLOW STEP PARKED EXPIRES
wf-8a31:pre-review@3 jira-implement pre-review 4m in 23h
Answer the id apiary approvals prints, not one remembered from an earlier lap —
an id whose lap is already resolved is refused with already approved. The
earlier laps' decisions stay in the store as their own rows, so the audit trail
shows every answer the gate collected rather than only the last one.
A timeout, remind_after, and escalate_after are all measured from the current
lap's park, not from the first one.
The execution timeline records approval.requested, approval.reminder,
approval.escalated, approval.granted, approval.rejected, and
approval.timed_out. Actor, channel, feedback, form values, request identity, and
escalation targets are retained in redacted event metadata.