Use promo code BETATEST1 for full access
seekrit
← all posts

Managing secrets in Pydantic AI agents

Frameworks

Pydantic AI reads OPENAI_API_KEY, ANTHROPIC_API_KEY, and the other provider variables from the environment. A single-tenant agent needs one command and no code change:

seekrit secrets import .env --app agent --env development && rm .env
seekrit run -- python agent.py

The reason to read further is multi-tenancy. Pydantic AI has deps_type and RunContext so a tool reads per-run state instead of a global. That is the shape a multi-tenant agent's credentials need, and no other framework gives it to you as directly.

Setup 1: wrap the process

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

The agent reads the provider key from the environment the way it always did. No .env exists on disk.

Setup 2: resolve per run, not per process

One agent, many tenants, each with their own credentials. Put the resolved secrets on the dependencies object and let tools read them from the context:

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. One audit trail per tenant on the seekrit side.

Do not build a process-wide map of every tenant's secrets. It is the obvious optimisation and it turns one compromised request into a full breach. The Agent(...) at module scope is shared by every request. Only what you pass to run() is per run. Keep credentials on deps, never on the agent.

Setup 3: the process never holds the key

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, since the provider reads both from the environment:

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

The seekrit proxy swaps the placeholder for the real key against a default-deny rule list:

# 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 run in process:

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"]}),
    ),
)

This shares your address space, so it is a weaker boundary than the proxy. The in-process injection page sets out the trade-off.

Setup 4: a key one tool may spend

WrapperToolset.call_tool wraps every tool invocation with ctx in scope. SeekritToolset uses that to make "which credentials may this tool use" a line of configuration:

pip install 'seekrit[pydantic-ai]'
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])

refund may substitute the Stripe key and nothing else. tools is exhaustive when given, so search gets an empty allowlist. A prompt-injected search call cannot reach a payment credential.

Inside the tool, make the call through a transport and hold a placeholder:

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 makes the narrowing a boundary. With no scope in effect the transport refuses instead of falling back to the unnarrowed allowlist. Build the transport once, at module scope. Inside the tool it would get a fresh cache on every call.

The model call is not a tool call

A toolset wraps tools only. Pydantic AI builds its provider and HTTP client when the agent is constructed, so there is no per-request model hook. Wrap the run instead:

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 run establishes the tenant. 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.

Which setup for which credential

CredentialSetup
Model key, single tenant1
Anything per tenant2, or 4 with use_scope
Model key where the agent runs generated code3
A tool credential (Stripe, GitHub, a database)4, or 3 with methods and paths set

Setup

Three commands put the keys in an environment, mint a token bound to it, and export SEEKRIT_TOKEN. The frameworks guide has them. How to model a tenant, as its own environment, a group slice, or a lease, is in the environments guide. Values are encrypted before they are stored, so the service holding them cannot read them. The full Pydantic AI page is at /docs/guides/frameworks/pydantic-ai.