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

Human approval before a refund

A tool that moves money should wait for a person. requires_approval ends the run with the pending call, and a second run carries the decision.

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}."
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, and come back tomorrow.

Approve

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, keyed by tool_call_id. Approved calls run now, and the model continues with their results.

Deny

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.

Approval only when it matters

The same agent, with ApprovalRequired and RunContext added to the imports, and a refund tool that decides for itself:

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

Approval is not access control
Approval stops the model acting alone. If a browser sends the approvals, a user could approve their own refund, so check who is approving on your server, inside the tool if needed.
Try it yourself
  • Approve one call and deny another in the same DeferredToolResults, with a model that asks for two refunds.
  • Change the refund to 30 euros in refunder and run the conditional agent.
  • Save result.all_messages_json() between the two runs and load it back first, as in lesson 15.

You understood something today that you didn't yesterday.