Integrations OpenAI Agents SDK
openai agents sdk

Wrap a deny.sh vault entry as an Agents SDK tool.

The OpenAI Agents SDK runs the tool loop for you: 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 narrowed DTO. A fail-closed leak sweep throws if the raw secret ever appears in the return, so the key never enters the model's context window.

Jump to code ↓

Prerequisites

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 this is the deniable shape

The Stripe key is resolved + consumed entirely inside lookup. The Agents SDK and the model provider see only the input args ({ id }) and the narrowed return DTO. The key never crosses any wire that isn't deny.sh → your server → Stripe. 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.

Multi-tenant

For per-tenant 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 wrong vault password returns label_not_found, not invalid_password. That's deliberate; the wire shape is identical so an attacker can't enumerate which labels exist by probing passwords.

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