Wrap a deny.sh vault entry as a LlamaIndex FunctionTool.
LlamaIndex's FunctionTool.from_defaults turns any Python function into a tool the agent can invoke by name. The deniable pattern resolves the credential inside that function, makes the privileged HTTPS call, and returns only a narrowed DTO to the agent. The key never enters the LLM context window, regardless of how many agent steps run.
Prerequisites
pip install llama-index-core llama-index-llms-openai deny-sh requests(Python), ornpm install llamaindex @llamaindex/workflow deny-sh zod(TypeScript).- 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. - An OpenAI API key in
OPENAI_API_KEY; thellama-index-llms-openaipackage reads it automatically.
Python: FunctionTool + FunctionAgent
Show code — ~32 lines, python
# pip install llama-index-core llama-index-llms-openai deny-sh requests
import os, asyncio, requests, json
from llama_index.core.tools import FunctionTool
from llama_index.core.agent.workflow import FunctionAgent
from llama_index.llms.openai import OpenAI
from deny_sh import vault_get
def get_invoice(id: str) -> str:
"""Look up a Stripe invoice by id and return a narrowed summary."""
# 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"),
})
invoice_tool = FunctionTool.from_defaults(fn=get_invoice)
agent = FunctionAgent(
tools=[invoice_tool],
llm=OpenAI(model="gpt-4o"),
system_prompt="You are a billing assistant.",
)
async def main():
result = await agent.run("Look up invoice in_1Abc123XYZ")
print(result)
asyncio.run(main())Why this is the deniable shape
The Stripe key is resolved and consumed entirely inside get_invoice's scope. FunctionAgent sees only the function's name, its docstring (used as the tool description), the argument schema inferred from the type hints, and the narrowed return string. OpenAI sees the same. The key never crosses any wire that isn't deny.sh → your server → Stripe. Returning a narrowed DTO also prevents raw upstream error bodies or response headers from leaking into the model's next turn.
TypeScript: tool() + agent()
The llamaindex npm package exposes a tool() helper that pairs a Zod schema with an async handler. @llamaindex/workflow provides the agent() factory that drives the tool-calling loop.
Show code — ~35 lines, typescript
// npm install llamaindex @llamaindex/workflow deny-sh@^2.0.4 zod
import { tool, OpenAI } from 'llamaindex';
import { agent } from '@llamaindex/workflow';
import { vaultGet } from 'deny-sh/client';
import { z } from 'zod';
const invoiceTool = tool({
name: 'get_invoice',
description: 'Look up a Stripe invoice by id and return a narrowed summary',
parameters: z.object({ id: z.string() }),
execute: 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,
});
},
});
const billingAgent = agent({
tools: [invoiceTool],
llm: new OpenAI({ model: 'gpt-4o' }),
systemPrompt: 'You are a billing assistant.',
});
const result = await billingAgent.run('Look up invoice in_1Abc123XYZ');
console.log(result.message.content);Multi-tenant: factory-scoped FunctionTools
For per-tenant agent platforms, wrap the tool constructor in a factory that closes over each tenant's vault password and deny.sh API key. A fresh FunctionAgent is built per request; no shared credential state can bleed between tenants.
Show code — ~20 lines, python
from llama_index.core.tools import FunctionTool
from llama_index.core.agent.workflow import FunctionAgent
from llama_index.llms.openai import OpenAI
from deny_sh import vault_get
import requests, json
def tools_for_tenant(tenant_pw: str, tenant_api_key: str):
def get_invoice(id: str) -> str:
"""Look up a Stripe invoice by id for this tenant."""
# closure captures tenant_pw + tenant_api_key — no fallback to shared key
stripe_key = vault_get("stripe-prod", tenant_pw,
api_key=tenant_api_key)
r = requests.get(f"https://api.stripe.com/v1/invoices/{id}",
headers={"Authorization": f"Bearer {stripe_key}"})
body = r.json()
return json.dumps({"id": body.get("id"),
"amount_due": body.get("amount_due"), "status": body.get("status")})
return [FunctionTool.from_defaults(fn=get_invoice)]
# per-request: build a fresh agent with this tenant's tools
async def handle_request(tenant_pw: str, tenant_api_key: str, query: str):
tools = tools_for_tenant(tenant_pw, tenant_api_key)
ag = FunctionAgent(tools=tools, llm=OpenAI(model="gpt-4o"))
return await ag.run(query)Each tenant's vault entries are cryptographically isolated at rest. A compromised tenant vault password reveals only that tenant's labels — other tenants' ciphertexts remain opaque. Keep tenant_pw in your auth layer, not in the agent prompt or tool arguments visible to the model.
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 'FunctionAgent' from 'llama_index.agent'
The old llama_index.agent import path was removed in llama-index-core 0.11+. FunctionAgent and ReActAgent now live at llama_index.core.agent.workflow.
Fix: replace from llama_index.agent import ... with from llama_index.core.agent.workflow import FunctionAgent. Also confirm you are importing FunctionTool from llama_index.core.tools, not the old llama_index.tools path. Run pip show llama-index-core to confirm version ≥ 0.11.
agent never selects the tool — FunctionTool missing description
FunctionTool.from_defaults uses the function's docstring as the tool description sent to the LLM. If the docstring is absent or a single word like """invoice""", the model has no signal to pick the tool over generating a direct answer.
Fix: write a one-sentence docstring that describes what the tool does and what argument it expects — e.g. """Look up a Stripe invoice by id and return amount and status.""" The clearer the description, the more reliably the model invokes the tool. Alternatively, pass an explicit description= kwarg to FunctionTool.from_defaults.
ModuleNotFoundError: No module named 'llama_index.llms.openai'
LlamaIndex splits LLM providers into separate pip packages. The OpenAI class is in llama-index-llms-openai, which is not installed by pip install llama-index-core alone.
Fix: pip install llama-index-llms-openai. If you installed the meta-package (pip install llama-index), re-check your virtual environment — the meta-package pins are sometimes stale. Pin explicitly: pip install "llama-index-core>=0.12" "llama-index-llms-openai>=0.3".
KeyError: 'VAULT_PW' or deny_sh.DenyError: DENY_API_KEY not set
The deny-sh Python client reads DENY_API_KEY from the environment automatically. VAULT_PW is read inside your tool function via os.environ["VAULT_PW"]. Neither value should appear in the agent system prompt or tool arguments — only in the server environment.
Fix: set both variables before the process starts (export VAULT_PW=... DENY_API_KEY=...), or use python-dotenv with a .env file outside version control. In production use your platform's secret store. Never pass either value as a function argument that the LLM could read.
vault_get error: label_not_found when the vault entry exists
A wrong vault password returns label_not_found, not invalid_password. This is deliberate — the wire shape is identical so an attacker cannot enumerate which labels exist by probing passwords.
Fix: confirm the vault password matches exactly what was used when the label was stored via vault_store. If you have rotated the password, you must re-store every label under the new password — old ciphertexts will not decrypt with the new one. In multi-tenant setups, verify you are passing tenant_pw (not the global VAULT_PW) when calling with api_key=tenant_api_key.
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.