Skip to content
Learn Agentic AI
Learn Agentic AI11 min read8 views

Building Approval Gates for Sensitive Tool Operations

Learn how to implement human-in-the-loop approval gates in the OpenAI Agents SDK using needs_approval, MCPToolApprovalRequest, and RunState to control sensitive agent operations.

Why Approval Gates Are Essential

AI agents that can call tools are powerful. They are also dangerous. An agent with access to a billing API can issue refunds. An agent with database access can delete records. An agent with deployment tools can push code to production. The difference between a helpful agent and a catastrophic one often comes down to a single tool call that should have been reviewed by a human first.

Approval gates provide human-in-the-loop control over sensitive operations. The agent proposes an action, execution pauses, a human reviews the proposed action and its parameters, and only then does the operation proceed. The OpenAI Agents SDK provides first-class support for this pattern through the needs_approval configuration, MCPToolApprovalRequest, and RunState management.

Basic Approval with needs_approval

The simplest way to add an approval gate is the needs_approval flag on a function tool. When set to True, the runner pauses execution before calling the tool and raises an approval request:

flowchart TD
    START["Building Approval Gates for Sensitive Tool Operat…"] --> A
    A["Why Approval Gates Are Essential"]
    A --> B
    B["Basic Approval with needs_approval"]
    B --> C
    C["Handling Approval Requests with RunState"]
    C --> D
    D["Dynamic Approval with Approval Functions"]
    D --> E
    E["MCP Tool Approval with MCPToolApprovalR…"]
    E --> F
    F["Building a Web-Based Approval UI"]
    F --> G
    G["Best Practices for Approval Gates"]
    G --> DONE["Key Takeaways"]
    style START fill:#4f46e5,stroke:#4338ca,color:#fff
    style DONE fill:#059669,stroke:#047857,color:#fff
from agents import Agent, Runner, function_tool
import asyncio

@function_tool(needs_approval=True)
def delete_user_account(user_id: str, reason: str) -> dict:
    """Permanently delete a user account and all associated data.
    This action cannot be undone."""
    # In production, this would call the actual deletion API
    return {"status": "deleted", "user_id": user_id}

@function_tool(needs_approval=True)
def issue_refund(order_id: str, amount: float, reason: str) -> dict:
    """Issue a monetary refund for an order. Funds will be returned
    to the original payment method."""
    return {"status": "refunded", "order_id": order_id, "amount": amount}

@function_tool
def get_order_details(order_id: str) -> dict:
    """Retrieve order details including items, total, and status."""
    return {
        "order_id": order_id,
        "total": 149.99,
        "status": "delivered",
        "items": ["Widget A", "Widget B"],
    }

agent = Agent(
    name="CustomerServiceAgent",
    instructions="""You are a customer service agent. You can look up
    orders freely, but refunds and account deletions require approval.
    Always gather all relevant information before requesting a
    sensitive action.""",
    tools=[get_order_details, issue_refund, delete_user_account],
    model="gpt-4o",
)

Notice the pattern: read-only tools like get_order_details have no approval gate. Destructive or financial tools like issue_refund and delete_user_account require approval. This follows the principle of least privilege — agents can observe freely but must ask permission to act.

Handling Approval Requests with RunState

When the agent tries to call an approval-gated tool, the runner does not simply block. It returns a RunState that captures the entire execution context — the agent's reasoning, the tool call it wants to make, and the parameters it chose. Your application code then decides whether to approve or deny:

from agents import Agent, Runner, RunState
import asyncio

async def run_with_approval(agent: Agent, user_input: str):
    state: RunState = await Runner.run(
        agent,
        input=user_input,
    )

    # Check if the run is paused waiting for approval
    while state.status == "pending_approval":
        approval_request = state.pending_approval

        # Display the proposed action to the human reviewer
        print(f"\nAPPROVAL REQUIRED:")
        print(f"  Tool: {approval_request.tool_name}")
        print(f"  Arguments: {approval_request.arguments}")
        print(f"  Agent reasoning: {approval_request.reasoning}")

        # Get human decision
        decision = input("Approve? (yes/no): ").strip().lower()

        if decision == "yes":
            state = await Runner.resume(
                state,
                approval_result="approved",
            )
        else:
            denial_reason = input("Reason for denial: ").strip()
            state = await Runner.resume(
                state,
                approval_result="denied",
                denial_reason=denial_reason,
            )

    # Run is complete — either the tool executed or was denied
    print(f"\nFinal output: {state.final_output}")

