Skip to content
Anlyon
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

Was this page helpful?