Keep real keys private with LlamaIndex.
Let an agent use a service without seeing its key. LlamaIndex’s FunctionTool.from_defaults turns any Python function into a tool the agent can invoke by name. This integration pattern resolves the credential inside that function, makes the privileged HTTPS call, and returns only a response containing only approved fields (a data transfer object, or DTO) to the agent. The key never enters the model’s conversation context, regardless of how many agent steps run.
Before you start
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 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. - 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 the key stays private
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 vault sends encrypted data to your server. Your server decrypts it and sends the key to Stripe, not to the model. Returning a response containing only approved fields (a data transfer object, or 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.2.0 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);Multiple customers: factory-scoped FunctionTools
For per-customer 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
Common integration problems. Expand an item for the technical 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 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 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. 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.