Not every tool should just run

Lesson 6-7's tools executed immediately whenever the model called them. For anything destructive or costly, deleting a file, sending an email, issuing a refund, you often want a human to approve the call before it runs. requires_approval=True on a tool turns "call it" into "pause and ask."

@agent.tool_plain(requires_approval=True)
def delete_file(path: str) -> str:
"""Delete a file at the given path."""
return f"deleted {path}"

What happens when an approval-gated tool is called

Instead of running the tool, the agent's run ends early and result.output comes back as a DeferredToolRequests, holding the pending calls that need a decision, not your normal output_type. Declare that this can happen by including it in output_type.

from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults
agent = Agent("test", output_type=[str, DeferredToolRequests])
@agent.tool_plain(requires_approval=True)
def delete_file(path: str) -> str:
"""Delete a file at the given path.
Args:
path: The path of the file to delete.
"""
return f"deleted {'{'}path{'}'}"
def main() -> None:
test_model = TestModel(call_tools=["delete_file"])
result = agent.run_sync("Please delete /tmp/foo.txt", model=test_model)
print("First run output type:", type(result.output).__name__)
if not isinstance(result.output, DeferredToolRequests):
print("No approval needed, final answer:", result.output)
return
print("Pending approvals:")
for call in result.output.approvals:
print(f" {call.tool_name}({call.args})")
# A human (or a policy function) decides here. We approve every call.
decisions = DeferredToolResults()
for call in result.output.approvals:
decisions.approvals[call.tool_call_id] = True
final = agent.run_sync(
message_history=result.all_messages(),
deferred_tool_results=decisions,
model=test_model,
)
print("\nAfter approval, final output:", final.output)

This lesson uses TestModel (Lesson 14) rather than a live Gemini call, so the approval flow is deterministic and needs no API key: the point here is the approve/resume mechanics, not what a real model decides to call.

Resuming after a decision

Once a human, or your own policy code, approves or rejects each pending call, build a DeferredToolResults and run again, passing the prior message history back in. The agent picks up exactly where it left off: approved tools actually run, rejected ones don't, and the run continues toward a real final answer. This is the Pydantic AI equivalent of LangGraph's interrupt-and-resume pattern for human approval, just modeled as a distinct output type instead of a graph interrupt.

Checkpoint

  • requires_approval=True on a tool turns a call into a pending request instead of an immediate execution.
  • output_type must include DeferredToolRequests for this to be a valid outcome of a run.
  • DeferredToolResults plus the prior message_history resumes the run, executing approved calls and skipping rejected ones.

If anything here still feels unclear, ask before moving to Lesson 21.