Pydantic AIPydantic AI 2.51 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
29 small wins to finish your pathNext lesson →

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

Example
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.

Example
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)

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.

Example
decision = DeferredToolResults(approvals={call.tool_call_id: True})
final = agent.run_sync(message_history=result.all_messages(), deferred_tool_results=decision)
print(final.output)

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

Example
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)

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.

Example
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)

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.

A refund that waits for a person
endsshowndecisionrun_sync(ticket)first runDeferredToolRequeststhe call, not runA managerapprove or denySecond runhistory + decisionsThe tool runsapprovedThe model is toldToolDenied's message
Hover or tap a piece to see what it is and which lesson built it.
Trace a refund

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:

python
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}."
Example
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)

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

DecisionValue in DeferredToolResultsWhat the tool does
ApproveTrueRuns with the model's arguments
EditToolApproved(override_args=...)Runs with your arguments instead
DenyToolDenied(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.
Watch out. Approval stops the model acting alone; it is not access control. If a browser sends the approvals, a user could approve or edit their own refund. Check who is approving on your server, inside the tool if needed, before you trust the decision.
Try it yourself
  • Approve one call and deny another in the same DeferredToolResults, with a model that asks for two refunds.
  • Change override_args to a different order_id and confirm the edited value is used.
  • Change the refund to 30 euros in refunder and run the conditional agent.

This is what real progress feels like.