Skip to content

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