Building an Approval Gate Before Irreversible Actions
An approval gate is a host-enforced pause that runs before a high-stakes tool executes, collects a human decision, and only then performs the side effect.
Irreversible here means hard or expensive to undo: payments, deletes, external messages, production deploys, permission changes.
Summary
Classify tools by blast radius, intercept gated calls in the tool dispatcher, persist a decision packet, block until approve/reject/edit, execute idempotently, and log the full chain.
Recipe
- Inventory tools and mark which are irreversible or externally visible.
- Implement a single dispatcher that all tool calls pass through (no bypass paths).
- On a gated call, freeze run state and write a durable
PendingActionrecord. - Render a decision packet: who/what/why, args, risk, links to context.
- Accept
approve,reject, oreditfrom an authenticated approver. - Re-validate args after edit; execute exactly once with an idempotency key.
- Resume the agent with a structured observation (
approved,rejected,executed). - Emit audit events for request, decision, and execution outcome.
Working Example
import hashlib
import json
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable
from uuid import uuid4
class Decision(str, Enum):
APPROVE = "approve"
REJECT = "reject"
EDIT = "edit"
IRREVERSIBLE = {"send_email", "issue_refund", "delete_record"}
@dataclass
class PendingAction:
id: str
run_id: str
tool: str
args: dict[str, Any]
created_at: float = field(default_factory=time.time)
status: str = "awaiting_approval"
idempotency_key: str = ""
STORE: dict[str, PendingAction] = {}
EXECUTED_KEYS: set[str] = set()
def idem_key(tool: str, args: dict[str, Any]) -> str:
raw = json.dumps({"tool": tool, "args": args}, sort_keys=True)
return hashlib.sha256(raw.encode()).hexdigest()[:24]
def execute_tool(tool: str, args: dict[str, Any]) -> dict[str, Any]:
# Replace with real integrations
return {"tool": tool, "args": args, "provider_status": "ok"}
def request_tool(run_id: str, tool: str, args: dict[str, Any]) -> dict[str, Any]:
if tool not in IRREVERSIBLE:
return {"status": "executed", "result": execute_tool(tool, args)}
key = idem_key(tool, args)
pending = PendingAction(
id=str(uuid4()),
run_id=run_id,
tool=tool,
args=args,
idempotency_key=key,
)
STORE[pending.id] = pending
return {
"status": "awaiting_approval",
"pending_id": pending.id,
"packet": {
"tool": tool,
"args": args,
"risk": "irreversible",
"choices": [d.value for d in Decision],
},
}
def resolve_pending(
pending_id: str,
decision: Decision,
actor: str,
edited_args: dict[str, Any] | None = None,
) -> dict[str, Any]:
pending = STORE[pending_id]
if pending.status != "awaiting_approval":
return {"status": "error", "error": "not_awaiting_approval"}
if decision is Decision.REJECT:
pending.status = "rejected"
return {"status": "rejected", "actor": actor, "pending_id": pending_id}
args = edited_args if decision is Decision.EDIT else pending.args
if decision is Decision.EDIT and edited_args is None:
return {"status": "error", "error": "edit_requires_args"}
key = idem_key(pending.tool, args)
if key in EXECUTED_KEYS:
pending.status = "executed"
return {"status": "already_executed", "idempotency_key": key}
result = execute_tool(pending.tool, args)
EXECUTED_KEYS.add(key)
pending.status = "executed"
pending.args = args
return {
"status": "executed",
"actor": actor,
"decision": decision.value,
"result": result,
"idempotency_key": key,
}
# Demo
req = request_tool("run-1", "send_email", {"to": "a@example.com", "body": "Ship it"})
print(req)
print(resolve_pending(req["pending_id"], Decision.APPROVE, actor="ops@example.com"))Deep Dive
Where the gate must live
Put the gate in the tool execution layer, the last code path before side effects.
If only the prompt or the planner "remembers" to ask, a single bad tool call path bypasses policy.
Framework hooks that help (verify APIs at build):
| Stack | Typical mechanism |
|---|---|
| LangGraph | interrupt / human node before privileged tools |
| Vercel AI SDK 6 | tool-approval states on the v3 language model spec |
| OpenAI Agents SDK | approval hooks / human handoff patterns |
| Custom runtime | dispatcher wrapper as above |
Use the framework pause primitive, but keep your irreversible set and packet schema as product code.
What belongs in the decision packet
Minimum fields:
pending_id,run_id, timestamp- tool name and human-readable action title
- args that will execute (after redaction of secrets)
- risk tags (money, external, delete, prod)
- model rationale snippet if available (not sufficient alone)
- deep link to traces / ticket
- approver choices and timeout policy
Hide API keys and raw credentials. Show destination accounts and dollar amounts clearly.
Timeouts and stale gates
Define what happens if no human acts:
- Fail closed (recommended for irreversible): mark rejected or expired; do not execute.
- Escalate: route to a secondary queue after N minutes.
- Never auto-approve high-blast actions on timeout.
Document the timeout in the packet so operators know the clock.
Idempotency and double submit
Humans double-click. Webhooks retry. Agents resume twice after a deploy.
Hash tool+args (and business ids like refund_id) into an idempotency key and store successful executions before returning success to the UI.
Edits and re-validation
When the human edits args, re-run the same validators the tool would use: schema, allowlisted domains, max amount, environment checks.
An edit that raises amount above policy may need a higher privilege approver.
UX that preserves attention
- Batch only similar low-medium items; never batch mixed refunds with deletes.
- Prefer one clear primary action (approve) with explicit reject/edit.
- Show diffs when the model revised a previous draft.
Gotchas
- Bypass paths. Admin scripts, notebooks, or secondary agents that call providers directly skip the gate.
- Approving the narrative, not the args. Humans rubber-stamp titles; always show final args.
- Auto-approve on timeout. Quietly turns gates into delays, not controls.
- Missing idempotency. Double execution is the classic HITL bug.
- Secrets in packets. Logging full tool args can leak tokens into Slack or email.
- Gate after the effect. "Notify me after send" is not an approval gate.
- One global queue for all risk. Critical refunds drown under draft-noise if rings are wrong.
Alternatives
| Approach | Best for | Trade-off |
|---|---|---|
| Hard gate (this recipe) | Irreversible / external actions | Latency; needs operators |
| Soft notify + undo window | Reversible internal writes | Undo must actually work |
| Pre-approved plan envelope | Known batch jobs | Weak for novel args |
| Dual control | High-value finance / prod | More ops overhead |
| Simulation / dry-run first | Deploys, migrations | Extra engineering |
FAQs
Should every tool call go through the gate?
No. Gate irreversible and high-blast tools. Auto-run pure reads and safe drafts so humans stay attentive for real risk.
Is a Slack emoji reaction a valid approval?
Only if you bind it to a pending_id, authenticate the actor, and prevent spoofing. Free-text "lgtm" in a channel is not an audit-grade decision.
How do we test gates in CI?
Unit-test dispatcher branching, idempotency, reject paths, and edit validation with fake tools. Add one integration test that proves a gated tool cannot execute without a decision record.
What if the model requests the same send after reject?
Return a clear observation that the action was rejected and optionally block identical signatures for the rest of the run.
Can the original user self-approve?
For personal agents, often yes. For enterprise money movement or production changes, require a separate role.
How does this relate to privilege rings?
Rings decide which tools need a gate. The gate is the mechanism that implements higher rings. See Privilege Rings.
Where should state live during the pause?
In durable storage keyed by run_id / pending_id. In-memory maps are fine for demos only.
Do we still need evals if humans approve everything?
Yes. Humans miss things under load. Evals and policy checks catch bad drafts before they hit the queue.
What is a good default timeout?
Match business hours and risk: minutes for chatops, hours for finance queues. Always fail closed for irreversible tools when the clock runs out.
How do multi-agent systems share a gate?
Centralize the dispatcher. Specialists propose; one policy service owns pause/approve/execute so rings stay consistent.
Related
- Human-in-the-Loop Basics - first pause/resume patterns
- Privilege Rings: Scoping What an Agent Can Do Without Asking - what to gate
- Kill Switches: Stopping a Runaway Agent Mid-Execution - stop mid-run
- Audit Trails: Logging Every Agent Action for Later Review - decision evidence
- Designing Escalation Paths When an Agent Gets Stuck - uncertainty routing
- Human-in-the-Loop Best Practices - checklist
Stack versions: Pins from the category manifest (verify at build): OpenRouter (~315+ models, July 2026 pricing/fees); LangGraph 1.0+; CrewAI 1.14+; Microsoft Agent Framework 1.0; Vercel AI SDK 6; Pydantic AI (latest); LlamaIndex (latest); OpenAI Agents SDK (latest + MCP); MCP (Linux Foundation governance); A2A (HTTP+SSE+JSON-RPC 2.0); Solana
@solana/web3.js+@solana/spl-token.