Keep real keys private with OpenAI Agents.
Let an agent use your service without reading its key. The OpenAI Agents SDK, a code library for agent workflows, runs the tool loop: the model emits a tool call, the SDK runs your function, and the result returns to the model. The official deny-sh-openai-agents adapter resolves the credential inside that function, makes the privileged call, and returns only a response containing only approved fields (a data transfer object, or DTO). It blocks return values that match the raw secret. Use the deployment checklist below to review the full boundary.
Before you start
- Recommended: the official OpenAI Agents adapter :
pip install deny-sh-openai-agents openai-agents(Python) ornpm i deny-sh-openai-agents @openai/agents zod(TypeScript). It checks returned data for the raw secret and blocks a matching result. Still review logs, errors and transformed values. - Note (TypeScript):
@openai/agentspeers zod v4 (the LangChain and Vercel adapters use zod v3). Installzodalongside so it resolves to v4. - 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.
Python: deny_vault_tool + Agent
Show code : ~32 lines, python
# pip install deny-sh-openai-agents openai-agents requests
import os, requests
from pydantic import BaseModel
from agents import Agent, Runner
from deny_sh_openai_agents import deny_vault_tool
class InvoiceArgs(BaseModel):
id: str
def lookup(stripe_key: str, args: dict) -> dict:
# resolve happens before this runs; stripe_key is live, in-scope only
r = requests.get(f"https://api.stripe.com/v1/invoices/{args['id']}",
headers={"Authorization": f"Bearer {stripe_key}"})
body = r.json()
# narrowed DTO: never the raw key, never the raw upstream body
return {"id": body.get("id"), "amount_due": body.get("amount_due"),
"status": body.get("status")}
invoice_tool = deny_vault_tool(
label="stripe-prod", # or: id="item_abc"
password=os.environ["VAULT_PW"], # server env, never the prompt
name="get_invoice",
description="Look up a Stripe invoice by id",
args_schema=InvoiceArgs,
use=lookup,
)
agent = Agent(
name="Billing",
instructions="Help the user with their invoices.",
tools=[invoice_tool],
)
result = Runner.run_sync(agent, "What is the status of invoice in_1Abc123XYZ?")
print(result.final_output)Why the key stays private
The Stripe key is resolved + consumed entirely inside lookup. The Agents SDK and the model provider see only the input args ({ id }) and the filtered response. The vault sends encrypted data to your server. Your server decrypts it and sends the key to Stripe, not to the model. If use ever returns the raw secret, the leak sweep raises DenyLeakError and the secret never crosses back into the model context.
TypeScript: denyVaultTool + Agent
Show code : ~33 lines, typescript
// npm i deny-sh-openai-agents @openai/agents zod (zod resolves to v4)
import { Agent, run } from '@openai/agents';
import { denyVaultTool } from 'deny-sh-openai-agents';
import { z } from 'zod';
const getInvoice = denyVaultTool({
label: 'stripe-prod', // or: id: 'item_abc' (skips the label scan)
password: process.env.VAULT_PW!, // server env, never the prompt
name: 'get_invoice',
description: 'Look up a Stripe invoice by id',
parameters: z.object({ id: z.string() }),
use: async (stripeKey, { id }) => {
const r = await fetch(`https://api.stripe.com/v1/invoices/${id}`, {
headers: { Authorization: `Bearer ${stripeKey}` },
});
if (!r.ok) return { error: 'invoice_lookup_failed', status: r.status };
const body = await r.json();
// narrowed DTO : never the raw key, never the raw upstream body
return { id: body.id, amount_due: body.amount_due, status: body.status };
},
});
const agent = new Agent({
name: 'Billing',
instructions: 'Help the user with their invoices.',
tools: [getInvoice],
});
const result = await run(agent, 'What is the status of invoice in_1Abc123XYZ?');
console.log(result.finalOutput);Fail-closed by design
The Agents SDK's tool() default error handler swallows thrown errors into a self-heal string for the model to retry. The adapter sets errorFunction: null so the (already secret-scrubbed) DenyToolError / DenyLeakError propagate loud and fail-closed instead of being silently fed back to the model. Same security model as the LangChain and Vercel adapters; only the wiring differs.
Multiple customers
For per-customer agent platforms, build the tool inside a closure that captures the tenant context. The closure scopes the deny.sh API key, vault label, and tenant password together so no fallback to a shared key is possible.
Show code : ~12 lines, typescript
const getInvoiceFor = (tenantId: string, tenantPw: string) =>
denyVaultTool({
label: 'stripe-prod',
password: tenantPw,
clientOptions: { apiKey: tenantKeyFor(tenantId) },
name: 'get_invoice',
description: 'Look up an invoice for the current tenant',
parameters: z.object({ id: z.string() }),
use: async (key, { id }) => { /* ... narrowed DTO ... */ },
});
// per-request: build the agent fresh with this tenant's tool
const agent = new Agent({ name: 'Billing', tools: [getInvoiceFor(tid, pw)] });Two tenants holding the same product cannot decrypt each other's vault entry, even if the labels collide. The deniability boundary is cryptographic, not policy-based; a leak of one tenant's vault password compromises that tenant only.
Troubleshooting
The errors a launch-day developer hits when copy-pasting the snippets above. Click to expand the fix.
peer dep zod@^4 from @openai/agents / two zod versions installed
The OpenAI Agents SDK peers zod v4. The LangChain and Vercel adapters peer zod v3, so a monorepo that uses several adapters can end up with both. The z.object() you pass to denyVaultTool must be the same zod major the SDK resolved.
Fix: in a project using only this adapter, npm i zod resolves v4 cleanly. In a mixed workspace, isolate the OpenAI agent in its own package, or pin zod to v4 at the workspace root and confirm the other adapters tolerate it.
tool error was swallowed, agent retried instead of failing
You rebuilt the tool by hand with tool() instead of using denyVaultTool. The SDK's default errorFunction converts thrown errors into a string the model sees and retries, which defeats fail-closed behaviour.
Fix: use the adapter (it sets errorFunction: null). If you must hand-roll, pass errorFunction: null yourself so DenyToolError / DenyLeakError propagate.
DenyLeakError: narrowed DTO contained the raw secret
This is the leak sweep doing its job. Your use function returned an object that still contains the resolved key somewhere (often a raw upstream response body, an echoed auth header, or an error stack).
Fix: return only the specific fields the model needs. Never spread the whole upstream body or include response headers. The sweep walks the return value (including nested objects and strings) and throws before anything crosses back to the model.
vault error: label_not_found when the password is wrong
A missing label may return vault_label_not_found. Do not assume a wrong vault password returns invalid_password. Check the label, account scope and password separately. The vault is not the core real-and-decoy scheme.
Fix: confirm the tenant password matches what was used in vault_store. If you've rotated, re-store every label under the new password; old ciphertexts won't decrypt with the new one.
ModuleNotFoundError: No module named 'agents' (Python)
The Python package is openai-agents but the import is agents. Installing deny-sh-openai-agents alone doesn't pull the SDK; it's a peer.
Fix: pip install openai-agents (Python ≥ 3.10). Then from agents import Agent, Runner resolves.
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.
What to know before production.
The adapter blocks return values containing the raw secret. Your integration must also keep keys out of logs, errors and other model-accessible tools; transformed or indirect leaks need separate review. Test authorised-tool access before production.