asyncio.run(run_with_approval(agent, "Delete account for user U-456, they requested it"))

The key insight is the RunState loop. The run starts, hits an approval gate, pauses, and returns the state. Your code inspects the pending approval, presents it to a human, and resumes the run with the decision. If denied, the agent receives the denial reason and can adjust its response accordingly — for example, telling the user that account deletion was denied and offering alternatives.

Dynamic Approval with Approval Functions

For more sophisticated control, you can use an approval function instead of a static boolean. This lets you implement conditional approval logic — approve small refunds automatically but require human review for large ones:

from agents import Agent, function_tool, ApprovalContext

def refund_approval_policy(context: ApprovalContext) -> bool:
    """Approve refunds under $50 automatically.
    Require human approval for larger amounts."""
    amount = context.arguments.get("amount", 0)
    if amount < 50:
        return True  # Auto-approve
    return False  # Requires human approval

@function_tool(needs_approval=refund_approval_policy)
def issue_refund(order_id: str, amount: float, reason: str) -> dict:
    """Issue a monetary refund for an order."""
    return {"status": "refunded", "order_id": order_id, "amount": amount}

The approval function receives the full context including the tool arguments, the conversation history, and the agent's state. You can implement any logic you need — role-based checks, amount thresholds, time-of-day restrictions, or rate limiting.

See AI Voice Agents Handle Real Calls

Book a free demo or calculate how much you can save with AI voice automation.

MCP Tool Approval with MCPToolApprovalRequest

When your agent uses tools from MCP (Model Context Protocol) servers, approval works through the MCPToolApprovalRequest type. MCP tools are external — they come from remote servers that your agent connects to — so the approval flow includes additional metadata about the tool's origin:

from agents import Agent, Runner, RunState
from agents.mcp import MCPServerStdio, MCPToolApprovalRequest

async def run_mcp_agent():
    # Connect to an MCP server that provides database tools
    server = MCPServerStdio(
        command="npx",
        args=["-y", "@modelcontextprotocol/server-postgres"],
        env={"DATABASE_URL": "postgresql://localhost/mydb"},
    )

    async with server:
        agent = Agent(
            name="DatabaseAgent",
            instructions="You are a database assistant. Query freely but require approval for any INSERT, UPDATE, or DELETE operations.",
            mcp_servers=[server],
            mcp_tool_approval="always",
        )

        state = await Runner.run(
            agent,
            input="Delete all records from the staging_data table",
        )

        while state.status == "pending_approval":
            request: MCPToolApprovalRequest = state.pending_approval

            print(f"\nMCP TOOL APPROVAL REQUIRED:")
            print(f"  Server: {request.server_name}")
            print(f"  Tool: {request.tool_name}")
            print(f"  Arguments: {request.arguments}")

            decision = input("Approve? (yes/no): ").strip().lower()

            if decision == "yes":
                state = await Runner.resume(state, approval_result="approved")
            else:
                state = await Runner.resume(
                    state,
                    approval_result="denied",
                    denial_reason="Destructive query requires DBA review",
                )

        print(f"Result: {state.final_output}")

The mcp_tool_approval="always" setting requires approval for every MCP tool call. You can also set it to a filter function that checks the tool name or arguments to selectively gate certain operations.

Building a Web-Based Approval UI

In production, approvals rarely happen in a terminal. You need a web-based approval queue where reviewers can see pending requests, review the details, and approve or deny from a dashboard. Here is a pattern using FastAPI and the RunState serialization:

from fastapi import FastAPI
from agents import Runner, RunState
import json

app = FastAPI()
pending_approvals: dict[str, RunState] = {}

