seekrit
Docs/Pydantic AI

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 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.
  • 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.