ActionBoxBlog

AutomationPlatformBackendML engineer

Human approval for AI agent tool calls: a human-in-the-loop workflow

A copy-paste human-in-the-loop AI agent workflow that pauses tool calls before irreversible actions with LangGraph, OpenAI Agents SDK, and Actionbox.

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.

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

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

python
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

python
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

  1. Install the CLI or grab the Python/TS SDK
  2. Create a Source → ACTIONBOX_TOKEN
  3. Wrap one destructive tool in needsApproval (Agents SDK) or an interrupt (LangGraph)

Create a free Source · Agent + SDK reference · Cron jobs that ask before acting

Try this workflow in minutes

Create a free Source, then run the exact commands from this post against the live API — no approval infrastructure to build.

S
Suson Sapkota

Founder, Actionbox