Integrations › OpenAI Agents SDK
openai agents sdk

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.

Jump to code ↓

Before you start

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

Limits and scope

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.

Review evidence and current status ↗ · Threat model