Skip to main content
Pydantic AI is a Python agent framework from the team behind Pydantic: model-agnostic agents with typed, validated inputs and outputs, dependency injection, and composable toolsets.
  • What Pydantic AI ships — a typed agent loop, structured output_type validation, dependency injection (deps_type), tool/toolset composition, streaming, and first-class MCP client support.
  • What it doesn’t ship — a way for those agents to act as a real account: sign up for a SaaS tool, hold a funded card, or spend within a budget you control, per end-user.
That’s the half Naive adds. You keep Pydantic AI’s orchestration; Naive gives each agent a tenant identity, a virtual card, 1,000+ third-party connections, and an Account Kit that bounds exactly what the agent can do — enforced server-side, with human approval on the sensitive actions.

How the pairing works

  • Pydantic AI registers any MCP server as a toolset — pass MCPServerSSE(...) to an Agent (or to agent.run(toolsets=[...])) and its tools are discovered automatically.
  • Naive ships a hosted MCP server and mints per-user sessions — short-lived, revocable SSE endpoints whose tool list is the fused native + third-party toolset, already filtered by that user’s Account Kit.
  • So the integration is: mint a session for one user → hand its SSE URL + scoped bearer to a Pydantic AI agent as a toolset → every tool call runs as that user, gated server-side.
  Pydantic AI (agent.run)               Naive
  ─────────────────────                 ─────
  agent ── tool call ──▶ loop
        │  model picks an MCP tool

  MCPServerSSE(url, headers)  ──────▶  /mcp/sse/sess_…   (per-user session)
                                          │  AccountKit-gated, scoped to one user

                                    connect GitHub · issue a $50 card · run a capability

                                    sensitive? → 202 pending_approval (human-in-the-loop)
Tested against: Naive API v2 (hosted MCP server over SSE, per-user sessions), pydantic-ai 1.x (pydantic_ai.Agent, pydantic_ai.mcp.MCPServerSSE, output_type, per-run toolsets), and the mcp Python package 1.x, on Python 3.10–3.13.Naive’s MCP server uses SSE transport, so this guide uses MCPServerSSE and passes the scoped bearer via its headers argument. In pydantic-ai 1.x, MCPServerSSE is being superseded by the FastMCP-based MCPToolset (same role, still functional until v2) — the swap is one line: MCPToolset(session["mcp"]["url"], headers=session["mcp"]["headers"]). There is no Naive Python SDK today — provision the control plane over the REST API, the dashboard, the CLI, or the Node SDK (@usenaive-sdk/server). Pin your versions and set the model to one you have access to.

Prerequisites

  • A Naive API key (nv_sk_...) — get one from the dashboard.
  • A model provider key for the model that runs the agent (this guide uses OpenAI via OPENAI_API_KEY).
  • Python 3.10–3.13.
pip install pydantic-ai requests
export NAIVE_API_KEY=nv_sk_live_...
export OPENAI_API_KEY=sk-...

Minimal viable integration

The shortest path to a Pydantic AI agent that can actually transact: define a policy, provision a user (control plane, once), then at runtime mint a per-user MCP session and hand it to the agent as a toolset.
1

Define the policy, then provision a user

An Account Kit is the spend/capability policy. Here a tenant user gets a card (capped at $500, approval required), the vault, and an allowlist of apps. Everything the agent does is bounded by this kit — server-side. These are one-time control-plane calls:
# provision.py — run once. No SDK needed; these are plain REST calls.
import os, requests

NAIVE = "https://api.usenaive.ai/v1"
HEAD = {
    "Authorization": f"Bearer {os.environ['NAIVE_API_KEY']}",
    "Content-Type": "application/json",
}

# Control plane: a reusable policy template.
kit = requests.post(f"{NAIVE}/account-kits", headers=HEAD, json={
    "name": "Pro",
    "primitives_config": {
        "cards": {"enabled": True, "requiresApproval": True,
                   "defaults": {"spending_limit_cents": 50000}},
        "vault": {"enabled": True},
    },
    "connections_config": {"mode": "allowlist",
                            "toolkits": ["github", "gmail", "stripe"]},
}).json()

# Provision one of your end-users and assign the kit.
alice = requests.post(f"{NAIVE}/users", headers=HEAD, json={
    "external_id": "user_123",
    "email": "alice@acme.com",
    "account_kit_id": kit["id"],
}).json()

print(alice["id"])  # → set as NAIVE_USER_ID for the agent
2

Mint a per-user MCP session

At runtime, mint a short-lived session scoped to one user. It returns an SSE URL plus a scoped bearer that lives only in the headers — never in the URL — and expires (default 15 min, max 24h):
# agent.py
import os, requests

NAIVE = "https://api.usenaive.ai/v1"
API_HEAD = {"Authorization": f"Bearer {os.environ['NAIVE_API_KEY']}"}
USER_ID = os.environ["NAIVE_USER_ID"]  # Alice, from provisioning

