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