Keep real keys private with Pydantic AI.
Keep each customer’s service keys out of agent conversations. Pydantic AI’s @agent.tool decorator and first-class dependency injection make it the cleanest Python framework for this integration pattern. Register a tool, resolve the credential inside its scope, return a response containing only approved fields (a data transfer object, or DTO). For multi-tenant platforms, inject per-customer vault passwords through deps_type : Pydantic AI passes them to every tool call automatically, with no global state.
Before you start
pip install pydantic-ai deny-sh requests- A deny.sh account (free to start), its API key (service access credential) and a vault entry under it (see /dashboard/vault).
- A vault encryption password (
VAULT_PW) in your server environment : never the agent prompt.
Simple tool: @agent.tool_plain
Use @agent.tool_plain when the tool needs no dependency injection : just args from the model and the environment. This is the minimal deniable shape.
Show code : ~26 lines, python
# pip install pydantic-ai deny-sh requests
import asyncio, os, requests, json
from pydantic_ai import Agent
from deny_sh import vault_get
agent = Agent(
'openai:gpt-5.4',
system_prompt="You are a billing assistant.",
)
@agent.tool_plain
def get_invoice(invoice_id: str) -> str:
"""Look up a Stripe invoice by id. Returns status and amount due."""
# resolve credential INSIDE the tool boundary
stripe_key = vault_get("stripe-prod", os.environ["VAULT_PW"])
r = requests.get(
f"https://api.stripe.com/v1/invoices/{invoice_id}",
headers={"Authorization": f"Bearer {stripe_key}"},
)
body = r.json()
# narrowed DTO: key never reaches the LLM context
return json.dumps({"id": body.get("id"), "amount_due": body.get("amount_due"),
"status": body.get("status")})
async def main():
result = await agent.run("Look up invoice in_1Abc123XYZ")
print(result.output)
asyncio.run(main())Why the key stays private
The Stripe key is resolved and consumed entirely inside get_invoice's scope. Pydantic AI passes the model-provided args (invoice_id) in and receives only the narrowed return string. The integration must keep the key out of agent history, logs and errors. The provider receiving the authorised request still receives the key. The narrowed DTO prevents leaking raw response headers or error stacks to the model.
Multiple customers: @agent.tool + dependency injection
Keep each customer’s service keys out of agent conversations. Pydantic AI’s deps_type is purpose-built for this. Define a @dataclass Deps holding the per-customer vault password and deny.sh API key, declare it on the Agent, and Pydantic AI injects it into every @agent.tool call via RunContext. Keep request-specific credentials separate. Each request carries exactly its tenant's credentials.
Show code : ~38 lines, python
import asyncio, requests, json
from dataclasses import dataclass
from pydantic_ai import Agent, RunContext
from deny_sh import vault_get
@dataclass
class Deps:
tenant_pw: str # per-tenant vault wrap password
deny_api_key: str # per-tenant deny.sh API key
agent = Agent(
'openai:gpt-5.4',
deps_type=Deps,
system_prompt="You are a billing assistant.",
)
@agent.tool
async def get_invoice(ctx: RunContext[Deps], invoice_id: str) -> str:
"""Look up a Stripe invoice by id. Returns status and amount due."""
# deps carry per-tenant credentials; no global fallback possible
stripe_key = vault_get(
"stripe-prod",
ctx.deps.tenant_pw,
api_key=ctx.deps.deny_api_key,
)
r = requests.get(
f"https://api.stripe.com/v1/invoices/{invoice_id}",
headers={"Authorization": f"Bearer {stripe_key}"},
)
body = r.json()
# narrowed DTO: only the fields the model needs
return json.dumps({"id": body.get("id"), "amount_due": body.get("amount_due"),
"status": body.get("status")})
async def handle_request(user_msg: str, tenant_pw: str, deny_api_key: str):
deps = Deps(tenant_pw=tenant_pw, deny_api_key=deny_api_key)
result = await agent.run(user_msg, deps=deps)
return result.output
# per-request invocation : each tenant gets its own deps
asyncio.run(handle_request(
"Look up invoice in_1Abc123XYZ",
tenant_pw="tenant-vault-password",
deny_api_key="dk_live_...",
))Deps scopes credentials to a single agent.run() call. A leaked vault password for tenant A cannot decrypt tenant B's entries : cryptographic isolation is per-customer. Keep each customer’s service keys out of agent conversations. Pydantic AI’s RunContext makes this the natural, idiomatic path rather than a workaround.
Production-scale notes
For high-throughput deployments, a few patterns apply directly on top of the multi-tenant shape above.
- The
Agentinstance is safe to share across requests : it holds no credential state. Instantiate once at module level; build a freshDepsper request. - For FastAPI / Starlette, extract the tenant's
deny_api_keyandtenant_pwfrom your auth middleware and pass them asDepsintoagent.run()inside the route handler. - Each tenant's vault entries are independently encrypted. A compromise of one tenant's vault password does not affect other tenants' entries.
- For parallel tool calls (Pydantic AI supports concurrent tool execution), each concurrent
@agent.toolinvocation receives the sameRunContext: credentials are injected correctly regardless of concurrency. - Use
agent.run_sync()as a convenience wrapper in synchronous contexts, but preferawait agent.run()inside an async framework to avoid blocking the event loop during the vault round-trip.
Troubleshooting
The first five errors a launch-day developer hits with this stack. Click to expand the fix.
AttributeError: 'RunResult' object has no attribute 'data'
result.data was the output accessor in older Pydantic AI releases. It was renamed to result.output in a later release to better reflect that the value is the agent's final output, not raw data.
Fix: replace every result.data with result.output. If you need to support both old and new versions temporarily: getattr(result, 'output', result.data). Pin pydantic-ai>=0.0.36 and use result.output consistently.
TypeError: get_invoice() missing 1 required positional argument: 'ctx'
You decorated the function with @agent.tool but defined it without a RunContext first argument, or vice versa. The two decorators have different calling conventions: @agent.tool always passes RunContext as the first positional arg; @agent.tool_plain passes only the model-provided args.
Fix: if you don't need deps, switch to @agent.tool_plain and remove the ctx parameter. If you do need deps, keep @agent.tool and ensure the signature is def get_invoice(ctx: RunContext[Deps], invoice_id: str).
UserError: deps_type is not set but deps were passed to run() or opposite
Pydantic AI enforces that deps_type on the Agent and the deps= argument to agent.run() agree. If you pass deps=Deps(...) but forgot deps_type=Deps on the agent, or declared deps_type but called agent.run(msg) without deps=, you get a runtime error.
Fix: declare Agent('openai:gpt-5.4', deps_type=Deps, ...) and always pass agent.run(msg, deps=Deps(...)). If you intentionally have no deps, use neither: no deps_type, no deps= argument, and @agent.tool_plain for all tools.
ValueError: Unknown model: 'gpt-5.4' or similar model-string error
Pydantic AI model strings follow the format <provider>:<model-name>, e.g. openai:gpt-5.4, anthropic:claude-opus-4, google-gla:gemini-2.0-flash. Passing a bare model name like 'gpt-5.4' or using a provider prefix that doesn't match the installed provider package will raise a validation error.
Fix: ensure the string includes the provider prefix and that the corresponding provider extra is installed : e.g. pip install 'pydantic-ai[openai]'. Check the Pydantic AI docs for the exact provider string for your model.
vault_get error: label_not_found even when label and password look correct
A missing vault label may return vault_label_not_found. A wrong password is a separate decryption issue. Check the label, account scope and password separately. The vault is not the core real-and-decoy scheme.
Fix: confirm the vault password in Deps.tenant_pw (or os.environ["VAULT_PW"]) exactly matches what was used in vault_store. Also confirm DENY_API_KEY is set : the Python client reads it from the environment. If you've rotated the password, every label must be re-stored under the new password; old ciphertexts cannot be decrypted with a new password.
Production checklist
- Set up /dashboard/decoy-alerts with a SHA-256 of a decoy controlData. Receive alerts when a registered fingerprint reaches our monitored decryption endpoint, not merely when a value is copied.
- Wire /dashboard/webhooks to your existing Datadog / PagerDuty / Slack.
- Pull audit-chain receipts via
GET /v1/audit/receipt/<id>or browse them at /dashboard/audit for reviewable evidence of recorded activity. - Use /dashboard/byok if you need customer-managed KMS keys.