Pydantic AI: hosted tools and human approval
Call an Anlyon action from a Pydantic AI tool, defer the call while an approver decides outside the agent, and resume the conversation with the receipt grade.
Pydantic AI runs the agent. Anlyon executes the named external action. As of 2026-10-18, a Pydantic AI tool with requires_approval=True, or one that raises ApprovalRequired, ends the run with DeferredToolRequests until your code answers with DeferredToolResults. That answer is made in your process, it does not hold credentials, and it does not approve a pending Anlyon invocation. See Pydantic AI’s deferred tools.
Prerequisites
Complete the quickstart in a test environment. Define a governed action, for example send-support-email on the resend.email_send adapter with requires_approval=True, using an operator credential outside the agent. See Governed effects for the adapter config and Impact limits to cap it.
The agent runtime needs an Anlyon agent credential with actions:invoke and actions:read. It does not need the provider key or approvals:decide.
pip install pydantic-ai anlyon
Defer while the approver decides
Pydantic AI’s externally executed tools match an approval held outside your process. The tool invokes the action. If it is pending approval, the tool raises CallDeferred with the invocation ID, and the run ends with DeferredToolRequests.
from dataclasses import dataclass
from anlyon import AsyncClient
from pydantic_ai import Agent, CallDeferred, DeferredToolRequests, RunContext
@dataclass
class Deps:
anlyon: AsyncClient
support_agent = Agent("openai:gpt-5-mini", deps_type=Deps, output_type=[str, DeferredToolRequests])
def describe(invocation: dict) -> str:
return f"Email {invocation['id']}: grade {invocation.get('grade') or 'pending'}. Do not send it again."
@support_agent.tool
async def send_customer_email(ctx: RunContext[Deps], ticket_id: str, to: str, subject: str, body: str) -> str:
"""Send one plain-text email to a customer. Every email waits for an approver."""
result = await ctx.deps.anlyon.actions.invoke(
"send-support-email",
{"from": "support@mail.example.com", "to": [to], "subject": subject, "text": body},
operation_key=f"email:{ticket_id}",
)
invocation = result.data
if invocation["pendingApproval"]:
raise CallDeferred(metadata={"invocation_id": invocation["id"]})
return describe(invocation)
Store the paused conversation with ConversationTypeAdapter.dump_json(result.conversation). Derive operation_key from your own record, not from the model. The same key and request return the existing effect instead of a second send.
Resume with the result
Once the approver has decided, read the invocation and answer the deferred call with its result:
import os
from anlyon import AsyncClient
from pydantic_ai import ConversationTypeAdapter
async def resume(stored: bytes) -> str | None:
conversation = ConversationTypeAdapter.validate_json(stored)
requests = conversation.deferred_tool_requests
async with AsyncClient(api_key=os.environ["ANLYON_AGENT_KEY"]) as anlyon:
answers = {}
for call in requests.calls:
invocation_id = requests.metadata[call.tool_call_id]["invocation_id"]
invocation = (await anlyon.actions.invocation(invocation_id)).data
if invocation["status"] in ("pending_approval", "running"):
return None
answers[call.tool_call_id] = describe(invocation)
result = await support_agent.run(
conversation=conversation,
deferred_tool_results=requests.build_results(calls=answers),
deps=Deps(anlyon),
)
return result.output
| Status | Application behavior |
|---|---|
pending_approval |
Leave the conversation paused. An approver decides in Anlyon |
running |
Read the same invocation later |
succeeded |
Answer with the receipt grade. For resend.email_send, confirmed is Resend’s record of the send, not inbox delivery |
unknown |
No usable response. Anlyon does not send it again. Tell the model not to send it again |
failed |
Answer with the provider’s rejection |
denied or expired |
Answer that it was not sent. Do not resubmit to bypass the decision |
Pydantic AI approvals and Anlyon approvals together
Do not mark the same tool requires_approval=True as well, or the reviewer is asked twice. As of 2026-10-18, Pydantic AI’s docs say its approval is not an authorization boundary against an untrusted client that submits its own message history. The Anlyon approval is decided by a credential other than the one that asked, and the request it binds is the one that runs. Keep provider keys out of the agent’s environment, or a tool can call the provider directly and skip it.
A Pydantic AI tool that calls a provider directly is not routed through Anlyon and is not governed by it. For a call you cannot move yet, approvals.gate() waits for a decision and then runs your own function with your own credential.
See the full Pydantic AI example, receipts and grades, and the Python SDK.

