Framework guides

How to add human approvals to Pydantic AI

Connect Pydantic AI deferred tools and approval-required tool calls to Contro1 decisions, signed callbacks, and audit evidence.

Pydantic AI deferred tools are a clean place to pause risky tool calls and let Contro1 route the decision to the right human.

Use the integration skill

Copy this skill link into your code agent to add Pydantic AI and Contro1 to your system.

Copy skill link

Key takeaways

  • Start by marking one risky Pydantic AI tool with requires_approval=True.
  • Convert each DeferredToolRequest into a Contro1 approval request before the tool executes.
  • Control Map previews whether the required reviewers are mapped and available before the agent waits.
  • Use audit records and evidence packets to prove both the human decision and the action that followed.

What this integration does

Pydantic AI can pause tool execution by returning deferred approval requests. Contro1 becomes the external approval system for those requests: it routes the decision to the right human, records the decision, returns a signed outcome, and keeps audit evidence.

Use this pattern when a Pydantic AI tool can mutate production state, send customer messages, make payments, change access, delete data, or trigger infrastructure changes.

Setup

There is no Pydantic AI-specific Contro1 package required for this first integration. The examples assume your existing Pydantic AI app can make HTTPS calls to Contro1 and receive signed webhooks.

If your project already uses the Contro1 Python SDK, you can replace the direct client calls with your SDK wrapper. The required runtime configuration stays the same.

.env
CENTCOM_API_KEY=cc_live_your_key
CENTCOM_BASE_URL=https://api.contro1.com/api/centcom/v1
CENTCOM_WEBHOOK_SECRET=whsec_your_secret
CENTCOM_CALLBACK_URL=https://your-app.example.com/webhooks/contro1

Short example: mark a tool for approval

This is the smallest useful change in a Pydantic AI agent. The tool will not execute until your deferred-tool handler supplies an approved result.

agent.py
from pydantic_ai import Agent

agent = Agent("openai:gpt-5")

@agent.tool_plain(requires_approval=True)
def issue_refund(customer_id: str, amount: int) -> str:
    return billing.refund(customer_id, amount)

Short example: create a Contro1 request

When Pydantic AI returns a deferred approval request, create one Contro1 request for that tool call. Use the run ID as correlation_id and the tool_call_id for idempotency.

create_approval.py
request = await centcom.create_request(
    type="approval",
    question=f"Approve Pydantic AI tool: {call.tool_name}?",
    context={"args": call.args},
    callback_url="https://your-app.example.com/webhooks/contro1",
    required_role="manager",
    external_request_id=f"pydantic-ai:{run_id}:{call.tool_call_id}",
    correlation_id=run_id,
    metadata={
        "integration": "pydantic-ai",
        "tool_name": call.tool_name,
        "tool_call_id": call.tool_call_id,
    },
)

Full example: deferred approval handler

A production handler should create the approval request, wait for a verified signed decision, and return the deferred result to Pydantic AI.

The exact Pydantic AI return helpers depend on your chosen deferred-tool flow, but the Contro1 contract stays the same: one request per tool_call_id and one correlation_id per agent run.

contro1_deferred_handler.py
async def handle_deferred_approvals(run_id: str, requests):
    approvals = {}
    for call in requests.approvals:
        request = await centcom.create_request(
            type="approval",
            question=f"Approve Pydantic AI tool: {call.tool_name}?",
            context={"args": call.args},
            callback_url="https://your-app.example.com/webhooks/contro1",
            required_role="manager",
            external_request_id=f"pydantic-ai:{run_id}:{call.tool_call_id}",
            correlation_id=run_id,
            metadata={
                "integration": "pydantic-ai",
                "tool_name": call.tool_name,
                "tool_call_id": call.tool_call_id,
            },
        )
        decision = await wait_for_signed_decision(request["id"])
        approvals[call.tool_call_id] = bool(decision.get("response", {}).get("approved"))

    return requests.build_results(approvals=approvals)

Pydantic AI deferred tools · Webhooks · Audit records and cases

If routing fails, check who is available

Most integrations do not need Control Map in the normal approval path. If a request cannot be routed, times out unexpectedly, or your app wants to show a clear operational error, call Control Map to see who is currently available.

It helps answer which required roles are mapped, who is on shift now, whether fallback reviewers exist, and which warning explains the routing problem.

