Contents · 17 sections↓
- 01What should happen when nobody approves an AI agent's action?
- 02What does "fail closed" mean for an approval gate?
- 03Why should consent never be cached?
- 04How do you bind an approval to the exact call?
- 05When should an approval escalate instead of waiting longer?
- 06What you need
- 07Step 1: Deny when the timeout runs out
- 08Step 2: Escalate to a backup reviewer, then deny
- 09Step 3: Bind each approval to one call
- 10Step 4: Enforce the same rule on the MCP server
- 11How long should an approval timeout be?
- 12Approval settings to check in any framework
- 13Running approval timeouts in production
- 14Common problems and fixes
- 15Limitations
- 16FAQ
- 17Next steps
An AI agent approval timeout decides what happens when a person is asked to approve a high-risk tool call and nobody answers. The safe answer is to fail closed: the call is denied, the agent is told why, and nothing runs on silence. This guide explains default deny, why consent should never be cached, how to bind an approval to the exact call so it can't be replayed, and how to escalate before the deadline. Each step runs on Promptise Foundry with real output.
What should happen when nobody approves an AI agent's action?
The call should be denied. A timeout is not a yes: if the reviewer is asleep, the channel is down or the request got lost, the agent must not take the action. Treat every way the approval can fail (timeout, an error in the approval service, a full queue, a lost process) as a denial, and record which one it was. If waiting is costly, escalate to a second reviewer before the deadline instead of auto-approving after it.
Default deny. Silence, errors and missing reviewers all mean "no". Allowing on timeout turns the control off exactly when nobody is watching.
One approval, one call. Ask for every gated call, never remember a yes for later calls, and bind each decision to the request it answers.
Bind and expire. The reviewer approves a specific tool with specific arguments, inside a time window. A changed argument or a late replay must not pass.
Escalate inside the deadline. A backup reviewer, then a denial. Never a silent approval.
Tell the model clearly. The agent gets a denial it can explain to the user, and the gate stops asking after repeated denials.
[02]
What does "fail closed" mean for an approval gate?
An approval gate sits between the model's tool call and the tool. It sends a request to a reviewer and waits. "Fail closed" means every outcome except an explicit approval ends in a denial.
The usual failure cases:
What happened | Fail-closed result |
|---|---|
Reviewer approved this call | The tool runs |
Reviewer denied | Denied, with the reviewer's reason |
No answer before the timeout | Denied, reason "timed out" |
Approval service error or unreachable | Denied, reason "handler error" |
Too many requests already waiting | Denied without asking |
The agent process restarted while waiting | The request is gone; the call never ran |
Answer arrives after the timeout | Discarded; the agent already got its denial |
OWASP's entry on excessive agency (LLM06:2025) recommends requiring a human to approve high-impact actions before they are taken, and enforcing authorization in downstream systems rather than relying on the model. Its Top 10 for Agentic Applications adds Human-Agent Trust Exploitation (ASI09), where convincing explanations mislead the people approving actions. A gate that approves on silence is weaker than either: it needs no one to be fooled.
[03]
Why should consent never be cached?
Caching consent means remembering "the reviewer said yes to issue_refund" and skipping the question next time. It feels efficient and it breaks the control:
The arguments change. The yes was for $40 on order A-7. The next call is $4,000 on another order.
The context changes. A prompt injection that arrives later rides on an approval given before it.
Nobody revisits it. A cached yes has no owner and no expiry, so it outlives the reason it was given.
Ask for every gated call and treat each decision as single use. If people are asked too often, the fix is a written policy that approves the routine cases on its own, with a reason recorded for each one. That is what Approval Fatigue: Auto-Approve Safe AI Agent Actions covers. A rule is reviewed, tested and versioned; a cached yes is none of those.
The same goes for denials, with one exception: after several denials of the same tool in a short window, stop asking for a while. Otherwise a model that retries turns into a stream of requests the reviewer has already refused.
[04]
How do you bind an approval to the exact call?
An approval is only safe if it can't be moved to another call. Four properties do that:
A unique request id per call, generated with a secure random source, so a decision can't be guessed or reused.
The decision resolves that id once. A second decision for the same id, or one for an id that isn't pending, is rejected.
The request is signed over what matters: the id, the tool, the arguments, the agent, the caller and the time. A reviewer service that checks the signature rejects a request whose amount was changed in transit.
The request expires. A signed request replayed an hour later fails a freshness check, and the gate itself has already timed out.
The agent runs the arguments the reviewer saw. If the reviewer edits them, the edited values are validated against the tool's schema before the tool runs, and the reviewer's change is noted in the result.
[05]
When should an approval escalate instead of waiting longer?
Long timeouts hold the agent's run open and keep the user waiting, and a request that waits for hours is often approved without much thought. A better shape is a short wait for the first reviewer, a hand-over to a backup (on call, a manager, a second team), and a denial when the last tier is silent. The total of all tiers must stay below the gate's own timeout, so the gate's deadline is the one that ends the request. Escalation adds reviewers. It never adds an approval.
[06]
What you need
Python 3.10 or later and Promptise Foundry 1.7.0.
An OpenAI API key in a .env file for the agent examples (the binding and server examples need none).
About 20 minutes.
uv venv && uv pip install "promptise==1.7.0" python-dotenv[07]
Step 1: Deny when the timeout runs out
The agent has one gated tool, issue_refund. The approval handler stands in for a reviewer who never answers. on_decision records every decision, including those the gate makes itself:
"""What happens when nobody answers an approval request: the call is denied."""
import asyncio
import json
import time
from dotenv import load_dotenv
from promptise import build_agent, tool
from promptise.approval import ApprovalPolicy
load_dotenv()
REFUNDS: list[str] = [] # stands in for the payment system
@tool
def issue_refund(order_id: str, amount: float) -> str:
"""Refund part or all of an order."""
REFUNDS.append(order_id)
return f"Refunded ${amount:.2f} on {order_id}"
async def reviewer_is_away(request):
"""The request reaches a channel, but nobody answers it."""
print(f"asked: {request.tool_name} {request.arguments} (request {request.request_id[:8]})")
await asyncio.Event().wait() # never returns
def audit(request, decision):
print("decision:", json.dumps({
"request": request.request_id[:8],
"approved": decision.approved,
"decided_by": decision.decided_by,
"reason": decision.reason,
}))
async def main():
agent = await build_agent(
model="openai:gpt-5-mini",
instructions="You are a support agent. Answer in one plain sentence.",
extra_tools=[issue_refund],
approval=ApprovalPolicy(
tools=["issue_refund"],
handler=reviewer_is_away,
timeout=5, # 300 seconds by default
on_timeout="deny", # the default, written out
on_decision=audit,
),
)
try:
start = time.monotonic()
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": "Refund $40 on order A-7."}]}
)
print(f"after {time.monotonic() - start:.0f}s")
print("agent: ", result["messages"][-1].content)
print("refunds: ", REFUNDS)
finally:
await agent.shutdown()
asyncio.run(main())Output:
asked: issue_refund {'order_id': 'A-7', 'amount': 40.0} (request f680ce5d)
decision: {"request": "f680ce5d", "approved": false, "decided_by": "gate", "reason": "Approval timed out after 5s"}
after 9s
agent: I couldn't process the $40 refund for order A-7 because the approval timed out; would you like me to retry now or escalate this to a supervisor?
refunds: []What happened:
The request reached the handler, nobody answered, and after 5 seconds the gate denied the call with decided_by: "gate" and the reason
Approval timed out after 5s. The refund list stayed empty.The model got
DENIED: Approval timed out after 5sas the tool result and told the user the refund wasn't made, offering to retry or escalate. A retry would be a new request with a new id, asked again from the start: nothing from a denied attempt carries over (Step 2 shows one).on_timeout="deny" is the default; it is written out here so a reader of the configuration sees the choice. The default timeout is 300 seconds, and the largest allowed value is 24 hours.
A timeout counts like a reviewer's denial for the retry limit: after max_retries_after_deny (3 by default) denials of the same tool in deny_window (10 minutes) for the same user and session, the gate denies without asking and tells the model not to retry. Set it to 1 for tools where one silent request is enough.
[08]
Step 2: Escalate to a backup reviewer, then deny
Any object with an async request_approval(request) method is an approval handler. This one asks a list of tiers in order, each with its own wait, and raises TimeoutError when all are silent, so the gate denies:
"""Escalate an unanswered approval to a backup reviewer, then deny."""
import asyncio
import json
from dotenv import load_dotenv
from promptise import build_agent, tool
from promptise.approval import ApprovalDecision, ApprovalPolicy
load_dotenv()
REFUNDS: list[str] = []
@tool
def issue_refund(order_id: str, amount: float) -> str:
"""Refund part or all of an order."""
REFUNDS.append(order_id)
return f"Refunded ${amount:.2f} on {order_id}"
class EscalatingApprover:
"""Ask each tier in turn; a tier that stays silent hands over to the next.
It never approves on its own: when the last tier is silent too, it raises
TimeoutError and the gate denies the call.
"""
def __init__(self, tiers):
self.tiers = tiers # [(name, async handler, seconds to wait)]
async def request_approval(self, request):
for name, ask, wait in self.tiers:
try:
decision = await asyncio.wait_for(ask(request), timeout=wait)
except asyncio.TimeoutError:
print(f" {name}: no answer in {wait}s")
continue
decision.reviewer_id = decision.reviewer_id or name
return decision
raise asyncio.TimeoutError("no reviewer answered")
async def team_queue(request):
await asyncio.Event().wait() # the team channel is quiet tonight
async def on_call(request):
if request.arguments["amount"] > 100:
await asyncio.Event().wait() # on call is busy: no answer either
await asyncio.sleep(1) # reads the request
return ApprovalDecision(approved=True, reviewer_id="on-call:dana")
def audit(request, decision):
print(" decision:", json.dumps({
"tool": request.tool_name,
"arguments": request.arguments,
"approved": decision.approved,
"decided_by": decision.decided_by,
"reviewer": decision.reviewer_id,
"reason": decision.reason,
}))
async def main():
approver = EscalatingApprover([
("team-queue", team_queue, 3),
("on-call", on_call, 4),
])
agent = await build_agent(
model="openai:gpt-5-mini",
instructions="You are a support agent. Answer in one plain sentence.",
extra_tools=[issue_refund],
approval=ApprovalPolicy(
tools=["issue_refund"],
handler=approver,
timeout=10, # longer than all tiers together (3 + 4 seconds)
on_decision=audit,
),
)
try:
for question in ["Refund $40 on order A-7.", "Refund $900 on order B-2."]:
print(f"user: {question}")
result = await agent.ainvoke({"messages": [{"role": "user", "content": question}]})
print("agent:", result["messages"][-1].content, "\n")
print("refunds:", REFUNDS)
finally:
await agent.shutdown()
asyncio.run(main())Output:
user: Refund $40 on order A-7.
team-queue: no answer in 3s
decision: {"tool": "issue_refund", "arguments": {"order_id": "A-7", "amount": 40.0}, "approved": true, "decided_by": "reviewer", "reviewer": "on-call:dana", "reason": null}
agent: Refund of $40.00 for order A-7 has been issued.
user: Refund $900 on order B-2.
team-queue: no answer in 3s
on-call: no answer in 4s
decision: {"tool": "issue_refund", "arguments": {"order_id": "B-2", "amount": 900.0}, "approved": false, "decided_by": "gate", "reviewer": null, "reason": "Approval timed out after 10s"}
team-queue: no answer in 3s
on-call: no answer in 4s
decision: {"tool": "issue_refund", "arguments": {"order_id": "B-2", "amount": 900.0}, "approved": false, "decided_by": "gate", "reviewer": null, "reason": "Approval timed out after 10s"}
agent: I attempted to refund $900 on order B-2 but the refund was denied due to an approval timeout; would you like me to retry the refund, escalate to finance, or perform another action?
refunds: ['A-7']The $40 refund waited 3 seconds on the team queue, moved to on call, and was approved by on-call:dana, which the audit line records as decided_by: "reviewer". The $900 refund found nobody in either tier and was denied by the gate. The model retried it once, and the retry went through both tiers again. The denial reason names the policy's 10-second timeout, though the last tier gave up after 7 seconds: the handler raised TimeoutError and the gate reported it as a timeout.
In production the tiers post to a chat channel, a pager or a ticket queue and wait for an answer. Keep each tier's wait short and the policy's timeout a little longer than the sum.
[09]
Step 3: Bind each approval to one call
When approvals go to another service over HTTP, WebhookApprovalHandler signs each request with HMAC-SHA256 over the request id, tool name, arguments, agent id, caller and timestamp, and sends it in the X-Promptise-Signature header. The receiving service verifies it with verify_webhook_signature. In-process, QueueApprovalHandler resolves each pending request id exactly once:
"""An approval belongs to one exact call: signed, fresh and single use."""
import asyncio
import json
import secrets
import time
from promptise.approval import (
ApprovalDecision,
ApprovalRequest,
QueueApprovalHandler,
verify_webhook_signature,
)
SECRET = secrets.token_hex(32) # shared by the agent's webhook handler and your approval service
request = ApprovalRequest(
request_id=secrets.token_hex(16),
tool_name="issue_refund",
arguments={"order_id": "A-7", "amount": 40.0},
caller_user_id="user-42",
)
body = json.dumps(request.to_dict())
signature = request.compute_hmac(SECRET)
# 1. The approval service checks the signature and the age of each request.
print("original request: ", verify_webhook_signature(body, signature, SECRET, max_age=300))
tampered = json.loads(body)
tampered["arguments"]["amount"] = 4000.0
print("amount changed: ", verify_webhook_signature(tampered, signature, SECRET, max_age=300))
replayed = ApprovalRequest(**{**request.to_dict(), "timestamp": time.time() - 3600})
print("replayed an hour late:", verify_webhook_signature(
replayed.to_dict(), replayed.compute_hmac(SECRET), SECRET, max_age=300))
# 2. A decision resolves one pending request id, once.
async def single_use():
queue = QueueApprovalHandler()
waiting = asyncio.create_task(queue.request_approval(request))
pending = await queue.request_queue.get()
queue.submit_decision(pending.request_id, ApprovalDecision(approved=True, reviewer_id="sam"))
print("first decision: ", (await waiting).approved)
for label, request_id in [("same id again", pending.request_id), ("made-up id", "0" * 32)]:
try:
queue.submit_decision(request_id, ApprovalDecision(approved=True))
except KeyError as exc:
print(f"{label + ':':22}", "KeyError", exc)
asyncio.run(single_use())Output:
original request: True
amount changed: False
replayed an hour late: False
first decision: True
same id again: KeyError "No pending approval request with id '6d2319e6a044f388eb5686210249f942'"
made-up id: KeyError "No pending approval request with id '00000000000000000000000000000000'"A request whose amount changed after signing fails, and so does a correctly signed request presented an hour later when the service checks max_age=300. A decision for an id that was already decided, or never existed, raises KeyError instead of approving anything. The webhook handler polls {url}/{request_id} for the decision, so its answer is also tied to one request; protect that poll endpoint with authentication, because the decision it returns is trusted.
[10]
Step 4: Enforce the same rule on the MCP server
An agent-side gate only governs agents that use it. When the tool lives on an MCP server called by many clients, put the gate on the server, where no client can skip it:
"""The same rule where the tool lives: an MCP server gate that denies on timeout."""
import asyncio
from promptise.mcp.server import ApprovalGateMiddleware, MCPServer
from promptise.mcp.server.testing import TestClient
server = MCPServer(name="billing")
async def nobody_answers(request):
await asyncio.Event().wait()
server.add_middleware(ApprovalGateMiddleware(nobody_answers, timeout=2)) # on_timeout="deny"
@server.tool(requires_approval=True)
async def issue_refund(order_id: str, amount: float) -> str:
"""Refund part or all of an order."""
return f"Refunded ${amount:.2f} on {order_id}"
async def main():
result = await TestClient(server).call_tool("issue_refund", {"order_id": "A-7", "amount": 40})
print(result[0].text)
asyncio.run(main())Output:
{
"error": {
"code": "APPROVAL_DENIED",
"message": "Approval for tool 'issue_refund' timed out after 2s and was denied by default",
"retryable": false,
"details": {
"approval_request_id": "b0e24f006c4411c4bfcb8a01d1a416d1"
}
}
}ApprovalGateMiddleware denies on timeout by default, marks the error as not retryable and returns the approval request id to the client. A tool declared with requires_approval=True on a server without the gate refuses to build, so a missing gate can't go unnoticed. Server-side gates approve or deny the arguments they bound; a decision that tries to change them is denied. With PendingApprover, a reviewer can't approve their own call.
[11]
How long should an approval timeout be?
Pick the timeout from how long a decision is useful, not from how long a reviewer might take:
Kind of action | Typical timeout | On timeout |
|---|---|---|
Interactive chat, user waiting | 30 to 120 seconds | Deny and tell the user |
Support or ops action during working hours | 5 to 15 minutes, with one escalation tier | Deny, ticket for follow-up |
Scheduled or event-driven agent | Short gate timeout; queue the work outside the agent | Deny, retry the job later after approval |
Payments, deletions, access changes | Short, with escalation to a named owner | Deny |
For work that genuinely needs hours, don't hold an agent run open. Deny the call, record what was asked, and start a new run when the approval arrives, with idempotency keys so the re-run can't double the action. Idempotent Tool Calls: Make AI Agent Retries Safe shows how.
[12]
Approval settings to check in any framework
Whatever you build approvals with, check these settings. The table shows the Promptise values:
Setting | Safe value | Promptise agent gate (ApprovalPolicy) | Promptise server gate (ApprovalGateMiddleware) |
|---|---|---|---|
Behavior on timeout | Deny | on_timeout="deny" (default) | on_timeout="deny" (default) |
Timeout | Bounded | 300 s default, at most 24 h | 300 s default |
Approval service error | Deny | Denied, decided_by="gate" | Denied |
Pending queue full | Deny | max_pending=10, then denied | PendingApprover caps, then denied |
Repeated denials | Stop asking | max_retries_after_deny=3 per deny_window | Not applicable |
Reviewer edits arguments | Validate or refuse | Merged and validated | Denied |
Request binding | Unique id, signed | 128-bit id, HMAC on webhook | Request id in error details |
Self-approval | Refused | Up to your handler | Refused by PendingApprover |
[13]
Running approval timeouts in production
Pending approvals live in memory. If the agent process stops, waiting calls are gone and never run. That is the safe outcome, but tell the reviewer the request is void, or they may approve something that no longer exists.
Keep the agent's timeout below the server's. When both gates exist, the agent-side handler should give up first; a late yes that reaches the server after its own timeout is already a denial.
Record every decision with what decided it. on_decision receives the gate's own denials (timeout, handler error, full queue) as well as reviewers' decisions. Ship them to an audit store the agent can't write to; see AI Agent Audit Logging: Tamper-Evident Logs in Python.
Show reviewers the real action. Redaction hides emails and keys from reviewers by default; make sure what's left is enough to decide. A reviewer who can't see what the call does approves the summary, not the action.
Alert on timeouts. A rising timeout count means reviewers aren't reachable. Route approval.denied events with reason "timeout" to the on-call channel, as AI Agent Escalation: Page a Human When Limits Hit shows.
Cap irreversible actions too. Approvals answer "should this call run?". A budget on irreversible actions answers "how many can run at all?". AI Agent Irreversible Actions: Cap Destructive Tool Calls combines both.
[14]
Common problems and fixes
Symptom | Cause | Fix |
|---|---|---|
A gated action ran while the reviewer was away | on_timeout="allow" | Use the default "deny"; escalate instead |
Reviewers approve requests that already timed out | Late answers are discarded; the agent already got its denial | Show the deadline in the request; mark expired requests in your UI |
The agent keeps asking after a timeout | The model retries the denied call | Lower max_retries_after_deny; say in the instructions not to retry denied calls |
Approvals vanish after a deploy | Pending requests are in memory | Deny and re-run after approval; drain before restarts |
A replayed approval request is accepted by the reviewer service | Signature checked without freshness | Pass max_age to verify_webhook_signature |
One yes seems to cover later calls | Something caches decisions in your handler | Decide per request id; write allow rules instead |
Timeout reason is wrong for a tiered handler | The handler raised TimeoutError before the policy timeout | Expected; log the tier that gave up in the handler |
[15]
Limitations
Escalation is your handler. Promptise has no built-in multi-tier escalation for approvals; the tiered handler above is a pattern you own.
Pending state is process-local. Neither the agent gate nor PendingApprover survives a restart or shares a queue between replicas. For shared state, use a webhook to an approval service with its own store.
The webhook decision is not signed. Requests are signed by the agent; the decision the poll returns is trusted as received, so the poll endpoint needs its own authentication.
An approval can't undo anything. It prevents a call; it can't reverse one that ran on an earlier, correct approval.
[16]
FAQ
What happens when nobody approves an AI agent's tool call?
With a fail-closed gate, the call is denied when the approval timeout runs out, and the agent receives a denial it can report to the user. In Promptise, ApprovalPolicy waits 300 seconds by default and then denies with the reason "Approval timed out", and the MCP server's ApprovalGateMiddleware does the same.
Should an approval timeout ever auto-approve?
Rarely, and only for low-risk tools where a missed action costs more than a wrong one. For high-risk actions such as payments, deletions or access changes, auto-approving on timeout disables the control exactly when no one is watching. Escalate to a backup reviewer inside the deadline instead.
What does default deny mean for human-in-the-loop approvals?
Default deny means a gated action runs only on an explicit approval. Every other outcome, including a timeout, an error in the approval service, a full queue or a lost request, is a denial, and the reason is recorded.
Why shouldn't an agent remember a reviewer's approval?
Because the next call can have different arguments and a different context, and a remembered yes has no expiry or owner. Ask for every gated call. To reduce the number of questions, write explicit allow rules for routine cases instead of caching decisions.
How do you protect approvals against replay?
Give each request a unique random id, resolve each id once, sign the request over the tool, arguments, caller and time, and reject old requests with a freshness check. Promptise signs webhook requests with HMAC-SHA256, and verify_webhook_signature(..., max_age=300) rejects altered or stale ones.
How long should an approval timeout be?
As long as the decision stays useful: about a minute when a user is waiting, minutes with one escalation tier for operational actions, and short with a denial for scheduled agents, which can re-run once approval arrives.
[17]
Next steps
AI Agent Human in the Loop: Approve Tool Calls in Python builds the approval flow itself, with queue and webhook reviewers.
Approval Fatigue: Auto-Approve Safe AI Agent Actions shows the rules that keep reviewers for the calls that need them.
Policy as Code for AI Agent Tool Calls: OPA vs Approval moves deterministic decisions into a policy engine.
MCP Security Best Practices: A Checklist for MCP Servers covers the server side of tool safety.