Pydantic AI tool approval: requires_approval and deferred tool results, tested

Pydantic AI handles approval by ending the run: the agent returns the pending calls instead of an answer, and a second run carries your decisions. We ran it with the built-in TestModel.

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

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 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_type includes DeferredToolRequests.
  • Tools that write, send or spend use requires_approval or raise ApprovalRequired by argument.
  • MCP toolsets wrapped in ApprovalRequiredToolset, relaxed only for checked read-only tools.
  • DeferredToolResults accepted only from authenticated reviewers.
  • Denials carry a message; approvals with changed arguments are logged.
  • approve_all=True never 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.

The takeaway

With requires_approval, our Pydantic AI run ended with a DeferredToolRequests instead of running the tool; a ToolDenied kept it unrun and True let it run once.

Read nextMastra tool approval: requireToolApproval, requireApproval and what happens on a crash Try it: install in 7 s

Related on this site

More posts

Sources

  1. 1Pydantic AI docs: Deferred tools · pydantic.dev · checked 26 October 2026
  2. 2Deedglass docs: Pydantic AI · docs.deedglass.com · 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