Brainby arc-labs/docs
Guide

Multi-tenant patterns

Serve many tenants correctly — namespace-per-tenant isolation, one key per agent, and a pooled service key with per-request act_as. No user axis, no scope juggling.

Prerequisites
  • A working Brain integration with at least one namespace.
  • A backend framework with per-request handling.
  • Familiarity with the identity walkthrough.
What you'll build

Two implementations of a multi-tenant backend: one key per tenant agent, and a single pooled service key that rebinds identity per request with act_as.

The only isolation primitives

Brain isolates data by namespace (the company / tenant) and agent (the app within it). Every memory, entity, statement, and relation lives in exactly one namespace, and a key can only ever touch its own. There is no separate per-user axis and no client-sent scope — the server derives (namespace, agent) from the key. See Authentication.

That leaves two patterns for serving many tenants.

Provision one API key per (namespace, agent) in the dashboard and use the matching key for that tenant's traffic. Isolation is enforced by the key itself — no rebinding, no scope arguments, nothing to get wrong.

import { BrainHttpClient } from "@brain-db/sdk";

// One client per tenant, keyed by that tenant's API key.
const clients = new Map<string, BrainHttpClient>();

function brainFor(tenantId: string): BrainHttpClient {
  let c = clients.get(tenantId);
  if (!c) {
    c = new BrainHttpClient({ apiKey: keyStore.get(tenantId), baseUrl: BASE_URL });
    clients.set(tenantId, c);
  }
  return c;
}
from brain_db_sdk import BrainHttpClient

_clients: dict[str, BrainHttpClient] = {}

def brain_for(tenant_id: str) -> BrainHttpClient:
    c = _clients.get(tenant_id)
    if c is None:
        c = BrainHttpClient(key_store[tenant_id], base_url=BASE_URL)
        _clients[tenant_id] = c
    return c
use std::collections::HashMap;
use brain_db_sdk::BrainHttpClient;

// One client per tenant, keyed by that tenant's API key.
fn brain_for<'a>(
    clients: &'a mut HashMap<String, BrainHttpClient>,
    key_store: &HashMap<String, String>,
    tenant_id: &str,
) -> &'a BrainHttpClient {
    clients
        .entry(tenant_id.to_string())
        .or_insert_with(|| BrainHttpClient::new(BASE_URL, &key_store[tenant_id]))
}

This is the simplest correct pattern. Its cost is key management — you provision, rotate, and revoke a key per tenant.

Pattern B: pooled service key + act_as (wire)

When managing a key per tenant is too many keys, hold a single service key with the can_act_as grant and set the effective (namespace, agent) per request with act_as. This is wire-only — the HTTP client does not expose act_as.

import { BrainClient } from "@brain-db/sdk";

// One shared, multiplexed connection for the whole backend.
const client = await BrainClient.connect("brain.internal", 9090, {
  auth: { kind: "token", token: new TextEncoder().encode(process.env.BRAIN_SERVICE_TOKEN!) },
});
import { EncodeBuilder, RecallBuilder } from "@brain-db/sdk";

app.post("/chat", async (req, res) => {
  const { namespace, agentId } = tenantOf(req);

  const answer = await client.recall(
    new RecallBuilder(req.body.query).actAs(namespace, agentId).build(),
  );
  await client.encode(
    new EncodeBuilder(req.body.query).actAs(namespace, agentId).build(),
  );

  res.json({ answer });
});
from brain_db_sdk import EncodeBuilder, RecallBuilder

def chat(req):
    namespace, agent_id = tenant_of(req)
    answer = client.recall(RecallBuilder(req.query).act_as(namespace, agent_id).build())
    client.encode(EncodeBuilder(req.query).act_as(namespace, agent_id).build())
    return {"answer": answer}
use brain_db_sdk::{EncodeBuilder, RecallBuilder};

// tenant_of returns (namespace, agent_id): (String, WireUuid). act_as rebinds
// the effective identity per request, so one pooled client serves every tenant.
let (namespace, agent_id) = tenant_of(&req);

let answer = client
    .recall(&RecallBuilder::new(&req.query).act_as(namespace.as_str(), agent_id).build())
    .await?;
client
    .encode(&EncodeBuilder::new(&req.query).act_as(namespace.as_str(), agent_id).build())
    .await?;

If the service key lacks can_act_as, or the requested namespace is outside its allowlist, the call fails with ActAsDenied — surface it, don't retry it. See SDK errors.

What does NOT scope memories

Things people reach for that are not isolation boundaries:

  • Context ids — a context groups related memories for retrieval; it does not isolate one tenant from another.
  • Recall filterssubject, kinds, and confidence floors apply after retrieval within one scope; they are not boundaries.
  • Predicate or name prefixes — putting a tenant id inside content does not stop another scope's reads from matching.

The only isolation primitives are the namespace and the agent the key resolves to.

Never front many tenants with one shared key and no act_as. That collapses every tenant into one (namespace, agent) — the server stores and retrieves all of them together, and provenance collapses to a single principal.

Prefer Pattern A unless key count is genuinely unmanageable. When you use Pattern B, scope the service key's act_as allowlist as narrowly as the deployment allows, and audit which identities it acted as.

Was this page helpful?

On this page