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. - Shared within the project. Every authenticated user can read and list every request; only listed approvers can decide.
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, each reproduced live: 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",
"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, both verified live: 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.
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}'Guardrails, each reproduced live:
- A subject not listed in
approversgets403 approver_not_assigned- even the request's creator. - 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 the requester's "I saw the outcome" receipt:
snug hitl request acknowledge -i <request_id>Acknowledging before any decision is 400 invalid_request, and
acknowledging twice is 409 request_already_acknowledged. Verified live:
acknowledgement is not restricted to the creator - any authenticated user
in the project can acknowledge a decided request (the CLI help's "must be
the creator" is aspirational).
Expiry
Both clocks were exercised live with 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 supports status filtering, full-text search over title and
description, the shared
field:operator:value grammar, sorting,
and pagination:
snug hitl request list --status pending --page-size 20
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-by created_at --sort-order ascLimits 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 in the
HITL CONFIG reference.
Reference
- Human-in-the-Loop API - every endpoint, callable
- Related: FSM for machine-driven state machines, Inbox for notifying humans that work awaits them