agent_signal

Guide

OpenAI Agents SDK tool approval and resume: needs_approval, interruptions and RunState

By Agent Signal · Published 2026-10-16 · Updated 2026-10-16

Short answer

Mark the tool with @function_tool(needs_approval=True), or pass an async callable that decides per call. The run then stops with result.interruptions holding ToolApprovalItem entries. Call result.to_state(), store state.to_string(), later rebuild it with RunState.from_string(agent, saved), call state.approve(item) or state.reject(item), and resume with Runner.run(agent, state) using the original top-level agent. The approved tool runs once; a rejected call returns a rejection message to the model instead of running.

You want a human to approve a refund, email or delete before the agent runs it, and you want the approval to work even if it arrives hours later in a different process. The OpenAI Agents SDK does this with three pieces: needs_approval on the tool, result.interruptions, and a serializable RunState. The code below was run on openai-agents 0.24.0 (Python 3.13), Oct 16, 2026.

How the flow works

From the OpenAI Agents SDK human-in-the-loop docs:

  1. When the model emits a tool call, the runner evaluates needs_approval. It can be True or an async function (ctx, params, call_id) -> bool.
  2. If approval is needed and no decision is stored, the run pauses and result.interruptions lists ToolApprovalItems (tool_name, arguments, agent.name).
  3. result.to_state() gives you a RunState. state.to_string() and RunState.from_string(agent, s) (or to_json / from_json) make it durable.
  4. Call state.approve(item) or state.reject(item, rejection_message=...), then Runner.run(agent, state) with the original top-level agent.
  5. If the resumed run hits new approvals, it pauses again, so loop on while result.interruptions.

Tested example

No API key needed: a small scripted Model stands in for OpenAI and always emits the same three tool calls. Everything else is the real SDK.

# scripted.py
# Offline stand-in for the OpenAI model. Everything else is real SDK code.
import json
from agents import Model, ModelResponse, Usage
from openai.types.responses import ResponseFunctionToolCall, ResponseOutputMessage, ResponseOutputText

class ScriptedModel(Model):
    """Turn 1: emit the given tool calls. After tool outputs arrive: reply with text."""
    def __init__(self, calls):
        self.calls = calls

    async def get_response(self, system_instructions, input, model_settings, tools,
                           output_schema, handoffs, tracing, **kw):
        items = input if isinstance(input, list) else []
        outs = [i for i in items if isinstance(i, dict) and i.get("type") == "function_call_output"]
        if not outs:
            out = [ResponseFunctionToolCall(type="function_call", id=f"fc_{n}", call_id=f"call_{n}",
                                            name=name, arguments=json.dumps(args))
                   for n, (name, args) in enumerate(self.calls)]
        else:
            text = " | ".join(f"{o['call_id']}={o['output']}" for o in outs)
            out = [ResponseOutputMessage(type="message", id="msg_1", role="assistant", status="completed",
                   content=[ResponseOutputText(type="output_text", text=text, annotations=[])])]
        return ModelResponse(output=out, usage=Usage(), response_id=None)

    def stream_response(self, *a, **kw):
        raise NotImplementedError
# guide_a.py
import asyncio
from agents import Agent, Runner, RunConfig, RunState, function_tool
from scripted import ScriptedModel

REFUNDS = []  # stands in for a payment API

@function_tool(needs_approval=True)
def refund(order_id: str, amount: int) -> str:
    """Refund an order."""
    REFUNDS.append((order_id, amount))
    return f"refunded {amount} on {order_id}"

async def needs_review(_ctx, params, _call_id) -> bool:
    return "@external" in params.get("to", "")   # only external emails pause

@function_tool(needs_approval=needs_review)
def send_email(to: str, body: str) -> str:
    """Send an email."""
    return f"emailed {to}"

def make_agent():
    return Agent(name="Support", instructions="Handle the ticket.",
                 model=ScriptedModel([("refund", {"order_id": "A1", "amount": 79}),
                                      ("send_email", {"to": "ceo@external.com", "body": "hi"}),
                                      ("send_email", {"to": "team@internal.com", "body": "fyi"})]),
                 tools=[refund, send_email])

async def main():
    cfg = RunConfig(tracing_disabled=True)
    result = await Runner.run(make_agent(), "Refund A1 and notify", run_config=cfg)
    print("paused on:", [(i.tool_name, i.arguments) for i in result.interruptions])
    print("refunds so far:", REFUNDS)
    saved = result.to_state().to_string()          # store in your DB

    # --- later, maybe another process ---
    agent = make_agent()
    state = await RunState.from_string(agent, saved)
    for item in state.get_interruptions():
        if item.tool_name == "refund":
            state.approve(item)
        else:
            state.reject(item, rejection_message="Reviewer declined the external email.")
    result = await Runner.run(agent, state, run_config=cfg)
    print("interruptions after resume:", result.interruptions)
    print("refunds:", REFUNDS)
    print("final:", result.final_output)

asyncio.run(main())

Actual output:

paused on: [('refund', '{"order_id": "A1", "amount": 79}'), ('send_email', '{"to": "ceo@external.com", "body": "hi"}')]
refunds so far: []
interruptions after resume: []
refunds: [('A1', 79)]
final: call_2=emailed team@internal.com | call_0=refunded 79 on A1 | call_1=Reviewer declined the external email.

What this shows:

Gotchas

What we did not test

Real OpenAI models, streaming, sessions, hosted MCP approvals and nested Agent.as_tool approvals were not run for this guide.

Get the template

Free starter pack

The approval-flow starter pack

Tested human-in-the-loop approval starters for LangGraph, OpenAI Agents SDK, Vercel AI SDK and Google ADK, plus the weekly Agent Signal newsletter. Free.

Get tested agent templates weekly