Tool approval: approve, deny or edit a refund
A deferred tool is one Pydantic AI pauses on instead of running, so a person can approve, deny or edit the call first. requires_approval=True marks the tool, and the decision travels into a second run.
Last updated: 28 Sep, 2026 · Pydantic AI 2.51
A tool that moves money should wait for a person. With approval, the model proposes the call, your app shows it to a human, and only their decision lets it run, changes its arguments, or turns it away.
The refund tool and a model that calls it
from pydantic_ai import Agent, DeferredToolRequests, DeferredToolResults, ModelResponse, TextPart, ToolCallPart, ToolDenied
from pydantic_ai.models.function import FunctionModel
def refunder(messages, info):
last = messages[-1].parts[-1]
if last.part_kind == "user-prompt":
return ModelResponse(parts=[ToolCallPart("refund_order", {"order_id": "A-1002", "amount": 120.0})])
return ModelResponse(parts=[TextPart(last.content)])
agent = Agent(FunctionModel(refunder), output_type=[str, DeferredToolRequests])
@agent.tool_plain(requires_approval=True)
def refund_order(order_id: str, amount: float) -> str:
"""Refund an order."""
return f"Refunded {amount:.2f} euros on {order_id}."The run pauses with a pending call
Ask for a refund. The tool does not run; the run ends with the call waiting for a decision.
result = agent.run_sync("Refund my order A-1002")
print(type(result.output).__name__)
for call in result.output.approvals:
print(call.tool_name, call.args)DeferredToolRequests
refund_order {'order_id': 'A-1002', 'amount': 120.0}requires_approval=True makes the tool deferred: when the model calls it, the function does not run. The run ends instead, and its output is a DeferredToolRequests listing the calls waiting for approval. That is why DeferredToolRequests is in output_type, next to str for normal answers. Between the two runs your app can take as long as it needs: show the call to a manager, store the messages, come back tomorrow.
Approving the call
A second run carries the decision, keyed by tool_call_id. Approve it and the tool runs now.
decision = DeferredToolResults(approvals={call.tool_call_id: True})
final = agent.run_sync(message_history=result.all_messages(), deferred_tool_results=decision)
print(final.output)Refunded 120.00 euros on A-1002.
The second run has no new prompt. It gets the first run's messages and the decisions. Approved calls run, and the model continues with their results.
Denying the call
decision = DeferredToolResults(approvals={call.tool_call_id: ToolDenied("A manager must approve refunds over 100 euros.")})
final = agent.run_sync(message_history=result.all_messages(), deferred_tool_results=decision)
print(final.output)A manager must approve refunds over 100 euros.
ToolDenied sends your message to the model as the tool's result, so it can tell the customer why. The refund function never ran.
Editing the call before it runs
Approval is not only yes or no. ToolApproved takes override_args, which replaces the arguments the model proposed. Here the manager approves the refund but lowers the amount from 120 to 50 before it runs.
print("asked for:", call.args)
# the manager lowers the amount before the tool runs
edited = DeferredToolResults(approvals={
call.tool_call_id: ToolApproved(override_args={"order_id": "A-1002", "amount": 50.0})
})
final = agent.run_sync(message_history=result.all_messages(), deferred_tool_results=edited)
print(final.output)asked for: {'order_id': 'A-1002', 'amount': 120.0}
Refunded 50.00 euros on A-1002.The tool ran with the edited amount, not the one the model asked for. override_args is the whole new argument dictionary, so the human stays in control of exactly what the tool does, not only whether it happens.
Pick one to watch it run, step by step.
Requiring approval only above a limit
The same agent, with ApprovalRequired and RunContext added to the imports, and a refund tool that decides for itself when to pause:
agent = Agent(FunctionModel(refunder), output_type=[str, DeferredToolRequests])
@agent.tool
def refund_order(ctx: RunContext, order_id: str, amount: float) -> str:
"""Refund an order."""
if amount > 50 and not ctx.tool_call_approved:
raise ApprovalRequired(metadata={"reason": "over 50 euros"})
return f"Refunded {amount:.2f} euros on {order_id}."result = agent.run_sync("Refund my order A-1002")
call = result.output.approvals[0]
print(result.output.metadata[call.tool_call_id])
decision = DeferredToolResults(approvals={call.tool_call_id: True})
print(agent.run_sync(message_history=result.all_messages(), deferred_tool_results=decision).output){'reason': 'over 50 euros'}
Refunded 120.00 euros on A-1002.Raising ApprovalRequired inside a tool asks for approval only when your rule says so. metadata travels with the request, keyed by call id, for the person deciding. On the approved second run the tool runs again with ctx.tool_call_approved set to True, so the same check lets it through.
Approve, deny or edit
| Decision | Value in DeferredToolResults | What the tool does |
|---|---|---|
| Approve | True | Runs with the model's arguments |
| Edit | ToolApproved(override_args=...) | Runs with your arguments instead |
| Deny | ToolDenied(message) | Never runs; the model gets your message |
When you reach for approval
- An action that spends money or cannot be undone: a refund, a delete, an email to a customer.
- A step that needs a human's judgement on the exact arguments, not only a yes.
- A policy that pauses only past a threshold, decided inside the tool.
Related
- Previous: Streaming: showing the answer as it is written
- Next: FallbackModel: when a provider is down
- Reference: Deferred tools
- Approve one call and deny another in the same
DeferredToolResults, with a model that asks for two refunds. - Change
override_argsto a differentorder_idand confirm the edited value is used. - Change the refund to 30 euros in
refunderand run the conditional agent.
This is what real progress feels like.