Resolve vault credentials inside an AutoGen tool function.
Microsoft AutoGen v0.4 (autogen-agentchat) defines tools as plain async Python functions passed directly to AssistantAgent. The deniable pattern resolves the credential inside the tool boundary, makes the privileged HTTPS call, and returns only a narrowed DTO to the agent. The key never enters the message history the model sees.
Prerequisites
pip install autogen-agentchat autogen-ext[openai] deny-sh- 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 orsystem_message. - An OpenAI API key in
OPENAI_API_KEY(consumed byOpenAIChatCompletionClient).
Python: AssistantAgent + async tool function
Show code — ~32 lines, python
# pip install autogen-agentchat autogen-ext[openai] deny-sh requests
import asyncio, os, requests, json
from autogen_agentchat.agents import AssistantAgent
from autogen_ext.models.openai import OpenAIChatCompletionClient
from deny_sh import vault_get
async def get_invoice(id: str) -> str:
"""Look up a Stripe invoice by id and return status and amount due."""
# 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}"},
)
body = r.json()
# narrowed DTO: never return raw upstream response bodies
return json.dumps({
"id": body.get("id"),
"amount_due": body.get("amount_due"),
"status": body.get("status"),
})
model_client = OpenAIChatCompletionClient(model="gpt-4o")
agent = AssistantAgent(
name="billing_agent",
model_client=model_client,
tools=[get_invoice],
system_message="You are a billing assistant. Use tools to look up invoices.",
)
async def main():
result = await agent.run(task="Look up invoice in_1Abc123XYZ")
print(result.messages[-1].content)
asyncio.run(main())Why this is the deniable shape
The Stripe key is resolved and consumed entirely inside get_invoice's scope. AutoGen's AssistantAgent infers the tool schema from the function signature and docstring — it sees the input args (id: str) and the narrowed return string. The OpenAIChatCompletionClient 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 into the model's message history.
Streaming variant with run_stream
For long-running agentic tasks, swap agent.run() for agent.run_stream() and consume the async iterator. The deniable shape is identical — credential resolution stays inside the tool boundary regardless of streaming mode.
Show code — ~16 lines, python
from autogen_agentchat.base import TaskResult
from autogen_agentchat.messages import AgentEvent, ChatMessage
async def main_stream():
async for event in agent.run_stream(task="Look up invoice in_1Abc123XYZ"):
if isinstance(event, (AgentEvent, ChatMessage)):
print(f"[{event.source}] {event.content}", flush=True)
elif isinstance(event, TaskResult):
print(f"\nFinal: {event.messages[-1].content}")
break
asyncio.run(main_stream())Multi-tenant: factory pattern
For per-tenant agent platforms, build a factory that captures each tenant's vault password and deny.sh API key in a closure. Construct a fresh AssistantAgent per request so tool functions are scoped to that tenant only — no shared credential fallback is possible.
Show code — ~22 lines, python
def build_agent_for_tenant(tenant_pw: str, tenant_api_key: str) -> AssistantAgent:
async def get_invoice(id: str) -> str:
"""Look up a Stripe invoice for this tenant by id."""
# tenant_pw and tenant_api_key captured in closure — never shared
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 AssistantAgent(
name="billing_agent",
model_client=OpenAIChatCompletionClient(model="gpt-4o"),
tools=[get_invoice],
system_message="You are a billing assistant.",
)
# per-request: resolve tenant credentials from your own store, then build
agent = build_agent_for_tenant(tenant_pw=pw, tenant_api_key=key)
result = await agent.run(task="Look up invoice in_1Abc123XYZ")Each tenant's vault entries are cryptographically isolated. A compromise of one tenant's vault password exposes only that tenant's labels — not any other tenant's credentials or the platform operator's own keys.
Troubleshooting
The first five errors a launch-day developer hits when copy-pasting the snippets above. Click to expand the fix.
ModuleNotFoundError: No module named 'autogen_agentchat'
You are importing from the old v0.2 package (import autogen / from autogen import ConversableAgent), which installs as pyautogen. AutoGen v0.4 restructured into separate packages and the top-level autogen namespace no longer ships AssistantAgent or the new agent-chat primitives.
Fix: pip install autogen-agentchat autogen-ext[openai]. Update all imports to from autogen_agentchat.agents import AssistantAgent and from autogen_ext.models.openai import OpenAIChatCompletionClient. Remove any pyautogen or old autogen pins from your requirements file to avoid conflicts.
ModuleNotFoundError: No module named 'autogen_ext'
autogen-agentchat was installed but the model-client extension package was not. OpenAIChatCompletionClient lives in autogen-ext, which is a separate install with an optional dependency group for OpenAI.
Fix: pip install "autogen-ext[openai]". The [openai] extra pulls in openai>=1.3 and the required async transport. If you need Anthropic or Azure instead, use [anthropic] or [azure] respectively.
Agent never calls the tool — loops on text replies instead
AssistantAgent infers the tool call schema from the function's type hints and docstring. If either is missing, AutoGen cannot generate a valid JSON schema for the tool and the model never receives a usable tool definition — so it answers in plain text.
Fix: ensure every tool function has (a) typed parameters (id: str), (b) a typed return annotation (-> str), and (c) a non-empty docstring. The docstring becomes the tool's description field; vague or absent descriptions cause the model to ignore the tool even when it is registered.
KeyError: 'VAULT_PW' or KeyError: 'DENY_API_KEY'
The tool function reads os.environ["VAULT_PW"] at call time. If the environment variable is not set in the process running the agent, the lookup raises KeyError the first time the model invokes the tool.
Fix: set VAULT_PW and DENY_API_KEY in your server environment (Fly secrets, Render env vars, a .env loaded with python-dotenv before agent construction). Never pass them as part of system_message or task — that puts the value inside the LLM context window and defeats the deniable shape.
vault_get error: label_not_found when the vault entry exists
A wrong vault password returns label_not_found, not invalid_password. That is deliberate: the wire shape is identical so an attacker cannot enumerate which labels exist by probing passwords. If you are in a multi-tenant setup, the most common cause is passing the platform operator's vault password instead of the tenant-specific password captured in the closure.
Fix: confirm the password passed to vault_get is the same one used in vault_store for that label. If the password has been rotated, all labels stored under the old password must be re-stored under the new one — old ciphertexts will not decrypt with a new password regardless of the label name matching.
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.