Keep real keys private with LangChain.
Let an agent act without reading your service key. LangChain v1 connects your tool through a create_agent / createAgent call plus the @tool / tool() wrapper. This integration 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.
Before you start
- Recommended: the official LangChain adapter :
pip install deny-sh-langchain(Python) ornpm i deny-sh-langchain @langchain/core(TypeScript). It checks returned data for the raw secret and blocks a matching result. Still review logs, errors and transformed values. - Or use the core SDK inline (shown below):
pip install langchain langchain-openai deny-sh/npm install langchain @langchain/openai deny-sh zod. - A deny.sh account (free to start), its API key (service access credential) and a vault entry under it (see /dashboard/vault).
- A vault encryption 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 the key stays private
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 vault sends encrypted data to your server. Your server decrypts it and sends the key to Stripe, not to the model. Return only approved fields, and sanitise errors and logs separately.
TypeScript: v1 createAgent + tool()
Show code : ~34 lines, typescript
// npm install langchain @langchain/openai deny-sh@^2.2.0 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);Multiple customers + LangGraph variants
For per-customer 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
Common integration problems. Expand an item for the technical 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 missing label may return vault_label_not_found. Do not assume a wrong vault password returns invalid_password. Check the label, account scope and password separately. The vault is not the core real-and-decoy scheme.
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. Receive alerts when a registered fingerprint reaches our monitored decryption endpoint, not merely when a value is copied.
- 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 reviewable evidence of recorded activity. - Use /dashboard/byok if you need customer-managed KMS keys.