Human-in-the-Loop
A human approval gateway for automated workflows: an agent or service files a request (approve/reject, pick an option, or provide input), the assigned human approvers decide before a deadline, and the requester polls the outcome and acknowledges it - with multi-approver consensus, one vote per approver, and a recorded decision trail.
When to reach for it: an AI agent asking permission before a destructive action, deploy gates, refund or expense sign-off above a threshold, content takedown review - any place automation must pause for a human.
When not to: machine-driven state transitions with guards and timeouts belong to FSM; actively notifying humans belongs to Inbox - HITL stores the work item itself and approvers poll for it.
Concepts
- Two sides. The machine side creates requests and acknowledges
outcomes (
snug hitl request ...); the human side lists and answers pending work (snug hitl approval ...). The creator is recorded ascreated_by; approvers are the subject IDs listed inapprovers. - Three kinds.
approve_reject(binary),multiple_choice(pick one of the declaredoptions), andinput(return a JSON value). The decision payload must match the kind or the server answers400. - Votes and consensus. Each approver votes at most once (a second vote
is
409 already_voted). The request is approved oncerequired_approversnon-rejection votes arrive; a single rejection short-circuits the whole request torejected. Thresholds above 1 are only allowed for theapprove_rejectkind. - Two clocks.
expires_atis the hard lifetime (expires_in_seconds, default 24 h).decision_deadline(decision_timeout_seconds, default 2 h, clamped to never exceedexpires_at) is softer: it is enforced when a decision arrives, not in the background. - Lifecycle.
pendingbecomesapproved,rejected, orexpired; a decided request can then beacknowledgedexactly once. Note thatmultiple_choiceandinputrequests also terminate asapproved- the chosen option or submitted value lives in the decision record. - Audience and assignment. Requests default to private in the personal
workspace: the creator and assigned approvers can read them. Choose
--workspace-id <id> --visibility workspaceto share with workspace readers, or grant Reader access to a person or group through Access. Sharing never makes someone an approver. Only the immutable assigned approvers can vote; disabled principals and other tenants cannot read or act.
Create a request (machine side)
snug hitl request create --title "Deploy v2 to production" \
--approver alice --expires-in-seconds 600
snug hitl request create --title "Pick a region" -k multiple_choice \
--approver ops -o us-east:"US East" -o eu-west:"EU West"
snug hitl request create --title "Provide config" -k input --approver ops
snug hitl request get -i <request_id> # poll for the outcomeCreation is validated up front: no approvers,
required_approvers exceeding the approver count, required_approvers > 1
on a non-approve_reject kind, or a multiple_choice request without
options all fail with 400 invalid_request.
Over HTTP the same create is a POST, and the response arrives in the
standard envelope (trimmed):
curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
-H "Content-Type: application/json" -H "Idempotency-Key: deploy-20260828" \
-d '{"title":"Deploy v2 to production","kind":"approve_reject",
"approvers":["alice"],"expires_in_seconds":600}' \
http://localhost:4000/api/v1/hitl/requests{
"status": 201,
"msg": "Created",
"data": {
"request_id": "QUefWbLCECUf",
"workspace_id": "root",
"visibility": "private",
"title": "Deploy v2 to production",
"kind": "approve_reject",
"approvers": ["alice"],
"required_approvers": 1,
"tally": { "required_approvers": 1, "approvals_received": 0,
"rejections_received": 0, "remaining_approvers": ["alice"] },
"status": "pending",
"created_by": "docs-wave",
"expires_at": "2026-08-28T06:41:12.470265Z",
"decision_deadline": "2026-08-28T06:41:12.470265Z",
"created_at": "2026-08-28T06:31:12.470265Z"
}
}Retries are safe two ways: repeating the POST with
the same Idempotency-Key header returns the original request (same
request_id) instead of creating a twin, and a caller-supplied
--request-id acts as a uniqueness anchor - reusing one is
409 request_already_exists.
Share the audience without changing approvers
A private request remains visible to its creator and assigned approvers. Share a request with a person or Access group using a Reader grant:
snug access grant --target hitl-requests --id REQUEST \
--subject-kind user --subject-id USER --role reader
snug access revoke --target hitl-requests --id REQUEST \
--subject-kind user --subject-id USER --role readerThe same grant API is POST /api/v1/access/hitl-requests/{id}/grants with
{"subject":{"kind":"user","id":"USER"},"role":"reader"}; use DELETE to
revoke it. Manager grants delegate sharing administration. Editor, Reviewer and
Creator are not HITL grant roles: they cannot add approvers or change votes.
Removing a grant removes that access path; someone who is also assigned or has
workspace access retains that independent path.
To create a request for the readers of a named workspace:
snug hitl request create --title "Review HR policy" --approver alice \
--workspace-id WORKSPACE --visibility workspaceThe caller needs workspace creation access. Service accounts use an explicitly scoped credential and workspace Creator grant; assigning a service account to an approval still does not grant it access to unrelated resources or the underlying Form data. Reads, list totals, pending queues and profile includes all enforce the same tenant and audience boundary.
Decide (human side)
Approvers discover their queue and answer it - the decision flag must match the request kind:
snug hitl approval list
snug hitl approval respond -i <request_id> --approve -r "looks good"
snug hitl approval respond -i <request_id> --reject -r "too risky"
snug hitl approval respond -i <request_id> --select-option us-east
snug hitl approval respond -i <request_id> --input '{"port":8080}'The queue is a list with the same filters,
sorts, and search as hitl request list, narrowed to pending, unexpired
requests the caller may decide; snug hitl approval list --sort expires_at
puts the most urgent first.
Guardrails:
- A subject not listed in
approversgets403 approver_not_assignedif they can read the request — even its creator. A caller outside the audience receives404without disclosing the request. - Selecting an undeclared option is
400 invalid_request, as is--input 'null'(input requests require a non-null value). - Voting on an already-decided request is
409 decision_already_recorded.
snug hitl request decision (POST /api/v1/hitl/requests/{id}/decision)
is an equivalent route; both record the same vote.
Multi-approver consensus
With --required-approvers 2 and two approvers, the first approval leaves
the request pending with a live tally, captured live:
"tally": { "required_approvers": 2, "approvals_received": 1,
"rejections_received": 0, "remaining_approvers": ["bob"] }The second approval flips it to approved and stamps the final decision
record. A single rejection ends the request immediately as rejected -
remaining approvers are not consulted.
Acknowledge
Acknowledgement is a cooperative "I saw the outcome" receipt for any current reader:
snug hitl request acknowledge -i <request_id>Acknowledging before any decision is 400 invalid_request, and
acknowledging twice is 409 request_already_acknowledged. Acknowledgement is not restricted to the creator - any authenticated user
with permission to read the request can acknowledge a decided request.
Private requests remain hidden from other organization members, including
directory administrators. Workspace membership and grants are checked live.
Expiry
Both clocks take short timeouts:
- Past
expires_at, a read flips the request toexpired, it disappears from every approver's pending list, and a decision attempt is400 request_expired. A background sweep (every 60 s by default) also expires overdue requests, so they expire even if nobody looks. - Past
decision_deadline(but beforeexpires_at), the request still reads aspending- the deadline is enforced only when a decision arrives, which is refused with400 request_expiredand flips the status toexpired.
Finding requests
list speaks the standard list contract:
full-text q over title and description; filters on status, kind,
approvers, created_by, created_at, expires_at, and
decision_deadline; and sorts on created_at (newest first by default),
expires_at, and decision_deadline:
snug hitl request list --filter 'status[eq]=pending' --limit 20 # then --cursor <page.next_cursor>, or --all
snug hitl request list -q "budget"
snug hitl request list --filter 'kind[eq]=input' --filter 'created_by[eq]=docs-wave'
snug hitl request list --sort created_atLimits and configuration
Default request TTL (24 h), default decision timeout (2 h), the pending cap
per approver (1000 - creation fails with 429 approver_pending_limit
beyond it), idempotency-key retention, and the expiry sweep worker are all
tunable via the HITL_* variables.
Reference
- Human-in-the-Loop API - every endpoint, callable
- Related: FSM for machine-driven state machines, Inbox for notifying humans that work awaits them
