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.
Search across all documentation pages
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.
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.
PendingAction record.approve, reject, or edit from an authenticated approver.approved, rejected, executed).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"))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.
Minimum fields:
pending_id, run_id, timestampHide API keys and raw credentials. Show destination accounts and dollar amounts clearly.
Define what happens if no human acts:
Document the timeout in the packet so operators know the clock.
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.
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.
| 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 |
No. Gate irreversible and high-blast tools. Auto-run pure reads and safe drafts so humans stay attentive for real risk.
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.
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.
Return a clear observation that the action was rejected and optionally block identical signatures for the rest of the run.
For personal agents, often yes. For enterprise money movement or production changes, require a separate role.
Rings decide which tools need a gate. The gate is the mechanism that implements higher rings. See Privilege Rings.
In durable storage keyed by run_id / pending_id. In-memory maps are fine for demos only.
Yes. Humans miss things under load. Evals and policy checks catch bad drafts before they hit the queue.
Match business hours and risk: minutes for chatops, hours for finance queues. Always fail closed for irreversible tools when the clock runs out.
Centralize the dispatcher. Specialists propose; one policy service owns pause/approve/execute so rings stay consistent.
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.
Reviewed by Chris St. John·Last updated Jul 16, 2026