control_map.py
preview = await centcom.post("/requests/control-map", {
    "approval_requirements": {
        "required_roles": ["manager"],
        "required_approvals": 1,
    },
    "metadata": {
        "integration": "pydantic-ai",
        "tool_name": call.tool_name,
        "tool_call_id": call.tool_call_id,
    },
})

print(preview["satisfiable"])            # can this request be routed?
print(preview.get("on_shift_capacity"))  # who is currently available?
print(preview.get("fallback_reviewers")) # who can receive fallback routing?
print(preview.get("warnings"))           # why routing may fail

API endpoint index · Requests API

Log autonomous and post-approval tool actions

Every approval request already stores the reviewer decision in Contro1. You do not need a separate audit record just to prove the approval happened.

Use audit records for low-risk tools that run without approval, and optionally after an approved tool completes if you want to record what your Pydantic AI system actually did next.

log_autonomous_tool.py
await centcom.log_action(
    action="pydantic_ai.tool_allowed",
    summary="Ran read-only account lookup",
    source={"integration": "pydantic-ai", "run_id": run_id},
    outcome="success",
    severity="info",
    correlation_id=run_id,
)
log_post_approval_tool.py
await centcom.log_action(
    action="pydantic_ai.tool_completed",
    summary=f"Completed approved tool {call.tool_name}",
    source={"integration": "pydantic-ai", "run_id": run_id},
    outcome="success",
    correlation_id=run_id,
    in_reply_to={"type": "request", "id": request_id},
)

Get evidence for compliance or incident review

Evidence is the exportable packet for one reviewed tool call. It includes request metadata, policy context, reviewer decision, timestamps, callback status, and the final protocol response.

Use request evidence for one approval. Use the case timeline when you need the full Pydantic AI run: approvals, logs, denied actions, and autonomous actions sharing the same correlation_id.

get_evidence.py
evidence = await centcom.get(f"/requests/{request_id}/evidence")
get_case.py
timeline = await centcom.get(f"/cases/{run_id}")

Send full agent traceability

Beyond the approval call, attach identity, a run trace, the tools you invoked, and the context you retrieved. Each field is optional — add what you have. The verified identity always comes from your API key; a caller-supplied actor.agent_id is recorded as a claimed sub-agent until an admin verifies it.

  • trace_id / parent_trace_id — link one run (and sub-agent runs) into a single trace.
  • tool_calls[] — what the agent tried to do, so reviewers see the actions.
  • retrieved_context[] — the data the decision was based on (RAG provenance).
  • Then export a signed evidence packet from GET /requests/:id/evidence.
Send full traceability
# Same approval call you already make — now with full traceability.
client.requests.create(
    request_type="approval",
    title="Refund $4,200 to customer 8831",
    source={"integration": "pydantic-ai"},
    actor={"agent_id": "billing-agent", "agent_name": "Billing Agent"},  # claimed sub-agent
    trace_id=f"trc_{run_id}",          # link every step of this run
    tool_calls=[{"name": "lookup_order", "outcome": "success"}],
    retrieved_context=[{"source": "policy:refunds", "uri": "kb://policy/refunds"}],
    continuation={"mode": "decision"},
)

Agent identity, traceability & signed evidence

Frequently asked questions

Why use deferred tools instead of approving after the run?

Approval after the run is too late for risky tool calls. Deferred tools pause before execution, which is the point where a human decision still controls the outcome.

Can the handler approve some tools and deny others?

Yes. Build results per tool_call_id. Approved calls can continue, denied calls should return a denial result or ToolDenied-style response according to your runtime pattern.

What does Control Map return?

It previews routing before you create a request: satisfiable status, role mappings, current shift capacity, fallback reviewers, and warnings such as missing role mappings or not enough distinct approvers.

How do I prove what the tool did after approval?

The approval decision is already stored on the request. Create an audit record only if you also want to record the external tool result, and set in_reply_to to the Contro1 request id.

How do I export evidence?

Use GET /requests/:id/evidence for one reviewed tool call, and GET /cases/:case_id for the full run timeline grouped by correlation_id.

Does this require a Contro1 package for Pydantic AI?

No. The starter repo can begin as a skill-only repo, and teams can use direct HTTP calls until a dedicated connector package is needed.