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.
Start here if seekrit is new: three
commands put the keys in
an environment, mint a token bound to it, and export SEEKRIT_TOKEN. A token
reads everything in its
environment, so
there is no per-key or per-framework setup to do before any of the below.
1. Wrap the process
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.
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 for whether each tenant should be its own environment, a group slice, or a lease.
3. Never hold 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 at all, since Pydantic AI reads them from the environment:
export OPENAI_BASE_URL=http://127.0.0.1:8080/openai/v1
export OPENAI_API_KEY='{{seekrit:OPENAI_API_KEY}}'
# 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:
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 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:
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])
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 and hold a placeholder:
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:
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.
depsis per run, module scope is not. AnAgent(...)at import time is shared by every request; only what you pass torun()is per-run. Keep credentials on the dependencies object, never on the agent.- Tool keys deserve shape 3 more than model keys do. The
refundtool above is the reasonmethodsandpathsexist 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
scopethat returns group overrides needs a token, not a client. Oneseekrit.Clientis bound to the overrides it was built with, so a per-tenant setup either lets the transport build a scoped client from$SEEKRIT_TOKENor passesclient=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.