The short answer
Pass requires_approval=True to a tool decorator, the Tool class or a toolset method. When the model calls that tool, the run ends with a DeferredToolRequests output listing the pending calls in approvals, so the agent’s output_type must include DeferredToolRequests. To continue, start a second run with the first run’s message history and a DeferredToolResults whose approvals maps each tool call ID to True, a ToolApproved (which can override the arguments) or a ToolDenied with an optional message for the model.1
For approval that depends on the arguments, the tool raises ApprovalRequired; for whole toolsets such as MCP servers, ApprovalRequiredToolset wraps them.1 The docs add a warning worth repeating: approval isn’t an authorization boundary for untrusted clients.1
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 stop-the-world flow
Most frameworks pause a run and resume it. Pydantic AI ends it:
| Step | What you do |
|---|---|
| 1 | Run the agent; output_type includes DeferredToolRequests1 |
| 2 | If the output is DeferredToolRequests, read output.approvals |
| 3 | Decide each call: True, ToolApproved(...), or ToolDenied("message") |
| 4 | Run again with message_history from run 1 and deferred_tool_results1 |
The follow-up is a separate run with its own run_id.1 That makes storage simple: what you keep between the two runs is the message history and the pending calls, both plain data. It also means a waiting approval holds no live process.
DeferredToolRequests.build_results() can build the results for you and validates the IDs; it also accepts approve_all=True.1 Don’t let that flag reach production code paths: it approves everything in one line.
A run with TestModel
We installed pydantic-ai-slim 2.61.0 in a scratch environment and used its TestModel, which calls the agent’s tools with generated arguments, no API key needed. One tool, marked for approval:
Code Program code, for developers. The text around it says what it does.
ran = []
agent = Agent(TestModel(), output_type=[str, DeferredToolRequests])
@agent.tool_plain(requires_approval=True)
def delete_customer(customer_id: str) -> str:
ran.append(customer_id)
return f"deleted {customer_id}"
Run 1, then a denial, then a fresh run approved:
Code Program code, for developers. The text around it says what it does.
r1 = agent.run_sync("delete customer 7")
results = DeferredToolResults(approvals={r1.output.approvals[0].tool_call_id: ToolDenied("Deleting customers needs a manager.")})
r2 = agent.run_sync(message_history=r1.all_messages(), deferred_tool_results=results)
What it printed:
Code What the screen or file shows, copied as is.
run 1 output type: DeferredToolRequests | tool ran: 0
pending: [('delete_customer', {'customer_id': 'a'})]
run 2 (denied) output: '{"delete_customer":"Deleting customers needs a manager."}' | tool ran: 0
run 3 (approved) output: '{"delete_customer":"deleted a"}' | tool ran: 1
('a' is TestModel’s generated argument.) Run 1 stopped before the tool ran. The denial’s message became the tool’s result, which a real model would read and explain. Only the approved run executed the tool, once.
Approval by argument: ApprovalRequired
Blanket approval on delete_customer is right. On a refund tool it’s tiring. For calls whose need for approval depends on the arguments, the tool raises ApprovalRequired itself, and checks RunContext.tool_call_approved to know whether this is the approved second pass.1 A sketch (not run):
Code Program code, for developers. The text around it says what it does.
@agent.tool
def refund(ctx: RunContext, order_id: str, amount: float) -> str:
if amount > 100 and not ctx.tool_call_approved:
raise ApprovalRequired
return issue_refund(order_id, amount)
ToolApproved can also override the arguments, so a reviewer can approve “refund 25, not 250” rather than reject and start over.1 Log both the proposed and approved arguments when that happens. Per-call metadata in the results reaches the tool as RunContext.tool_call_metadata1, a tidy place to pass the reviewer’s identity into the tool’s own audit log.
MCP servers and toolsets
MCP servers in Pydantic AI are toolsets, and the docs point to ApprovalRequiredToolset for approving calls to toolset tools, such as MCP servers.1 Wrap a server’s toolset and every tool it exposes needs approval, which is the safe default for a server you didn’t write and whose tool list may change. Then relax it for read-only tools you’ve checked, rather than the other way round.
Storing pending approvals
Because the first run has ended, a pending approval is just data: the message history and the list of pending calls. That makes it easy to store, and easy to get wrong. A few habits:
- Store the history server-side, keyed by an ID you give the reviewer, not by anything the client sends back. If a client could submit its own message history with the decisions, it could rewrite what the model “said” before approving.
- Bind decisions to the pending call IDs you stored.
build_results()validates IDs1; do the same check yourself if you build results by hand. - Expire old approvals. A refund approved three days after it was requested may no longer be right. Re-run the request instead.
- Record the outcome of run 2, not just the decision. “Approved” and “the tool succeeded” are different facts.
Approval is not authorization
The docs warn that approval isn’t an authorization boundary for untrusted clients: sensitive actions still need authentication and checks inside the tool.1 Take that literally in two ways:
- The approval comes from your client code. If a browser or another service sends
DeferredToolResults, anyone who can call that endpoint can approve. Check who’s approving on the server. - The tool should still refuse what nobody may do. Approval decides whether a person agreed. It doesn’t decide whether that person was allowed to. A delete tool should check the caller’s rights itself.
Deedglass’s docs include a Pydantic AI recipe that puts the agent’s tools behind its MCP server, so the same rules apply to every client, one of nine framework recipes of 15 lines or fewer, with no live model run behind them.2
A checklist
-
output_typeincludesDeferredToolRequests. - Tools that write, send or spend use
requires_approvalor raiseApprovalRequiredby argument. - MCP toolsets wrapped in
ApprovalRequiredToolset, relaxed only for checked read-only tools. -
DeferredToolResultsaccepted only from authenticated reviewers. - Denials carry a message; approvals with changed arguments are logged.
-
approve_all=Truenever used outside tests. - Tools check authorization themselves.
For the same pattern in other frameworks, see Mastra tool approval and OpenAI Agents SDK approvals; 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.