An agent that can call tools is an agent that can delete, pay, send, or deploy. The model proposes; nothing guarantees a human saw the action before it executed. A human approval gate turns "the agent decided" into "the agent proposed, a person approved, then it ran" — the difference between automation you trust and automation you babysit.
This post shows the same gate three ways: LangGraph interrupt, the OpenAI Agents SDK, and a hosted decision layer that works with any of them.
Human approval for AI agent tool calls: interrupt before the side effect
Put the checkpoint at the tool boundary, not at plan generation. A plan can change by the time it executes; the tool call is the exact payload that runs.
# LangGraph: pause before the destructive tool, resume with the decision
import os
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Command
from actionbox import Actionbox
AB = Actionbox(api_key=os.environ["ACTIONBOX_TOKEN"])
def run_plan(state: dict) -> dict:
"""The agent prepares the exact action it wants to take."""
# ... agent work ...
return {"proposed_action": state["proposed_action"]}
def approval_gate(state: dict) -> dict:
"""Pause the graph until a human approves or rejects."""
proposal = state["proposed_action"]
decision = interrupt({
"kind": "actionbox",
"title": proposal["title"],
"context": proposal["context"],
"timeout": 3600,
})
state["approved"] = decision.get("approved") is True
state["decision_note"] = decision.get("reason", "")
return state
def execute(state: dict) -> dict:
if not state["approved"]:
return {"outcome": "skipped", "reason": state["decision_note"]}
# Only the approved payload runs here.
return {"outcome": run_tool(state["proposed_action"])}
graph = StateGraph(dict)
graph.add_node("plan", run_plan)
graph.add_node("gate", approval_gate)
graph.add_node("execute", execute)
graph.add_edge(START, "plan")
graph.add_edge("plan", "gate")
graph.add_edge("gate", "execute")
graph.add_edge("execute", END)The graph stops at interrupt. Nothing executes until a human resolves the decision — no polling loop, no half-applied state.
Step 2: OpenAI Agents SDK — needsApproval
The Agents SDK gives you approval at the tool level for free: mark the tool needsApproval and the run pauses with interruptions you resolve explicitly.
from agents import Agent, Runner, tool
@tool(needsApproval=True)
def delete_production_volume(volume_id: str) -> str:
"""Delete a production volume. Requires human approval."""
...
agent = Agent(name="ops", instructions="...", tools=[delete_production_volume])
result = await Runner.run(agent, "free up the flagged volume")
for item in result.interruptions:
# Route to a human, wait, then resume with the decision.
decision = await ask_human(item.tool_call)
if decision.approved:
result.state.approve(item)
else:
result.state.reject(item, message=decision.reason)
final = await Runner.run(agent, result.state)Step 3: hosted decision layer — same gate, any framework
LangGraph and the Agents SDK both give you the pause; you still have to build the ask: who gets notified, what context they see, what happens on timeout, and where the audit record lives. Actionbox is the hosted ask for all of them:
import os
from datetime import datetime, timedelta, timezone
from actionbox import Actionbox
AB = Actionbox(api_key=os.environ["ACTIONBOX_TOKEN"])
def ask_human(proposal: dict) -> dict:
action = AB.actions.create(
title=f"{proposal['tool']}: {proposal['summary']}",
description=proposal["detail"],
interaction={"type": "boolean", "label": "Allow this tool call?"},
context=[{"type": "key_value", "items": proposal["context"]}],
expires_at=(datetime.now(timezone.utc) + timedelta(hours=1)).isoformat(),
on_expire={"type": "return_expired"},
idempotency_key=f"agent-tool:{proposal['tool']}:{proposal['summary']}",
)
decision = action.wait(timeout=3600) # bounded long-poll, no webhook needed
approved = decision is True
return {"approved": approved, "reason": "approved" if approved else "rejected or expired"}The decision is durable and signed: the agent can only resume with the outcome, and the audit trail shows who approved what, when, and why.
Why this beats an "are you sure?" prompt
- The pause is enforced in the graph, not the UI — the tool cannot run without a resolved decision
- Rich context reaches the approver — tool name, arguments, target, and risk render before the buttons
- Timeout is a policy, not an accident — choose fail-closed, expire, or an explicit fallback when nobody answers
- One approval surface for every agent — the same dashboard, phone, and audit log as your deploys, cron jobs, and pipelines
Full example: agent-initiated destroy with timeout
from datetime import datetime, timedelta, timezone
proposal = {
"tool": "terraform.destroy",
"summary": "Destroy staging environment stg-0421",
"detail": "User request: 'tear down my sandbox'. 14 resources. No state data loss.",
"context": {"environment": "staging", "resources": "14", "requested_by": "[email protected]"},
}
action = AB.actions.create(
title=f"{proposal['tool']}: {proposal['summary']}",
description=proposal["detail"],
interaction={"type": "boolean", "label": "Allow this tool call?"},
context=[{"type": "key_value", "items": proposal["context"]}],
expires_at=(datetime.now(timezone.utc) + timedelta(minutes=15)).isoformat(),
on_expire={"type": "return_expired"},
)
decision = action.wait(timeout=900)
if action.status == "expired":
print("No human answered — destroy did NOT run")
elif decision is True:
run_tool(proposal)Try it
- Install the CLI or grab the Python/TS SDK
- Create a Source →
ACTIONBOX_TOKEN - Wrap one destructive tool in
needsApproval(Agents SDK) or aninterrupt(LangGraph)
Create a free Source · Agent + SDK reference · Cron jobs that ask before acting