Skip to content
Learn Agentic AI
Learn Agentic AI11 min read14 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 LR
    INPUT(["User input"])
    AGENT["Agent<br/>name plus instructions"]
    HAND{"Handoff to<br/>another agent?"}
    SUB["Sub-agent<br/>specialist"]
    GUARD{"Guardrail<br/>passed?"}
    TOOL["Tool call"]
    SDK[("Tracing<br/>OpenAI dashboard")]
    OUT(["Final output"])
    INPUT --> AGENT --> HAND
    HAND -->|Yes| SUB --> GUARD
    HAND -->|No| GUARD
    GUARD -->|Yes| TOOL --> AGENT
    GUARD -->|Block| OUT
    AGENT --> OUT
    AGENT --> SDK
    style AGENT fill:#4f46e5,stroke:#4338ca,color:#fff
    style GUARD fill:#f59e0b,stroke:#d97706,color:#1f2937
    style SDK fill:#ede9fe,stroke:#7c3aed,color:#1e1b4b
    style OUT 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:

Hear it before you finish reading

Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.

Try Live Demo →
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.

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:

Still reading? Stop comparing — try CallSphere live.

CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.

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

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

Agentic AI

Human-in-the-Loop Hybrid Agents: 73% Fewer Errors in 2026

Fully autonomous agents are still a fantasy in production. LangGraph's interrupt() lets you pause for human approval mid-graph without losing state. We cover approve/edit/reject/respond actions and CallSphere's escalation ladder.

Agentic AI

Browser Agents with LangGraph + Playwright: Visual Evaluation Pipelines That Don't Lie

Build a browser agent with LangGraph and Playwright that does multi-step web tasks, then ground-truth its work with visual diffs and DOM-based evaluators.

Agentic AI

OpenAI Computer-Use Agents (CUA) in Production: Build + Evaluate a Real Workflow (2026)

Build a working computer-use agent with the OpenAI Computer Use tool — clicks, types, scrolls a real browser — then evaluate task success on a benchmark suite.

Funding & Industry

OpenAI revenue run-rate — April 2026 read — April 2026 update

OpenAI's April 2026 reported revenue run-rate cleared $13B annualized, on continued ChatGPT growth, agentic Operator monetization, and enterprise API expansion.

Funding & Industry

Stargate progress update — April 2026 site and capex

OpenAI's Stargate with Oracle and SoftBank crossed a milestone in April 2026 with the first Texas site partially energized and three additional sites under construction.

Funding & Industry

OpenAI acquisitions and acquihires — April 2026 roundup

April 2026 saw OpenAI complete two small acquisitions and several acquihires across robotics and enterprise agent teams, expanding the post-Stargate hiring spree.