@app.post("/agent/run")
async def start_agent_run(user_input: str):
    state = await Runner.run(agent, input=user_input)

    if state.status == "pending_approval":
        run_id = state.run_id
        pending_approvals[run_id] = state
        return {
            "status": "pending_approval",
            "run_id": run_id,
            "tool": state.pending_approval.tool_name,
            "arguments": state.pending_approval.arguments,
        }

    return {"status": "complete", "output": state.final_output}

@app.post("/agent/approve/{run_id}")
async def approve_action(run_id: str, approved: bool, reason: str = ""):
    state = pending_approvals.pop(run_id, None)
    if not state:
        return {"error": "No pending approval found"}

    result_state = await Runner.resume(
        state,
        approval_result="approved" if approved else "denied",
        denial_reason=reason,
    )

    if result_state.status == "pending_approval":
        pending_approvals[result_state.run_id] = result_state
        return {
            "status": "pending_approval",
            "run_id": result_state.run_id,
            "tool": result_state.pending_approval.tool_name,
        }

    return {"status": "complete", "output": result_state.final_output}

This pattern decouples the agent execution from the approval decision. The agent run starts, pauses at an approval gate, and the run state is stored. A separate API call (triggered by a human clicking "Approve" in a UI) resumes the run. This works across async boundaries, network requests, and even server restarts if you persist the RunState.

Best Practices for Approval Gates

Default to requiring approval for destructive operations. If a tool modifies, deletes, or spends money, it should require approval until you have high confidence in the agent's judgment.

Include the agent's reasoning in the approval request. The human reviewer needs context — not just the tool name and arguments, but why the agent decided to take this action. Configure your agent instructions to always explain its reasoning before acting.

Set approval timeouts. A run that waits for approval forever ties up resources. Set a reasonable timeout (e.g., 30 minutes) and have the agent gracefully handle expired approvals.

Log all approval decisions. Every approval and denial should be logged with the reviewer's identity, timestamp, and reasoning. This audit trail is essential for compliance and for improving the agent over time.

Use escalation chains. If the primary reviewer does not respond within a threshold, escalate to a backup. If the backup does not respond, auto-deny with a notification to the user.

Approval gates turn autonomous agents into supervised agents. They provide the safety net that makes it possible to give agents powerful capabilities without unacceptable risk. Start with approval on everything, then selectively remove gates as you build confidence in specific tool-task combinations.

Share
C

Written by

CallSphere Team

Expert insights on AI voice agents and customer communication automation.

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.

Related Articles You May Like

Technical Guides

Voice AI Latency: Why Sub-Second Response Time Matters (And How to Hit It)

A technical breakdown of voice AI latency budgets — STT, LLM, TTS, network — and how to hit sub-second end-to-end response times.

Technical Guides

Building Voice Agents with the OpenAI Realtime API: Full Tutorial

Hands-on tutorial for building voice agents with the OpenAI Realtime API — WebSocket setup, PCM16 audio, server VAD, and function calling.

Technical Guides

How AI Voice Agents Actually Work: Technical Deep Dive (2026 Edition)

A full technical walkthrough of how modern AI voice agents work — speech-to-text, LLM orchestration, TTS, tool calling, and sub-second latency.

AI Interview Prep

8 AI System Design Interview Questions Actually Asked at FAANG in 2026

Real AI system design interview questions from Google, Meta, OpenAI, and Anthropic. Covers LLM serving, RAG pipelines, recommendation systems, AI agents, and more — with detailed answer frameworks.

AI Interview Prep

8 LLM & RAG Interview Questions That OpenAI, Anthropic & Google Actually Ask

Real LLM and RAG interview questions from top AI labs in 2026. Covers fine-tuning vs RAG decisions, production RAG pipelines, evaluation, PEFT methods, positional embeddings, and safety guardrails with expert answers.

AI Interview Prep

7 ML Fundamentals Questions That Top AI Companies Still Ask in 2026

Real machine learning fundamentals interview questions from OpenAI, Google DeepMind, Meta, and xAI in 2026. Covers attention mechanisms, KV cache, distributed training, MoE, speculative decoding, and emerging architectures.