A registry per tenant¶
Tenants on the basic plan get search only. Pro tenants get every tool. One catalogue, one executor.
flowchart LR
CAT[("catalogue<br/>tagged tools")] --> B["subset(tags={'basic'})"]
CAT --> P["subset(tags={'pro'})"]
B --> M1["chat_tools(view) for acme"]
P --> M2["chat_tools(view) for globex"]
M1 --> EX["executor.run_call(call, ctx, registry=view)"]
M2 --> EX
Tag the tools¶
@catalogue.tool(tags={"basic", "pro"})
def search_docs(query: str, ctx: ToolContext) -> str:
"""Search the tenant's documents."""
return f"{ctx.extras['tenant']}: results for {query}"
@catalogue.tool(tags={"pro"})
def export_report(month: str) -> str:
"""Export the monthly report."""
return f"report {month}"
@catalogue.tool(tags={"pro"}, confirm=True)
def delete_workspace(name: str) -> str:
"""Delete a workspace."""
return f"deleted {name}"
Build the views once¶
PLANS = {"acme": "basic", "globex": "pro"} # from your tenant database
views = {plan: catalogue.subset(tags={plan}) for plan in set(PLANS.values())} # built once
executor = Executor(catalogue) # one executor; no approval port: confirm tools are denied
def registry_for(tenant: str) -> Registry:
return views[PLANS[tenant]]
subset returns a new Registry holding the same specs. Build views at start-up, not
per request.
Show and run per tenant¶
for tenant in ("acme", "globex"):
tools = registry_for(tenant)
print(tenant, [t["function"]["name"] for t in chat_tools(tools)]) # what the model sees
ctx = executor.context(thread_id="acme:chat-1", extras={"tenant": "acme"})
search = ToolCall(name="search_docs", arguments={"query": "refunds"})
export = ToolCall(name="export_report", arguments={"month": "2026-09"})
for call in (search, export): # the model may still name a tool it was not shown
result = await executor.run_call(call, ctx, registry=registry_for("acme"))
print(result.status, result.value or (result.error.kind if result.error else ""))
acme ['search_docs']
globex ['search_docs', 'export_report', 'delete_workspace']
ok acme: results for refunds
error tool-not-found
Passing registry= to run_call (or run_many) is what enforces the plan. Hiding a
tool from the model is not enough: a model can name any tool.
| Also scope by tenant | How |
|---|---|
| caches | a thread_id per tenant conversation; never cache_scope="global" for tenant data |
| spend | Cap spend per user, keyed by tenant |
| exports to frameworks or MCP | pass the view, for example structured_tools(view, executor=executor) |
| tools per surface | targets= on a tool, and registry.for_target("mcp") |
Pitfalls¶
| Pitfall | Do instead |
|---|---|
executor.run_call(call, ctx) without registry= |
it uses the executor's full catalogue |
| The tenant id from the model's arguments | take it from your session and put it in ctx.extras |
Files¶
| File | Does |
|---|---|
examples/cookbook_tenants.py |
a tagged catalogue, plan views, per-tenant render and run |
tests/docs/test_cookbook.py |
runs it offline and checks the output |