---
title: "Pydantic AI: hosted tools and human approval"
description: "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](https://pydantic.dev/docs/ai/tools-toolsets/deferred-tools/).

## Prerequisites

Complete the [quickstart](/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](/execution/governed-effects) for the adapter config and [Impact limits](/execution/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`.

```bash
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`.

```python
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:

```python
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()`](/guides/approval-gates) waits for a decision and then runs your own function with your own credential.

See the [full Pydantic AI example](https://anlyon.com/examples/pydantic-ai-tool-approval), [receipts and grades](/execution/outcomes), and the [Python SDK](/guides/python-sdk).
