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
Deedglass
A tool call meets your rules before it runs
Asks to run
DELETE FROM visits;
Deletes every row in visits
Deedglass checks your rules
- Reading dataAllowed
- Deleting a whole table (DROP, TRUNCATE)Never
- Changing or deleting rows without saying which (WHERE)This one
- Other changesWait for a person
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. -
ShellToolandApplyPatchToolsetneeds_approvalexplicitly; 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
RunStateis stored privately, signed, and bound to its user. -
always_approveavoided 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.