Skip to content

Human-in-the-Loop

Try the interactive example ↓

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 as created_by; approvers are the subject IDs listed in approvers.
  • Three kinds. approve_reject (binary), multiple_choice (pick one of the declared options), and input (return a JSON value). The decision payload must match the kind or the server answers 400.
  • Votes and consensus. Each approver votes at most once (a second vote is 409 already_voted). The request is approved once required_approvers non-rejection votes arrive; a single rejection short-circuits the whole request to rejected. Thresholds above 1 are only allowed for the approve_reject kind.
  • Two clocks. expires_at is the hard lifetime (expires_in_seconds, default 24 h). decision_deadline (decision_timeout_seconds, default 2 h, clamped to never exceed expires_at) is softer: it is enforced when a decision arrives, not in the background.
  • Lifecycle. pending becomes approved, rejected, or expired; a decided request can then be acknowledged exactly once. Note that multiple_choice and input requests also terminate as approved - 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 workspace to 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 outcome

Creation 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 reader

The 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 workspace

The 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 approvers gets 403 approver_not_assigned if they can read the request — even its creator. A caller outside the audience receives 404 without 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 to expired, it disappears from every approver's pending list, and a decision attempt is 400 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 before expires_at), the request still reads as pending - the deadline is enforced only when a decision arrives, which is refused with 400 request_expired and flips the status to expired.

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_at

Limits 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

Interactive example

Pause work until the right people approve it. Named approvers, deadlines, and rejection rules keep the decision explicit.

An illustrative example that runs in your browser. No account or API calls required.