Integrations LangChain
langchain v1

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.

Jump to code ↓

Prerequisites

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