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
| Credential | Setup |
|---|---|
| Model key, single tenant | 1 |
| Anything per tenant | 2, or 4 with use_scope |
| Model key where the agent runs generated code | 3 |
| 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.