session = requests.post(
    f"{NAIVE}/users/{USER_ID}/sessions",
    headers=API_HEAD,
    json={"ttl_ms": 15 * 60 * 1000},
).json()

# session["mcp"] = {
#   "url": "https://api.usenaive.ai/mcp/sse/sess_...",
#   "headers": {"Authorization": "Bearer nv_sess_..."},
#   "expires_at": "..."
# }
3

Give the agent the session — and let it transact

Wrap the session in MCPServerSSE and register it as the agent’s toolset. Pydantic AI discovers Naive’s tools automatically and runs the typed agent loop for you. Because it’s Pydantic AI, you can also pin a validated output_type so the run returns a typed object, not a blob of text:
import asyncio
from pydantic import BaseModel
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerSSE


class SetupReport(BaseModel):
    github_connect_url: str | None = None
    card_status: str  # "issued" | "pending_approval" | "failed"
    notes: str


naive = MCPServerSSE(
    url=session["mcp"]["url"],
    headers=session["mcp"]["headers"],  # scoped bearer — never in the URL
)

agent = Agent(
    "openai:gpt-5.1",
    toolsets=[naive],
    output_type=SetupReport,
    instructions=(
        "You are Alice's operations agent. Use the Naive tools to act on her "
        "real account. Report the GitHub connect link and the card's status."
    ),
)


async def main():
    async with agent:  # opens the MCP connection for the whole run
        result = await agent.run(
            "Connect Alice's GitHub, then issue a $50 virtual card called "
            "'Ads budget' for our marketing spend."
        )
    print(result.output)  # → a typed SetupReport


asyncio.run(main())
The agent discovers GitHub (naive_connections_connect), returns a connect link for Alice to authorize, and attempts to issue the card (naive_cards_create) — a real card on Alice’s account, capped by her kit. The whole agent loop is Pydantic AI’s; the real-world actions are Naive’s.
That’s the moat in ~40 lines: the same agent that would otherwise just describe spending money now issues a policy-bounded card on a specific user’s account — and hands you a typed SetupReport back.
The session’s tool list is already filtered by Alice’s kit — the agent never sees a tool the policy forbids. To narrow further per agent, register the server with a tool_prefix or compose it with a filtered toolset. To swap the session per request instead of per agent, pass it at run time: await agent.run(prompt, toolsets=[naive]).

Extension: human-in-the-loop spend

Because the kit set cards.requiresApproval: true, the agent cannot silently spend. When it calls the card-issuing tool, Naive freezes the request and the tool result comes back as a pending approval (HTTP 202) instead of a live card:
{
  "status": "pending_approval",
  "approval_id": "65589c8b-e033-4a65-b16c-379211c94429",
  "action": "cards.create",
  "primitive": "cards",
  "title": "Issue virtual card \"Ads budget\"",
  "message": "This action requires human approval before it executes."
}
The immediate 202 response uses action; approval records from the approvals API use action_type. The agent relays the pending status (card_status: "pending_approval" in the typed report) instead of claiming success. Your app then resolves it out of band — and on approval, Naive replays the frozen action server-side:
# resolve.py — operator side, out of band from the agent.
import os, requests

NAIVE = "https://api.usenaive.ai/v1"
HEAD = {"Authorization": f"Bearer {os.environ['NAIVE_API_KEY']}"}
USER_ID = os.environ["NAIVE_USER_ID"]

pending = requests.get(
    f"{NAIVE}/users/{USER_ID}/approvals",
    headers=HEAD, params={"status": "pending"},
).json()["approvals"]

for a in pending:
    # ...show a["title"] / a["action_type"] to a human in your UI...
    requests.post(f"{NAIVE}/users/{USER_ID}/approvals/{a['id']}/approve", headers=HEAD)
    # or: .../approvals/{a['id']}/deny  with {"reason": "over budget"}
See Approvals for the full lifecycle (pending → executed / failed / denied).
To detect the pending state inside the run, register the server with a process_tool_call hook — it wraps every MCP call, so you can inspect the result, tag a pending_approval, and shape how the agent reports it. The server-side gate holds regardless of what the hook does.
Approvals are only enforced for agent (API-key / MCP) calls on real tenant users. A human acting in your dashboard, and agent calls on the operator’s own default user, bypass the gate — so end-user agents stay governed while your own automation isn’t slowed down.

What stays enforced

No matter how the agent is wired, the policy is enforced where it matters — on Naive’s servers, not in your prompt or your agent config:
  • Identity — every action runs as a specific tenant user, fully isolated from your other users.
  • Capability bounds — the Account Kit decides which primitives and which apps the agent can touch (allowlist / blocklist / per-tool).
  • Scoped spend — virtual cards are capped per card and per user; the model can’t raise its own limit.
  • Human-in-the-loop — sensitive actions (cards, domains, KYC, formation, connecting an app) freeze as approvals until a human says yes.
  • Scoped, expiring access — the agent holds a per-user session bearer, not your API key; it expires (default 15 min) and you can DELETE /v1/users/:user_id/sessions/:id to revoke it early.

Next steps