Integrations Vercel AI SDK
vercel ai sdk

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.

Jump to code ↓

Prerequisites

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

Pattern caveat

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 version6.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 SettingsEnvironment 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.

Where to go next