ActionBoxBlog

ML engineerBackendPlatformAutomation

OpenAI Agents SDK Human-in-the-Loop Approval with Actionbox

Connect OpenAI Agents SDK human-in-the-loop tool approvals to Actionbox with needs_approval, typed decisions, and fail-closed resume.

AI letters on a dark surface

The OpenAI Agents SDK can pause a tool call before it runs. Actionbox gives that interruption a durable place for a person to review it from the web, mobile app, or CLI. The agent keeps its native RunState; Actionbox returns the typed decision used to approve or reject the exact interruption.

This separation is the simplest reliable OpenAI Agents SDK human-in-the-loop architecture:

Architecture Flow
1 Pause needs_approval tool call 2 Create Action for exact interruption 3 Notify with redacted arguments 4 Approve or reject 5 Return typed boolean decision 6 Resume original run 7 Execute only if approved OpenAI agent Agents RunState Actionbox Reviewer Side-effecting tool API- OpenAI agent Agents RunState Actionbox Reviewer Side-effecting tool API-

What OpenAI Agents owns—and what Actionbox owns

ConcernOwner
Agent conversation and tool interruptionOpenAI Agents SDK
Paused and resumed RunStateOpenAI Agents SDK
Reviewer notification and inboxActionbox
Typed approval or rejectionActionbox
Actual tool credentials and side effectYour application

Actionbox is not a replacement state store for the Agents SDK. If a reviewer may answer after the current worker exits, persist the resumable SDK state in your own durable store before releasing the worker.

Install the SDKs

bash
python -m pip install actionbox-sdk openai-agents
export ACTIONBOX_API_KEY="axb_live_..."
export OPENAI_API_KEY="..."

Create an Actionbox Source for the integration and keep both keys in your secret manager. Never place either key in the Action title, description, context, or logs.

Complete OpenAI Agents tool approval example

Mark only consequential tools with needs_approval=True. Safe read-only tools can continue without consuming human attention.

python
from __future__ import annotations

import asyncio
import hashlib
import os

from actionbox import Actionbox
from agents import Agent, Runner
from agents.decorators import tool


@tool(needs_approval=True)
async def deploy_service(environment: str, version: str) -> str:
    """Deploy one service version after approval."""
    # Replace this demo return with the real side effect after testing.
    return f"Deployed {version} to {environment}"


async def ask_actionbox(client: Actionbox, item) -> bool:
    call_id = getattr(item, "call_id", None)
    if not isinstance(call_id, str) or not call_id:
        raise ValueError("A stable tool-call ID is required")
    tool_name = (
        getattr(item, "qualified_name", None)
        or getattr(item, "name", None)
        or "unknown_tool"
    )
    arguments = getattr(item, "arguments", None) or "{}"
    identity = hashlib.sha256(call_id.encode()).hexdigest()

    action = await asyncio.to_thread(
        client.create,
        title=f"Approve {tool_name}"[:200],
        description="The OpenAI Agents SDK paused this exact tool call.",
        interaction={
            "type": "boolean",
            "label": "Allow this tool call?",
            "true_label": "Approve",
            "false_label": "Reject",
        },
        context=[{
            "type": "code",
            "title": "Arguments — remove secrets before sending",
            "language": "json",
            "content": arguments,
        }],
        metadata={
            "framework": "openai_agents",
            "tool_name": tool_name,
            "tool_call_id": call_id,
        },
        idempotency_key=f"openai-agents:{identity}",
    )

    response = await asyncio.to_thread(action.wait, timeout=3600)
    if response is None and action.status == "open":
        await asyncio.to_thread(
            client.cancel,
            action.id,
            "OpenAI Agents approval timed out; rejected fail-closed.",
        )

    return (
        isinstance(response, dict)
        and response.get("type") == "boolean"
        and response.get("value") is True
    )


async def main() -> None:
    agent = Agent(
        name="release-agent",
        instructions="Deploy version 2.18.0 to staging using the tool.",
        tools=[deploy_service],
    )

    with Actionbox(os.environ["ACTIONBOX_API_KEY"]) as actionbox:
        result = await Runner.run(agent, "Deploy the requested release.")

        while result.interruptions:
            state = result.to_state()
            decisions = await asyncio.gather(
                *(ask_actionbox(actionbox, item) for item in result.interruptions)
            )

            for item, approved in zip(
                result.interruptions, decisions, strict=True
            ):
                if approved:
                    state.approve(item, always_approve=False)
                else:
                    state.reject(
                        item,
                        rejection_message="Rejected or timed out in Actionbox.",
                    )

            result = await Runner.run(agent, state)

        print(result.final_output)


if __name__ == "__main__":
    asyncio.run(main())

The repository contains the maintained runnable version at integrations/agent_frameworks/openai_agents_hitl.py. It also rejects oversized approval context instead of silently showing the reviewer an incomplete payload.

Why result.to_state() matters

The interruption belongs to the run that produced it. Convert that result to its resumable state, apply each decision to that state, and pass the state back to Runner.run. Starting a fresh run after approval can lose the exact tool-call identity and conversation state the person reviewed.

Use always_approve=False for a one-time decision. A reviewer approving one deployment should not silently authorize every future call to the same tool.

Handle every interruption

A run can surface more than one approval item. Route every item, then approve or reject each exact object on the resumable state. The example gathers independent human requests concurrently so one reviewer decision does not prevent the others from being delivered.

Use the stable tool-call ID in the Actionbox idempotency key. A worker retry for the same interruption should recover the same logical Action instead of creating another notification.

Fail closed on timeout or malformed responses

A boolean Action returns a typed envelope such as {"type":"boolean","value":true}. Do not treat a truthy string, a missing response, or a local timeout as approval.

If the local wait expires, cancel the still-open Action when that run will no longer accept the answer. This prevents a late approval from looking valid after the worker has already rejected or abandoned the interruption.

Production checklist

  • Persist the resumable Agents SDK state when the wait can outlive the worker.
  • Redact tokens, credentials, personal data, and secret-bearing arguments.
  • Reject payloads that cannot be shown completely within your context limit.
  • Bind retries to the stable tool-call ID.
  • Approve the exact interruption with always_approve=False.
  • Reject expiry, timeout, and malformed typed responses.
  • Record the real execution outcome after an approved tool finishes.

For the framework-neutral architecture, read human-in-the-loop AI agents. If your application uses checkpointed graphs instead, follow the LangGraph human-in-the-loop tutorial. For the resulting evidence chain, see AI agent audit trails.

Create a free Source · Human approval API · Read the integration docs

Sources and further reading

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