# Pydantic AI

Pydantic AI is the framework where resolving credentials **in code** pays off,
because it already has the seam: `deps_type` and `RunContext` exist so a tool
can read per-run state instead of reaching for a global. That is exactly the
shape a multi-tenant agent needs.

> **Note:** **Start here if seekrit is new:** [three commands](/docs/guides/frameworks#get-running-in-five-minutes) put the keys in an environment, mint a token bound to it, and export `SEEKRIT_TOKEN`. A token reads [everything in its environment](/docs/guides/frameworks#which-secrets-does-the-agent-get), so there is no per-key or per-framework setup to do before any of the below.

## 1. Wrap the process

```bash
seekrit run -- python agent.py
seekrit run -- uvicorn app:app --reload
```

Pydantic AI reads `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` and friends from the
environment, so a single-tenant agent needs nothing more than this.

## 2. Resolve per run, not per process

The interesting case: one agent, many tenants, each with their own credentials.
Put the resolved secrets in the dependencies object and let tools read them from
the context.

```python
from dataclasses import dataclass

import seekrit
from pydantic_ai import Agent, RunContext

@dataclass
class Deps:
    stripe_key: str

agent = Agent("openai:gpt-5.6-terra", deps_type=Deps)

@agent.tool
async def refund(ctx: RunContext[Deps], charge_id: str) -> str:
    # ctx.deps.stripe_key belongs to whoever this run is for
    ...

async def handle(tenant: str, prompt: str) -> str:
    secrets = seekrit.Client(overrides={"tenants": tenant}).resolve()
    result = await agent.run(prompt, deps=Deps(stripe_key=secrets["STRIPE_SECRET_KEY"]))
    return result.output
```

One resolve per run, nothing cached across tenants, and one audit trail per
tenant on the seekrit side. See
[how to model a tenant](/docs/guides/environments) for whether each tenant should
be its own environment, a group slice, or a [lease](/docs/concepts/temporary-access).

## 3. Never hold the key

```python
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider

model = OpenAIChatModel(
    "gpt-5.6-terra",
    provider=OpenAIProvider(
        base_url="http://127.0.0.1:8080/openai/v1",
        api_key="{{seekrit:OPENAI_API_KEY}}",
    ),
)
agent = Agent(model)
```

Or with no source change at all, since Pydantic AI reads them from the
environment:

```bash
export OPENAI_BASE_URL=http://127.0.0.1:8080/openai/v1
export OPENAI_API_KEY='{{seekrit:OPENAI_API_KEY}}'
```

```toml
# seekrit-proxy.toml
listen = "127.0.0.1:8080"

[[route]]
prefix = "/openai"
upstream = "https://api.openai.com"
allow = ["OPENAI_API_KEY"]
methods = ["POST"]
paths = ["/v1/chat/completions"]
```

### Without running the proxy

`OpenAIProvider` takes an `http_client`, so the substitution can happen in
process instead of in a sidecar:

```python
import httpx
from seekrit.transport import AsyncSeekritTransport

provider = OpenAIProvider(
    api_key="{{seekrit:OPENAI_API_KEY}}",
    http_client=httpx.AsyncClient(
        transport=AsyncSeekritTransport(allow={"api.openai.com": ["OPENAI_API_KEY"]}),
    ),
)
```

Weaker than the proxy, since it shares your address space —
[in-process injection](/docs/guides/agent-proxy/in-process) sets out the
trade-off, and pairs well with the `deps` shape above.

## 4. A key one tool may spend and the others may not

Pydantic AI's seam is `WrapperToolset`, whose `call_tool` wraps every tool
invocation with `ctx` — and so `ctx.deps` — in scope. That makes "which
credentials may *this tool* use" a line of configuration:

```bash
pip install 'seekrit[pydantic-ai]'
```

```python
from pydantic_ai import Agent
from pydantic_ai.toolsets import FunctionToolset
from seekrit.pydantic_ai import SeekritToolset

toolset = SeekritToolset(
    FunctionToolset([refund, search]),
    scope=lambda deps: {"tenants": deps.tenant},
    tools={"refund": ["STRIPE_SECRET_KEY"]},
)

agent = Agent("openai:gpt-5.6-terra", deps_type=Deps, toolsets=[toolset])
```

The `refund` tool may substitute the Stripe key and nothing else. `search` may
substitute nothing at all — when `tools` is given it is exhaustive, so a tool not
named there gets an empty allowlist and a prompt-injected `search` call cannot
reach a payment credential.

Inside a tool, make the call through a
[seekrit transport](/docs/guides/agent-proxy/in-process) and hold a placeholder:

```python
import httpx
from seekrit.transport import AsyncSeekritTransport

transport = AsyncSeekritTransport(
    allow={"api.stripe.com": ["STRIPE_SECRET_KEY"]},
    require_scope=True,
)

async def refund(ctx: RunContext[Deps], charge_id: str) -> str:
    """Refund a charge."""
    async with httpx.AsyncClient(transport=transport) as client:
        response = await client.post(
            "https://api.stripe.com/v1/refunds",
            headers={"authorization": "Bearer {{seekrit:STRIPE_SECRET_KEY}}"},
            data={"charge": charge_id},
        )
    return "refunded" if response.is_success else f"refund failed: {response.status_code}"
```

`require_scope=True` is what makes the narrowing a boundary rather than a hint:
with no scope in effect there is nothing to narrow, so the transport refuses
instead of falling back to the unnarrowed allowlist.

### The model call is not a tool call

A toolset only wraps tools. Pydantic AI builds its provider — and its HTTP
client — when the agent is constructed, so there is no per-request model hook to
use. Wrap the run instead, and the scope covers the model call and every tool
call inside it:

```python
from seekrit.pydantic_ai import Scope, use_scope

async def handle(tenant: str, prompt: str) -> str:
    with use_scope(Scope(overrides={"tenants": tenant})):
        result = await agent.run(prompt, deps=Deps(tenant=tenant))
    return result.output
```

The two compose rather than fight: the run establishes the tenant, and the
toolset narrows what each tool may use on top of it. Omit `scope=` from the
toolset and it keeps whatever the surrounding run established.

## Gotchas

- **Don't build a process-wide map of every tenant's secrets.** It is the
  obvious optimisation and it converts one compromised request into a full
  breach. Resolve for the tenant you are serving now.
- **`deps` is per run, module scope is not.** An `Agent(...)` at import time is
  shared by every request; only what you pass to `run()` is per-run. Keep
  credentials on the dependencies object, never on the agent.
- **Tool keys deserve shape 3 more than model keys do.** The `refund` tool above
  is the reason `methods` and `paths` exist on a proxy route: an agent that may
  legitimately create a refund still should not be able to delete a customer.
  Shape 4 is the cheaper version of the same idea.
- **A `scope` that returns group overrides needs a token, not a client.** One
  `seekrit.Client` is bound to the overrides it was built with, so a per-tenant
  setup either lets the transport build a scoped client from `$SEEKRIT_TOKEN` or
  passes `client=` as a callable of the overrides. Passing a single client and
  then re-scoping raises rather than quietly resolving the wrong tenant.
- **Build the transport once, at module scope.** Inside the tool it would get a
  fresh cache on every call, so every tool call would resolve again.
