Wire deny.sh into the OpenAI Responses API function-calling loop.
The Responses API is OpenAI's current function-calling surface and the migration target for the Assistants API (which is deprecated and shuts down on 26 August 2026). The shape is a turn loop: send input + tools to client.responses.create, inspect response.output for function_call items, resolve them server-side, then send a follow-up call with function_call_output items and previous_response_id. The deniable pattern resolves the credential between the tool call and the tool result, so the real key never enters the conversation state.
If you have an existing Assistants integration: OpenAI's deprecations page confirms a 26 August 2026 shutdown. The deniable pattern is the same; the API surface is different. Migrate to Responses now; the snippet below is drop-in equivalent.
Prerequisites
npm install openai deny-sh(orpip install openai deny-sh requestsfor Python).openaiv6 or later.- A deny.sh tenant API key + a vault entry, set up at /dashboard/vault.
- An OpenAI API key.
TypeScript: Responses API turn loop
Show code — ~58 lines, typescript
// npm install openai deny-sh@^2.0.4
import OpenAI from 'openai';
import { vaultGet } from 'deny-sh/client';
const client = new OpenAI();
const tools = [{
type: 'function' as const,
name: 'get_invoice',
description: 'Look up a Stripe invoice by id',
strict: true,
parameters: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
additionalProperties: false,
},
}];
let resp = await client.responses.create({
model: 'gpt-5.5',
input: 'Look up invoice in_1Abc123XYZ',
tools,
});
// Walk function_call items, resolve server-side, send results back.
while (true) {
const calls = resp.output.filter(o => o.type === 'function_call');
if (calls.length === 0) break; // model is done; resp.output_text is the answer
const outputs = [];
for (const call of calls) {
if (call.name === 'get_invoice') {
const { id } = JSON.parse(call.arguments);
// resolve the credential INSIDE the tool boundary
const stripeKey = await vaultGet('stripe-prod', process.env.VAULT_PW!);
const r = await fetch(`https://api.stripe.com/v1/invoices/${id}`, {
headers: { Authorization: `Bearer ${stripeKey}` },
});
// narrowed DTO: pick only the fields the model needs. Never return
// raw upstream JSON, error bodies, or response headers to the model.
const body = r.ok ? await r.json() : null;
const dto = body
? { id: body.id, amount_due: body.amount_due, status: body.status }
: { error: 'invoice_lookup_failed', status: r.status };
outputs.push({
type: 'function_call_output' as const,
call_id: call.call_id,
output: JSON.stringify(dto),
});
}
}
resp = await client.responses.create({
model: 'gpt-5.5',
previous_response_id: resp.id, // chain server-side state
input: outputs,
tools,
});
}Why this is the deniable shape
OpenAI's infrastructure sees only: the tool schema (no key), the model's tool-call arguments (no key), and the tool output (the invoice JSON, no key). The Stripe key never appears in the input, the tool definition, the system prompt, or any function_call_output. A prompt-injection that tries to print STRIPE_KEY returns the model's apology because the key was never in its context window.
Python variant
Show code — ~53 lines, python
# pip install openai deny-sh requests
import os, json, requests
from openai import OpenAI
from deny_sh import vault_get
client = OpenAI()
tools = [{
"type": "function",
"name": "get_invoice",
"description": "Look up a Stripe invoice by id",
"parameters": {
"type": "object",
"properties": {"id": {"type": "string"}},
"required": ["id"],
"additionalProperties": False,
},
}]
resp = client.responses.create(
model="gpt-5.5",
input="Look up invoice in_1Abc123XYZ",
tools=tools,
)
while True:
calls = [o for o in resp.output if o.type == "function_call"]
if not calls:
break # model is done; resp.output_text is the answer
outputs = []
for call in calls:
if call.name == "get_invoice":
args = json.loads(call.arguments)
# resolve the 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/{args['id']}",
headers={"Authorization": f"Bearer {stripe_key}"})
# narrowed DTO: never feed raw upstream JSON back to the model
if r.ok:
b = r.json()
dto = {"id": b.get("id"), "amount_due": b.get("amount_due"), "status": b.get("status")}
else:
dto = {"error": "invoice_lookup_failed", "status": r.status_code}
outputs.append({
"type": "function_call_output",
"call_id": call.call_id,
"output": json.dumps(dto),
})
resp = client.responses.create(
model="gpt-5.5",
previous_response_id=resp.id,
input=outputs,
tools=tools,
)Multi-tenant Responses
The Responses API chains state through previous_response_id rather than a long-lived Assistant or Thread. Per-tenant deniability is therefore per-conversation: each end-user has their own deny.sh tenant API key and vault entries, and the credential resolver only runs against THAT tenant's vault inside the tool boundary.
Cross-tenant deniability is enforced at the deny.sh layer: tenant A's vault_get cannot return tenant B's plaintext even if the labels collide. If you store previous_response_id on your end to resume sessions, scope that storage per tenant too; OpenAI does not enforce tenancy across response chains.
Troubleshooting
The first five errors a launch-day developer hits when copy-pasting the snippets above. Click to expand the fix.
BadRequestError: Assistants API has been deprecated
OpenAI deprecated the Assistants API on 2025-08-20; shutdown is 2026-08-26. Old tutorials use client.beta.assistants.create(...) + Threads + Runs. The current path is the Responses API (client.responses.create(...)) + Conversations API for state.
Fix: replace client.beta.assistants + client.beta.threads with client.responses.create(model, input, tools, previous_response_id). The function-calling shape is the same; only the wrapper changed.
response has no .tool_calls on first call
The Responses API emits a list of output items, not the old tool_calls array. Each item has a type field; you iterate and dispatch on type == 'function_call'.
Fix: for item in response.output: if item.type == 'function_call': dispatch(item.name, item.arguments). The call_id on the item is what you echo back on the function_call_output input on the next turn.
OPENAI_API_KEY or DENY_API_KEY not loading in serverless
Vercel/Netlify/Lambda functions get env vars at cold-start only. If you've added a new secret since the last deployment, redeploy or trigger a fresh invocation. process.env.OPENAI_API_KEY being undefined in production usually means the secret was added but the function wasn't redeployed.
Fix: confirm via a one-shot diagnostic route that returns typeof process.env.OPENAI_API_KEY. Remove it after confirming. Never log the value itself.
previous_response_id conversations leak across tenants
OpenAI's Responses API stores chain state server-side keyed by your API key, not by tenant. If you reuse one OpenAI key across tenants and pass previous_response_id from a different tenant's session, the model sees the wrong tenant's history.
Fix: scope previous_response_id storage in your database by your tenant id. Optionally use one OpenAI key per high-stakes tenant. The deny.sh layer keeps credentials isolated; OpenAI's conversation state is a separate boundary you have to enforce.
vault_get returned the wrong key after rotation
If you ran vault_store('stripe-prod', new_key) with a different tenant password than the original, the old ciphertext is still under the original password. vault_get returns whichever ciphertext decrypts under the supplied password.
Fix: always rotate vault entries with the same tenant password you originally registered, OR roll the password globally and re-store every label under it in one sweep. There is no "merge passwords" path; that would defeat deniability.
Production checklist
- Register a decoy SHA-256 at /dashboard/decoy-alerts: when a decoy controlData ever hits
/api/decrypt, you get paged through Datadog / PagerDuty / Slack within ~5 seconds. - Wire your Datadog / PagerDuty / Slack at /dashboard/webhooks.
- Audit chain: /dashboard/audit records every vault hit with an RFC 3161 receipt for 365 days.
- The Responses API
instructionsfield is visible to the model; do not paste credentials, passwords, or vault passwords there. - Narrow what you return to the model. The example returns the full Stripe invoice JSON. For sensitive APIs, return a DTO with only the fields the model needs; never return upstream auth errors or response headers.
- For OpenAI compliance (SOC 2, BAA): deny.sh is the data-residency surface for the credentials, not OpenAI. Confirm your DPA covers both.