# 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](https://openai.github.io/openai-agents-python/human_in_the_loop/):

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 `ToolApprovalItem`s (`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.

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

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

```text
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 external `send_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_message` to the model instead of running.

## Gotchas

- **Resume with the same agent definition.** `RunState.from_string` needs 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_json` do 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 `interruptions` and 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_message` beats a run-wide `RunConfig(tool_error_formatter=...)`.
- **Streaming:** drain `stream_events()` first, then read `interruptions` and resume with `Runner.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](/guides/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](/templates/openai-agents-approval-resume/), this flow plus `tool_error_formatter`, with its own run log.
- Pro: idempotent approval templates in [Signal Pro](/pricing/).
- Done for you: the [$79 Agent Approval-Flow Fix](/fix/). We repair approval and resume in your agent and deliver a tested patch.
