Human-in-the-loop approvals for agent actions people actually read
For developers building AI agents that can call tools, mutate data, or trigger external side effects, this guide shows how to design approval controls that humans will actually review instead of blindly clicking through. It covers where approvals sit in the execution path, how to structure approval payloads, what to log, and the trade-offs between safety, latency, and operator fatigue.
TL;DR — If your agent can send money, delete data, change production state, or contact external parties, put an approval gate on the action boundary, not on the model output. The most effective fix is to present a short, deterministic diff of the exact side effect to be executed, with risk labels, idempotency key, and a default-deny timeout; long natural-language justifications get skimmed and rubber-stamped. Reading time: ~7 min
What it is and where it sits
Human-in-the-loop approval is a control plane around agent actions with external consequences. In practice, it is a policy check plus a pause point between the agent deciding to use a tool and the tool actually executing.
What talks to it:
- the agent runtime or orchestrator
- a policy engine or rules layer
- the approval service/UI used by reviewers
- the tool executor that performs the real side effect
- your audit log / event store
What it replaces:
- blind trust in model output
- ad hoc Slack messages like "hey, can someone sanity-check this?"
- broad allowlists such as "the agent may call the CRM API" without per-action review
Where it lives in a typical flow:
- after planning/tool selection
- before the irreversible side effect
- alongside authz, not instead of authz
The important architecture point: approvals should wrap the executable action payload, not the model's prose explanation. Reviewers need to approve "PATCH customer 481 set credit_limit=25000" or "DELETE 183 rows matching invoice_id=..." — not "the agent believes this is safe."
View as text
[User/App] ---- 1. request ----> [Agent Runtime] [Agent Runtime] ---- 2. propose tool call ----> [Policy Check] [Policy Check] ---- 3. needs approval ----> [Approval Queue] [Reviewer UI] | v [Approval Queue] ---- 4. approve/reject ----> [Tool Executor] [Tool Executor] ---- 5. side effect ----> [DB/API/Email] [Tool Executor] ---- 6. result ----> [Agent Runtime] [Policy Check] ---- 7. audit event ----> [Audit Log] [Approval Queue] ---- 8. audit event ----> [Audit Log]
In a normal web app request/data flow, this often sits in the same service boundary as your job runner or workflow engine. The API request enters your app, the app asks the agent to plan, the agent emits a structured tool call, policy decides whether the call is auto-allowed or queued, and only then does the executor talk to Postgres, Stripe, SMTP, Kubernetes, or your internal admin APIs.
How it actually works
Use one end-to-end example: an internal support agent wants to issue a refund and send an apology email.
Assume the agent can call two tools:
issue_refund(order_id, amount_cents, reason)send_email(to, template_id, variables)
Policy says:
- refunds over
5000cents require approval - any email to an external address requires approval if generated by the model
- approvals expire after 15 minutes
- default action on timeout is deny
Step 1: agent emits a structured action proposal
Do not send a paragraph to the reviewer. Serialize the exact call the executor would run.
{
"request_id": "req_01JY7V3Q6M8K8Y6Y8F0Q1P9R2A",
"action_id": "act_01JY7V4K9D9Q2D4QY7T6J5N3PW",
"tool": "issue_refund",
"args": {
"order_id": "ord_12841",
"amount_cents": 12999,
"reason": "duplicate shipment"
},
"actor": {
"type": "agent",
"agent_id": "support-agent-v3"
},
"subject": {
"customer_id": "cus_481",
"ticket_id": "t_90051"
},
"risk": {
"class": "financial",
"score": 0.82,
"triggers": ["refund_amount_gt_threshold"]
},
"idempotency_key": "refund:ord_12841:12999:duplicate-shipment",
"dry_run_preview": {
"provider": "payments-api",
"expected_effect": "Create refund for charge ch_9911 amount 129.99 USD"
},
"created_at": "2026-10-30T12:00:00Z",
"expires_at": "2026-10-30T12:15:00Z"
}
This is the object the reviewer sees, maybe rendered as a compact card. The key fields are the exact args, the expected effect, and the idempotency key. The model's chain-of-thought is not needed and should not be shown.
Step 2: policy decides whether to queue approval
A simple rules engine is enough for many teams. The decision should be deterministic and logged.
{
"decision": "require_approval",
"policy_version": "2026-10-15.3",
"matched_rules": [
"refunds_over_5000_require_human",
"external_comms_model_generated_require_human"
]
}
Step 3: reviewer gets a short approval payload
A readable approval has five things, in this order:
- what will happen
- what data changes or external calls will occur
- why it was flagged
- what happens if nobody acts
- approve/reject controls
Bad approval text:
- "Agent recommends a customer remediation workflow based on context."
Good approval text:
- "Refund $129.99 to order ord_12841 via payments-api. Then send template
refund_apology_v2toalice@example.com. Timeout in 15m; if no action, request is denied."
Step 4: approval creates a signed execution token
Do not let the UI call the tool directly with arbitrary parameters. On approval, mint a narrow execution token or signed record binding:
action_id- approved args hash
- approver identity
- expiry
- policy version
Then the executor verifies that the token matches the exact action payload before execution.
Step 5: executor runs with idempotency and logs the result
The executor should reject drift. If the action payload changed after approval, fail closed.
Realistic log shape:
2026-10-30T12:03:14Z approval accepted action_id=act_01JY7V4K9D9Q2D4QY7T6J5N3PW approver=sre-27 args_sha256=4d8d... policy=2026-10-15.3
2026-10-30T12:03:14Z executor start tool=issue_refund idempotency_key=refund:ord_12841:12999:duplicate-shipment
2026-10-30T12:03:15Z payments-api 201 Created refund_id=rf_7712
2026-10-30T12:03:15Z executor complete action_id=act_01JY7V4K9D9Q2D4QY7T6J5N3PW status=succeeded
And if something drifted:
2026-10-30T12:03:14Z executor reject action_id=act_01JY7V4K9D9Q2D4QY7T6J5N3PW error=approval_payload_mismatch
The agent then gets a tool result back, not a privileged bypass. That result can be summarized to the user, but the side effect is already constrained by the approved payload.
When to use it (and when not to)
Use approvals for actions with irreversible or high-blast-radius consequences. Do not use them as a comfort blanket on every tool call; that just trains reviewers to click approve without reading.
| Scenario | Recommendation |
|---|---|
| Agent drafts text, no external send, human copies it manually | Usually no approval service needed; standard app permissions are enough |
| Agent reads internal docs or runs read-only SQL against a replica | Prefer read-only credentials and query limits over human approval |
| Agent can delete records, write to prod DB, rotate secrets, or change infra | Add approval gate with exact diff/command preview |
| Agent sends emails, messages customers, or posts externally | Approval usually warranted unless content is from locked templates with fixed recipients |
| Agent triggers payments, refunds, discounts, credits | Approval for threshold breaches; auto-allow low-value actions with rate limits |
| Agent executes high-volume repetitive actions | Batch approvals or sampled review; per-item approval will collapse operationally |
| Agent is used by a single expert operator in a low-risk internal tool | You probably don't need full HITL; use role-based access, dry-run, and undo instead |
You probably don't need this if:
- the tool is strictly read-only
- the action is already manually applied by a human in another system
- the side effect is fully reversible and low cost
- the real problem is missing least-privilege credentials, not missing approvals
Trade-offs
Every benefit here costs something.
- Better containment of dangerous actions
- Cost: latency. A 200 ms tool call becomes a minutes-long workflow if a human must respond.
- Better auditability
- Cost: storage and process overhead. You now need immutable logs, retention rules, and reviewer identity binding.
- Reduced chance of model hallucinations causing damage
- Cost: reviewer fatigue. If half your queue is obvious approvals, humans stop reading.
- More confidence for regulators, security, and customers
- Cost: implementation complexity. You need policy evaluation, approval UI/API, token binding, expiry handling, and replay protection.
- Fine-grained control over high-risk actions
- Cost: possible lock-in to your workflow engine or policy framework if you over-couple the design.
The main failure mode is not "approvals are bypassed." It is "approvals exist but are unreadable, so they become ceremonial." Short payloads, deterministic diffs, and low queue volume matter more than fancy reviewer UX.
In practice
Example 1: policy rule and approval-required response
version: "2026-10-15.3"
rules:
- id: refunds_over_5000_require_human
match:
tool: issue_refund
expr: args.amount_cents > 5000
effect: require_approval
timeout_seconds: 900
on_timeout: deny
- id: external_email_requires_human
match:
tool: send_email
expr: recipient.domain not in ["example.internal"]
effect: require_approval
timeout_seconds: 900
on_timeout: deny
- id: readonly_search_autoallow
match:
tool: search_kb
effect: allow
This is the minimum useful policy surface: match on tool plus arguments, then return allow or require_approval. Gotcha: keep the expression language small; once policy can call arbitrary code, debugging and change review get ugly fast.
{
"status": "pending_approval",
"action_id": "act_01JY7V4K9D9Q2D4QY7T6J5N3PW",
"summary": "Refund $129.99 for order ord_12841",
"review_fields": {
"tool": "issue_refund",
"args": {
"order_id": "ord_12841",
"amount_cents": 12999,
"reason": "duplicate shipment"
},
"expected_effect": "Create refund via payments-api",
"risk_triggers": ["refund_amount_gt_threshold"],
"expires_at": "2026-10-30T12:15:00Z"
}
}
This is the response your app can render or push into a queue. Gotcha: include machine-readable fields, not just a summary string, so you can build consistent reviewer views and audits.
Example 2: executor verification before side effect
⚠️ If you skip payload verification and only check that an approval exists, a bug or malicious intermediary can swap arguments after approval and still execute. That turns HITL into theater.
import hashlib
import json
from datetime import datetime, timezone
class ApprovalError(Exception):
pass
def canonical_args(args: dict) -> bytes:
return json.dumps(args, sort_keys=True, separators=(",", ":")).encode()
def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def verify_and_execute(action: dict, approval: dict, payments_client):
now = datetime.now(timezone.utc)
if approval["action_id"] != action["action_id"]:
raise ApprovalError("approval_action_id_mismatch")
if now.isoformat() > approval["expires_at"]:
raise ApprovalError("approval_expired")
actual_hash = sha256_hex(canonical_args(action["args"]))
if actual_hash != approval["args_sha256"]:
raise ApprovalError("approval_payload_mismatch")
if action["tool"] != "issue_refund":
raise ApprovalError("unexpected_tool")
return payments_client.create_refund(
order_id=action["args"]["order_id"],
amount_cents=action["args"]["amount_cents"],
reason=action["args"]["reason"],
idempotency_key=action["idempotency_key"],
)
This binds approval to the exact arguments and uses an idempotency key at execution time. Gotcha: canonicalize JSON before hashing; otherwise harmless key-order changes will cause false mismatches.
Example 3: operational diagnosis with queue metrics
curl -s http://approval-service.internal/metrics | grep '^approval_'
approval_requests_total{decision="require_approval"} 1842
approval_requests_total{decision="allow"} 91221
approval_queue_depth 37
approval_timeouts_total 118
approval_median_seconds 46
approval_p95_seconds 611
approval_rejections_total 73
If approval_queue_depth and approval_p95_seconds climb, reviewers are overloaded and will stop reading. Gotcha: the fix is usually reducing approval volume with better thresholds and batching, not adding more explanatory text.
Further reading
- NIST AI Risk Management Framework
- OWASP Top 10 for Large Language Model Applications
- The "Authorization" and "Least Privilege" guidance in the OWASP Developer Guide
- RFC 9110 HTTP Semantics
- Designing Data-Intensive Applications
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI