2026-03-03
PydanticAI + Intended: Governing AI Agent Tools Step by Step
Developer Relations · Developer Experience
PydanticAI + Intended: Governing AI Agent Tools Step by Step
Runnable. This tutorial uses the publishedintendedSDK (pip install intended) and its real PydanticAI helperintended.pydantic_ai.IntendedPydanticGuard. Stack@guard.protectunder@agent.tool; the tool's side effect runs only on an ALLOW decision and raisesPermissionErrorotherwise (fail-closed). Get a free API key to run it.
PydanticAI is one of the fastest-growing frameworks for building production AI agents in Python. It combines Pydantic's type safety with a clean agent abstraction that makes tool use straightforward. But like every agent framework, PydanticAI does not include built-in authorization. If an agent has access to a tool, it can call that tool without restriction.
This tutorial walks you through adding Intended authority governance to a PydanticAI agent. By the end, every tool call your agent makes will be evaluated against your policies, risk-scored, and recorded in an immutable audit trail.
What You Will Build
You will build a PydanticAI agent that manages cloud infrastructure. The agent can list servers, restart services, and modify security group rules. Without governance, the agent can do all three actions without limits. With Intended, the agent can list servers freely, restart services with automatic approval during business hours, and modify security groups only with human approval.
Prerequisites
You need Python 3.11 or later, a PydanticAI installation, and a Intended account with an API key. Sign up at intended.so for a free tier account.
pip install pydantic-ai intendedStep 1: Define Your Agent and Tools
Start with a standard PydanticAI agent:
from pydantic_ai import Agent, RunContext
from pydantic import BaseModel
from dataclasses import dataclass
@dataclass
class InfraDeps:
cloud_client: CloudClient
environment: str
class ServerInfo(BaseModel):
server_id: str
status: str
region: str
agent = Agent(
"openai:gpt-4o",
deps_type=InfraDeps,
system_prompt="You are an infrastructure management assistant.",
)
@agent.tool
async def list_servers(ctx: RunContext[InfraDeps]) -> list[ServerInfo]:
"""List all servers in the current environment."""
return await ctx.deps.cloud_client.list_servers(ctx.deps.environment)
@agent.tool
async def restart_service(
ctx: RunContext[InfraDeps], server_id: str, service_name: str
) -> str:
"""Restart a service on a specific server."""
await ctx.deps.cloud_client.restart_service(server_id, service_name)
return f"Service {service_name} restarted on {server_id}"
@agent.tool
async def modify_security_group(
ctx: RunContext[InfraDeps],
group_id: str,
rule_type: str,
cidr: str,
port: int,
) -> str:
"""Modify a security group rule."""
await ctx.deps.cloud_client.modify_sg(group_id, rule_type, cidr, port)
return f"Security group {group_id} updated"This agent works. It can call all three tools. There is no governance.
Step 2: Create the Intended Guard
The Intended guard wraps your tools and intercepts every call:
from intended.pydantic_ai import IntendedPydanticGuard
guard = IntendedPydanticGuard(
api_key="intended_live_your_api_key_here",
tenant_id="your-tenant-id",
)There's no domain-pack or risk parameter to set. The engine infers risk from the action text — for each guarded call the helper submits pydantic_ai.tool.<tool_name> and classifies it against the Open Intent taxonomy, so policy keyed on the tool (e.g. "security-group modifications require approval") drives the verdict. Fail-closed is the default: anything but an explicit ALLOW raises.
Step 3: Create a Protected Agent
Now rebuild the agent with Intended-protected tools. The key is using PydanticAI's tool decorator with the guard's protect method:
protected_agent = Agent(
"openai:gpt-4o",
deps_type=InfraDeps,
system_prompt="You are an infrastructure management assistant.",
)
@protected_agent.tool
@guard.protect
async def list_servers(ctx: RunContext[InfraDeps]) -> list[ServerInfo]:
"""List all servers in the current environment."""
return await ctx.deps.cloud_client.list_servers(ctx.deps.environment)
@protected_agent.tool
@guard.protect
async def restart_service(
ctx: RunContext[InfraDeps], server_id: str, service_name: str
) -> str:
"""Restart a service on a specific server."""
await ctx.deps.cloud_client.restart_service(server_id, service_name)
return f"Service {service_name} restarted on {server_id}"
@protected_agent.tool
@guard.protect
async def modify_security_group(
ctx: RunContext[InfraDeps],
group_id: str,
rule_type: str,
cidr: str,
port: int,
) -> str:
"""Modify a security group rule."""
await ctx.deps.cloud_client.modify_sg(group_id, rule_type, cidr, port)
return f"Security group {group_id} updated"Each tool now carries @guard.protect directly beneath @agent.tool (order matters — @guard.protect must be the inner decorator so it wraps the tool body). Before the body runs, the guard authorizes pydantic_ai.tool.<name>; on ALLOW it proceeds, and on ESCALATE, DENY, or any engine error it raises PermissionError and the side effect never happens. No per-tool intent/params wiring — the decorator is the whole integration.
Step 4: Handle blocked calls
Because @guard.protect raises PermissionError when the engine doesn't return ALLOW, PydanticAI surfaces that to the model as a tool error, and the agent can explain why it could not proceed. If you'd rather branch yourself (e.g. to route an escalation into your own queue), call the underlying SDK directly instead of the decorator:
decision = guard.client.authorize(
action="restart_service(server_id=web-01)",
actor="infra-management-agent",
system="pydantic-ai",
)
if decision.allowed:
... # proceed; decision.token is the signed authority token
elif decision.escalated:
... # awaiting human approval: decision.escalation_id
else:
... # decision.denied — do not execute; decision.reason explains whyStep 5: Configure Policies
You don't author policies from the Python SDK — you manage them in the Intended console or via the policy API and CLI (policy-pack-install). Out of the box the conservative starter policy pack already gives sensible defaults: benign actions are allowed, production-affecting actions are escalated for human approval, and privileged or sensitive actions are denied by default. From there you tune rules per action — for example, allow restart_service during business hours and always escalate security-group changes — keyed on the action the guard submits (pydantic_ai.tool.<name>). No rules are required to get safe behavior; the deny-by-default posture means an un-configured action errs toward escalation, not silent allow.
Step 6: Run and Observe
Run the agent:
async def main():
deps = InfraDeps(
cloud_client=CloudClient(),
environment="production",
)
result = await protected_agent.run(
"List all servers, then restart the nginx service on srv-001, "
"and open port 443 on sg-prod-web",
deps=deps,
)
print(result.data)The agent will successfully list servers (low risk, auto-approved), restart nginx (approved if during business hours, escalated otherwise), and attempt to modify the security group (always escalated for human review). Each decision is recorded with full risk scores, policy evaluation details, and a signed token.
What You Get
Every tool call is now governed. The engine scores risk from the action text and the agent's behavioral history, and the audit trail records every decision with cryptographic proof. Your compliance team can verify any ALLOW decision independently against the public key — the signed authority token stands on its own.
The agent's behavior is unchanged from its perspective. It still reasons, plans, and calls tools. The governance layer is transparent. The latency overhead is tens of milliseconds per decision. The security improvement is the difference between "the agent can do anything it has a permission for" and "the agent can do what policy explicitly authorizes, with proof."
Install the SDK, configure your policies, and wrap your tools. Your PydanticAI agents are now operating under authority.