OpenAI Agents SDK human-in-the-loop: pausing a run for approval with needs_approval

The OpenAI Agents SDK can pause a run before a tool executes and wait for a person, even across a restart. Approvals also reach tools that guardrails skip, such as the shell and hosted MCP.

The short answer

Set needs_approval=True on a function tool to always require approval, or pass an async function that decides per call. When a call needs approval, the run pauses and result.interruptions holds ToolApprovalItem entries. Turn the result into a RunState with result.to_state(), call state.approve(...) or state.reject(...) for each item, and resume with Runner.run(agent, state).1

Approvals cover more than guardrails do. ShellTool, ApplyPatchTool, Agent.as_tool, local MCP servers (require_approval) and HostedMCPTool (require_approval: "always" in tool_config) can all require approval.1 Tool guardrails, by contrast, don’t apply to hosted tools, built-in execution tools such as ShellTool, or handoffs.2

Every MCP tool call goes through your rules first. Here a delete that doesn’t say which rows is stopped before it reaches the database. These are the rules deedglass init starts with; the call is made up.

The approval loop in four steps

A sketch following the documented names (we didn’t run it against a live model):

Code Program code, for developers. The text around it says what it does.

from agents import Agent, Runner, function_tool

@function_tool(needs_approval=True)
async def refund(order_id: str, amount: float) -> str:
    ...

agent = Agent(name="support", tools=[refund])

result = await Runner.run(agent, "Refund order 42 in full")
while result.interruptions:
    state = result.to_state()
    for item in result.interruptions:
        if reviewer_says_yes(item):        # your UI, chat approval, ticket...
            state.approve(item)
        else:
            state.reject(item)
    result = await Runner.run(agent, state)

The steps:1

Step API
Mark the tool needs_approval=True, or an async function
Run until it pauses result.interruptions lists ToolApprovalItems
Decide state = result.to_state(), then state.approve(...) / state.reject(...)
Resume Runner.run(agent, state) (or run_streamed) on the original top-level agent

The loop matters: one resumed run can hit another approval, so keep going until there are no interruptions.

Approving by argument

The function form is where approvals become useful rather than tiring. The docs describe needs_approval as either True or an async function that decides per call.1 For a refund tool, that function can approve small amounts automatically and send large ones to a person. Asking about every call trains people to click yes; asking about the few that matter keeps approvals meaningful.

For local MCP servers, approval is set with require_approval on the server: always, never, a per-tool map or a callable; that callable doesn’t receive the call’s arguments.3 So argument-level rules for MCP tools need another layer, such as a tool input guardrail moved before approval, or a gateway in front of the server.

Pausing overnight: saving the state

Approvals often take longer than a request. The SDK lets you store paused work with state.to_json() or state.to_string() and restore it with RunState.from_json(...) or RunState.from_string(...).1

The docs add a warning worth taking literally: restored snapshots are not authenticated, so only load trusted or verified data.1 If you store paused runs in a database or a queue, treat the serialized state like any other input that can be tampered with:

  • Store it where only your service can write.
  • Sign or MAC it when you save, and check before you load.
  • Bind it to the user and the approval request it belongs to.

Otherwise anyone who can edit a stored snapshot can change what the run does when it resumes, including which calls look approved.

Approvals reach what guardrails skip

This is the main reason to use approvals alongside guardrails in the same app:

Tool type Tool guardrails Approval
Function tools Yes2 needs_approval1
Local MCP servers Yes, on every tool the server exposes3 require_approval1
HostedMCPTool No2 tool_config={"require_approval": "always"}1
ShellTool, ApplyPatchTool No2 needs_approval, opt-in, default False1
Agent.as_tool No2 needs_approval1
Handoffs No2 Not listed

Note the shell row: approval for local ShellTool and ApplyPatchTool is opt-in, and needs_approval defaults to False.1 An agent with a shell tool and no approval setting runs commands without asking. Our post on Agents SDK tool guardrails covers the guardrail side in detail.

One ordering detail: for tools that need approval, input guardrails normally run after approval; ToolExecutionConfig(pre_approval_tool_input_guardrails=True) moves them before it.2 Running the guardrail first means a person isn’t asked to approve something the guardrail would refuse anyway.

What the reviewer should see

An approval is only as good as the information behind it. Each ToolApprovalItem describes a pending call;1 your interface decides how much of that a person actually reads. Show, at minimum:

  • The tool and the full arguments, not a summary the model wrote. A refund of 2,500 described as “a small refund” is exactly what the reviewer is there to catch.
  • Who the agent is acting for, and the request that led here.
  • What happens on approve: the side effect in plain words, and whether it can be undone.
  • A reject reason field. A rejection with a reason is something the agent can work with; a bare “no” often leads to a retry with different wording.

Keep the reviewer and the requester different people for anything that moves money or deletes data.

Sticky decisions: use with care

always_approve=True or always_reject=True makes a decision persist for future calls to the same tool identity; for function tools, the scope is the agent that requested approval.1

always_reject is a fine way to switch off a tool for a session. always_approve is the SDK’s version of “don’t ask again,” and it has the same failure mode: the one call that should have been checked goes through because an earlier, harmless call was approved. Prefer the per-call function from the section above.

A checklist

  • Every tool that writes, sends or spends has needs_approval, as a function where amounts or targets vary.
  • ShellTool and ApplyPatchTool set needs_approval explicitly; their default is off.
  • Hosted MCP tools use require_approval: "always" where they can write.
  • The run loop handles several interruptions in a row.
  • Saved RunState is stored privately, signed, and bound to its user.
  • always_approve avoided for tools with side effects.
  • Input guardrails run before approval where they can refuse outright.
  • Each decision logged with the reviewer, the arguments and the outcome.

For the same pattern in other frameworks, see LangGraph interrupts and AI SDK toolApproval; the human-in-the-loop approval entry covers the idea. To see what the agents configured on your machine can change, paste the config into the check page or run npx deedglass scan (how the grade works); to try approval rules in your browser, open the playground.

Written with AI assistance and checked against our claims register.

The takeaway

needs_approval pauses an Agents SDK run with interruptions you approve or reject on a RunState, and unlike guardrails it also covers ShellTool and hosted MCP.

Read nextLangGraph interrupt() or HumanInTheLoopMiddleware: which to use for tool approval Try it: install in 7 s

Related on this site

More posts

Sources

  1. 1OpenAI Agents SDK (Python): Human-in-the-loop · openai.github.io · checked 26 October 2026
  2. 2OpenAI Agents SDK (Python): Guardrails · openai.github.io · checked 26 October 2026
  3. 3OpenAI Agents SDK (Python): MCP · openai.github.io · checked 26 October 2026

Published 26 October 2026, sources last checked 26 October 2026. If something here is out of date, write to hello@deedglass.com.

Try it on your own setup

npx deedglass scan

Grades only your own setup. Nothing is uploaded.

Pricing