Wrap a deny.sh vault entry as a LangChain tool.
LangChain v1 streamlined the agent loop to a single create_agent / createAgent call plus the @tool / tool() wrapper. The deniable pattern resolves the credential inside the tool function, makes the privileged HTTPS call, and returns only the API result to the agent. Use LangGraph for advanced orchestration; this page covers the standard v1 path.
Prerequisites
pip install langchain langchain-openai deny-sh(or the TypeScript equivalentnpm install langchain @langchain/openai deny-sh zod).- A deny.sh tenant API key + a vault entry under it (see /dashboard/vault).
- A vault wrap password (
VAULT_PW) in your server environment, never the agent prompt.
Python: v1 create_agent + @tool
Show code — ~28 lines, python
# pip install langchain langchain-openai deny-sh requests
import os, requests, json
from langchain.agents import create_agent
from langchain.tools import tool
from langchain_openai import ChatOpenAI
from deny_sh import vault_get
@tool
def get_invoice(id: str) -> str:
"""Look up a Stripe invoice by id."""
# 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/{id}",
headers={"Authorization": f"Bearer {stripe_key}"})
# narrowed DTO: never return raw upstream response bodies
body = r.json()
return json.dumps({"id": body.get("id"), "amount_due": body.get("amount_due"),
"status": body.get("status")})
agent = create_agent(
model=ChatOpenAI(model="gpt-5.4"),
tools=[get_invoice],
system_prompt="You are a billing assistant.",
)
result = agent.invoke({"messages": [
{"role": "user", "content": "Look up invoice in_1Abc123XYZ"},
]})
print(result["messages"][-1].content)Why this is the deniable shape
The Stripe key is resolved + consumed entirely inside get_invoice's scope. The LangChain agent sees the input args ({ id }) and the narrowed return string. The OpenAI provider sees the same. The key never crosses any wire that isn't deny.sh → your server → Stripe. The narrowed DTO prevents leaking raw upstream response headers or error stacks to the model.
TypeScript: v1 createAgent + tool()
Show code — ~34 lines, typescript
// npm install langchain @langchain/openai deny-sh@^2.0.4 zod
import { createAgent, tool } from 'langchain';
import { ChatOpenAI } from '@langchain/openai';
import { vaultGet } from 'deny-sh/client';
import { z } from 'zod';
const invoiceTool = tool(
async ({ 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 response bodies
const body = await r.json();
return JSON.stringify({ id: body.id, amount_due: body.amount_due, status: body.status });
},
{
name: 'get_invoice',
description: 'Look up a Stripe invoice by id',
schema: z.object({ id: z.string() }),
}
);
const agent = createAgent({
model: new ChatOpenAI({ modelName: 'gpt-5.4' }),
tools: [invoiceTool],
systemPrompt: 'You are a billing assistant.',
});
const result = await agent.invoke({
messages: [{ role: 'user', content: 'Look up invoice in_1Abc123XYZ' }],
});
console.log(result.messages[result.messages.length - 1].content);Multi-tenant + LangGraph variants
For per-tenant agent platforms, factor the credential resolver into a closure that captures the tenant context. The closure scopes the deny.sh API key, vault label, and tenant passwords together so no fallback to a shared key is possible.
Show code — ~11 lines, python
def tools_for_tenant(tenant_id, tenant_pw, tenant_api_key):
@tool
def get_invoice(id: str) -> str:
"""Look up a Stripe invoice for this tenant."""
stripe_key = vault_get("stripe-prod", tenant_pw,
api_key=tenant_api_key)
... # same shape as above, narrowed DTO returned
return [get_invoice]
# per-request: build the agent fresh with this tenant's tools
agent = create_agent(model=llm, tools=tools_for_tenant(tid, pw, key))For complex multi-step orchestration (parallel tool calls, human-in-the-loop, branching), drop down to LangGraph: the @tool functions above attach to StateGraph nodes the same way. Each tenant's vault entries are cryptographically isolated; a leak of one tenant's vault password compromises that tenant only.
Troubleshooting
The first five errors a launch-day developer hits when copy-pasting the snippets above. Click to expand the fix.
ImportError: cannot import name 'create_agent' from 'langchain'
You're on LangChain < 1.0. The create_agent + @tool path is LangChain v1 (Python) / v1 (JS). Earlier versions still use AgentExecutor + create_tool_calling_agent, which works but isn't the recommended 2026 path.
Fix Python: pip install -U "langchain>=1.0" "langchain-openai>=1.0". Fix TypeScript: npm i langchain@^1. Re-run after upgrade.
TypeError: tool decorator got unexpected keyword 'inputSchema'
LangChain's @tool infers the schema from the function signature + docstring; you don't pass inputSchema like in the Vercel SDK. For explicit Pydantic schemas use StructuredTool.from_function(args_schema=MySchema).
Fix: drop the inputSchema kwarg. Add proper type hints to the wrapped function so the inferred schema matches what the model emits.
DENY_API_KEY missing or vault_pw env var not set
The Python client reads DENY_API_KEY from the environment by default. In notebooks, os.environ['DENY_API_KEY'] = '...' before importing deny_sh, or use python-dotenv + a .env file outside version control.
Fix: never put DENY_API_KEY or VAULT_PW in a tracked .env.example. Add .env + .env.local to .gitignore. In production, use your platform's secret store (Fly secrets, Render env vars, Vercel project env).
vault_get error: label_not_found when tenant 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, you must re-store every label under the new password; old ciphertexts won't decrypt with the new password.
agent loops, never calls the tool on Python 3.9 / older OpenAI client
LangChain v1 with the OpenAI provider needs the new function-calling shape (tools parameter, not the deprecated functions). Older openai Python clients silently fall back to the legacy path and the agent never emits a tool call.
Fix: pip install -U "openai>=1.50". Confirm Python ≥ 3.10 (LangChain v1 minimum). If you're stuck on 3.9, pin langchain==0.3.* and use the AgentExecutor path instead.
Production checklist
- Set up /dashboard/decoy-alerts with a SHA-256 of a decoy controlData. Get paged when a prompt-injection extracts the decoy.
- Wire /dashboard/webhooks to your existing Datadog / PagerDuty / Slack.
- Pull audit-chain receipts via
GET /v1/audit/receipt/<id>or browse them at /dashboard/audit for regulator-grade evidence. - Use /dashboard/byok if you need customer-managed KMS keys.