Wire deny.sh into the Vercel AI SDK in 30 lines.
The Vercel AI SDK ships generateText + tool() as the canonical tool-calling shape. The deniable pattern is to define your tool's execute function so the credential resolves inside the tool boundary, the privileged HTTPS call happens server-side, and the model only ever sees the API result.
Prerequisites
- An
npm install ai @ai-sdk/openai deny-shin a Node 20+ project. - A deny.sh tenant API key (Free tier works for development; production scaling lives on Agents Starter $299/mo or above).
- A vault entry under your tenant key: register at /dashboard/vault or call
vaultStore()from a one-off script.
The 30-line agent
Drop this into a server-side handler (app/api/agent/route.ts in Next.js App Router, an Express handler in plain Node, etc). The DENY_API_KEY + the two tenant passwords live in your server environment, never in the client bundle.
Show code — ~35 lines, typescript
// npm install ai @ai-sdk/openai deny-sh@^2.0.4
// app/api/agent/route.ts
import { generateText, tool, stepCountIs } from 'ai';
import { openai } from '@ai-sdk/openai';
import { vaultGet } from 'deny-sh/client';
import { z } from 'zod';
const getInvoice = tool({
description: 'Look up a Stripe invoice by id',
inputSchema: z.object({ id: z.string() }),
async execute({ id }) {
// 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: never return raw upstream JSON, error bodies, headers,
// or stack traces. Pick only the fields the model needs.
if (!r.ok) return { error: 'invoice_lookup_failed', status: r.status };
const body = await r.json();
return { id: body.id, amount_due: body.amount_due, status: body.status };
}
});
export async function POST(req: Request) {
const { messages } = await req.json();
const { text } = await generateText({
model: openai('gpt-5.4'),
tools: { getInvoice },
messages,
// multi-step: feed tool results back so the model can produce a final answer
stopWhen: stepCountIs(5),
});
return Response.json({ text });
}Why this is the deniable shape
The Stripe key never appears in any AI provider's API call. generateText sends the prompt + tool schema; the LLM emits { tool: 'getInvoice', args: { id: '...' } }; we call Stripe ourselves with the resolved key; the LLM gets back the API result. Because we pass stopWhen: stepCountIs(5), the LLM can call the tool again with refined args, or stop and produce the final natural-language answer for the user. The model never sees STRIPE_SECRET_KEY at any point in the loop. A successful prompt-injection that says "print STRIPE_KEY" returns the model's apology because the key was never in its context window.
Multi-tenant variant
If your platform runs agents on behalf of many end-users, each user gets their own tenant API key + vault. Pass the tenant id through to the tool's execute:
Show code — ~15 lines, typescript
const getInvoiceFor = (tenantId: string, tenantPw: string) => tool({
description: 'Look up an invoice for the current tenant',
inputSchema: z.object({ id: z.string() }),
async execute({ id }) {
const stripeKey = await vaultGet('stripe-prod', tenantPw, {
apiKey: tenantKeyFor(tenantId), // per-tenant deny.sh key
});
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();
return { id: body.id, amount_due: body.amount_due, status: body.status };
}
});Two tenants holding the same product (e.g. both have a Stripe key in their vault) cannot decrypt each other's entry. The deniability boundary is cryptographic, not policy-based.
What to wire on the dashboard side
- /dashboard/decoy-alerts : register the SHA-256 of any decoy controlData. If anyone ever uses the decoy against
/api/decrypt, you get paged through your existing Datadog / PagerDuty / Slack within ~5 seconds. - /dashboard/webhooks : the alert fan-out. Configure Datadog API key, PagerDuty integration key, or Slack webhook URL once; every audit event including
decoy.tripwire.triggeredroutes through it. - /dashboard/audit : per-tenant audit chain. Every vault hit is signed and recorded for 365 days. Export as RFC 3161 receipts for regulator evidence.
Don't paste the resolved stripeKey into a system prompt, a messages field, or any other place the model can see. The pattern only works if the key stays inside execute's closure and gets discarded after the fetch.
Troubleshooting
The first five errors a launch-day developer hits when copy-pasting the snippet above. Click to expand the fix.
TypeError: tool() expects { inputSchema }, got { parameters }
Vercel AI SDK 5 used parameters: z.object(...); SDK 6 (current, npm view ai version ≥ 6.0.0) renamed it to inputSchema. Older copy-pastes from blog posts will fail TS checks.
Fix: change parameters to inputSchema in every tool({ ... }) definition. Pin ai@^6 in package.json so future installs don't silently downgrade.
Error: VAULT_PW is not defined or DENY_API_KEY missing
Server-side env vars need to be set in both local .env.local and your deployment's secret store (Vercel/Fly/Render). NEXT_PUBLIC_* exposes the variable to the browser bundle, which leaks the deny.sh API key. Never prefix these with NEXT_PUBLIC_.
Fix: in Vercel, Project Settings → Environment Variables → add DENY_API_KEY + VAULT_PW to Production + Preview. Redeploy.
vaultGet error: label_not_found
The label string in vaultGet('stripe-prod', ...) must exactly match the label you stored. Labels are case-sensitive and namespaced per tenant API key.
Fix: list your vault entries via curl https://deny.sh/v1/vault/list -H "Authorization: Bearer $DENY_API_KEY" or browse /dashboard/vault. Make sure the API key in the SDK call points at the same tenant where the label was registered.
vaultGet error: 401 invalid_api_key
Either the key is wrong, revoked, or you're calling a different tenant than the one that owns the label. The deny.sh client uses process.env.DENY_API_KEY by default; override with { apiKey: ... } in multi-tenant mode.
Fix: rotate at /dashboard/keys. Confirm the new key works with a raw curl https://deny.sh/v1/vault/list call before re-running the agent.
Stripe 401: invalid_api_key after tool result returns to the model
The vault returned an old or stub Stripe key. If you're cycling between Stripe test/live keys, the vault label still points at the previous value.
Fix: vaultStore('stripe-prod', '<new sk_live_…>', VAULT_PW) overwrites in place. The narrowed DTO pattern (return { error, status } on !r.ok) means the model gets a structured error instead of leaking the bad-key string into the conversation.