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:
- When the model emits a tool call, the runner evaluates
needs_approval. It can beTrueor an async function(ctx, params, call_id) -> bool. - If approval is needed and no decision is stored, the run pauses and
result.interruptionslistsToolApprovalItems (tool_name,arguments,agent.name). result.to_state()gives you aRunState.state.to_string()andRunState.from_string(agent, s)(orto_json/from_json) make it durable.- Call
state.approve(item)orstate.reject(item, rejection_message=...), thenRunner.run(agent, state)with the original top-level agent. - 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:
refund(needs_approval=True) and the externalsend_email(the callable returned True) paused. The internal email did not need approval.- Nothing was refunded before approval (
refunds so far: []). - After restoring from the string, approving and resuming, the refund ran once, and the rejected call returned our
rejection_messageto the model instead of running.
Gotchas
- Resume with the same agent definition.
RunState.from_stringneeds an agent with the same tools. If tools may change while approvals wait, store a version marker next to the state (the docs recommend this). - Only deserialize trusted state. The docs warn that
from_string/from_jsondo not authenticate the snapshot. Keep it server-side and send the reviewer only IDs and display details. - Partial decisions are fine. Unresolved items stay in
interruptionsand pause the run again. - Sticky decisions:
state.approve(item, always_approve=True)approves later calls to that tool for the rest of the run. - Rejection text: a per-call
rejection_messagebeats a run-wideRunConfig(tool_error_formatter=...). - Streaming: drain
stream_events()first, then readinterruptionsand resume withRunner.run_streamed(agent, state). - Double resume means a double side effect. Resuming the same saved state twice (double-click, webhook retry) runs the approved tool twice. See duplicate tool calls after resume.
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: OpenAI Agents SDK approval + resume template, this flow plus
tool_error_formatter, with its own run log. - Pro: idempotent approval templates in Signal Pro.
- Done for you: the $79 Agent Approval-Flow Fix. We repair approval and resume in your agent and deliver a tested patch.