PromptisePromptise
Why PromptiseDocs
GitHubGet started
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Guides> Human approvals (human-in-the-loop)

AI Agent Approval Timeouts: Fail Closed, Default Deny

What should happen when nobody approves an AI agent's tool call? Deny on timeout, never cache consent, bind each approval to one call, escalate in time.

Level
Intermediate
Reading time
18 min
Published
Oct 23, 2026
Last updated
Oct 23, 2026
Tested with
Promptise 1.7.0
By
Promptise Team
  • Human-in-the-Loop
  • Tool Calling
  • Model Context Protocol (MCP)
Contents · 17 sections↓
  1. 01What should happen when nobody approves an AI agent's action?
  2. 02What does "fail closed" mean for an approval gate?
  3. 03Why should consent never be cached?
  4. 04How do you bind an approval to the exact call?
  5. 05When should an approval escalate instead of waiting longer?
  6. 06What you need
  7. 07Step 1: Deny when the timeout runs out
  8. 08Step 2: Escalate to a backup reviewer, then deny
  9. 09Step 3: Bind each approval to one call
  10. 10Step 4: Enforce the same rule on the MCP server
  11. 11How long should an approval timeout be?
  12. 12Approval settings to check in any framework
  13. 13Running approval timeouts in production
  14. 14Common problems and fixes
  15. 15Limitations
  16. 16FAQ
  17. 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.

[01]

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.

An approval gate's outcomes: a tool call that needs approval reaches the gate, which asks a team queue and then on call before its timeout. Only an explicit yes runs the tool; a denial, a timeout, a handler error, a full queue or a restart ends in a denial, and a late answer is discarded.
FigureA fail-closed approval gate: only an explicit yes runs the tool

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:

  1. A unique request id per call, generated with a secure random source, so a decision can't be guessed or reused.

  2. The decision resolves that id once. A second decision for the same id, or one for an id that isn't pending, is rejected.

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

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

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

Pythontimeout_demo.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
"""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:

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 5s as 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:

Pythonescalation.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
"""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:

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:

Pythonbinding.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
"""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:

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:

Pythonserver_gate.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
"""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:

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.

Contents

  1. 01What should happen when nobody approves an AI agent's action?
  2. 02What does "fail closed" mean for an approval gate?
  3. 03Why should consent never be cached?
  4. 04How do you bind an approval to the exact call?
  5. 05When should an approval escalate instead of waiting longer?
  6. 06What you need
  7. 07Step 1: Deny when the timeout runs out
  8. 08Step 2: Escalate to a backup reviewer, then deny
  9. 09Step 3: Bind each approval to one call
  10. 10Step 4: Enforce the same rule on the MCP server
  11. 11How long should an approval timeout be?
  12. 12Approval settings to check in any framework
  13. 13Running approval timeouts in production
  14. 14Common problems and fixes
  15. 15Limitations
  16. 16FAQ
  17. 17Next steps

Learning paths

Want more structure? Paths put guides in order, like a short course.

See the paths →

> Human approvals (human-in-the-loop)

Related guides

All guides in this topic →
  • // 01Advanced · 20 minPolicy as Code for AI Agent Tool Calls: OPA vs ApprovalPolicy as code for AI agent tool calls: when deterministic rules beat human approval or model judgment, and how to check each call with OPA before it runs.
  • // 02Intermediate · 21 minApproval Fatigue: Auto-Approve Safe AI Agent ActionsStop rubber-stamping AI agent actions. Auto-approve routine tool calls with rules, send only risky ones to a human, and log why. Real Python runs.
  • // 03Intermediate · 25 minAI Agent Notifications: Webhook and Slack Alerts in PythonGet alerted when your AI agent fails a tool call, hits a guardrail or budget, or needs approval. Webhooks, Slack and Python callbacks, with real payloads.
  • // 04Intermediate · 22 minFastAPI MCP Server: Turn Your FastAPI App into MCP ToolsTurn your FastAPI app into an MCP server for Claude and AI agents: fix auth, names and descriptions, gate writes, and test it with a real agent.
  • // 05Intermediate · 15 minOpenAPI to MCP: Turn Any REST API into an MCP ServerTurn an OpenAPI spec into a working MCP server in minutes. Read-only by default, human approval on every write, and tested by a real agent.
  • // 06Beginner · 7 minWhat Is Human in the Loop AI? Approvals for AI AgentsWhat human in the loop AI means for agents: a person approves risky tool calls before they run. How approval gates work, an example, and common mistakes.

Keep going.

Browse every guide by topic and level, or follow a learning path that puts them in order.

All guidesLearning paths