ServicesHuman-in-the-Loop

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 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.
  • 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 outcome

Creation 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 approvers gets 403 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 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 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 asc

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

On this page