NewRelease 0.12.3 is out: Engine v0.12.3, Dashboard 0.5.2, Helm 0.12.3, the four SDKs, CLI 0.8.2 and MCP 0.12.3Release 0.12.3 is outSee what's new →
Intermediate Architecture

Namespace Isolation

⏱ ~30 min to implement 📦 Requires: Dakera v0.12+

Serve many tenants from a single Dakera instance with each tenant's memory in its own namespace. The server checks every request's key against the namespace it touches, not just your application, so a misconfigured API call or a leaked tenant key cannot reach another tenant's data. This is the foundation of any multi-tenant AI memory architecture.

Set Up Tenant Isolation →
Prerequisites
  • Running Dakera server (see Quickstart)
  • An admin key for provisioning (server-side only)
  • Per-tenant API keys pinned to each tenant's namespace

Problem

In multi-tenant or multi-user applications, data isolation is not optional — it is a hard requirement for security and compliance. The failure modes when isolation is absent are severe:

Dakera keeps every agent's memories in its own namespace, _dakera_agent_{agent_id}, with its own vector and BM25 indexes and its own knowledge-graph edges. Give each tenant its own agent ID and a key pinned to that namespace: a key pinned to _dakera_agent_tenant-a gets a 403 on tenant-b regardless of what parameters are passed in the API call.

Application-Layer Checks Are Not Enough

Many teams implement tenant isolation by passing a user_id filter in every query. This relies on correct code in every call site: one bug, one missing parameter, one junior developer using the wrong query method, and data leaks across tenants. Namespace isolation enforces the boundary in the server: a pinned key is checked against the namespace of every request, whatever your code sends.

Architecture

Each tenant gets its own agent ID, and with it an isolated namespace (_dakera_agent_tenant-{id}) containing its own memory stack:

Namespace Architecture Diagram

Architecture of per-tenant namespaces. Requests for Acme Corp carry a key pinned to _dakera_agent_tenant-acme-corp and requests for Betacorp a key pinned to _dakera_agent_tenant-betacorp. Dakera checks the key on every request, so the Acme key only reaches the tenant-acme-corp agent and the Betacorp key only the tenant-betacorp agent, each with its own indexes, knowledge graph and lifecycle policy. A separate admin key, kept server-side and used only for provisioning, creates agents, pins keys and deletes namespaces.Architecture of per-tenant namespaces. Requests for Acme Corp carry a key pinned to _dakera_agent_tenant-acme-corp and requests for Betacorp a key pinned to _dakera_agent_tenant-betacorp. Dakera checks the key on every request, so the Acme key only reaches the tenant-acme-corp agent and the Betacorp key only the tenant-betacorp agent, each with its own indexes, knowledge graph and lifecycle policy. A separate admin key, kept server-side and used only for provisioning, creates agents, pins keys and deletes namespaces.
Each tenant gets its own agent namespace and a key that reaches nothing else; the admin key only provisions.Full size ↗Full size ↗

Step-by-Step Implementation

Implementation

# --- Tenant onboarding (admin key, server-side only) ---
# 1. Create the tenant's agent, and with it the namespace _dakera_agent_tenant-acme-corp
#    (POST /v1/agents is v0.12.2; on older servers the first stored memory creates it)
curl -X POST http://localhost:3000/v1/agents \
  -H "Authorization: Bearer $DAKERA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agent_id": "tenant-acme-corp"}'

# 2. Create a write key pinned to that namespace; keep the returned key in your secrets manager
curl -X POST http://localhost:3000/v1/namespaces/_dakera_agent_tenant-acme-corp/keys \
  -H "Authorization: Bearer $DAKERA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "acme-corp-app", "scope": "write"}'

# --- Tenant A: store and recall with its own key ---
curl -X POST http://localhost:3000/v1/memory/store \
  -H "Authorization: Bearer $ACME_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "tenant-acme-corp",
    "content": "Acme Corp Q3 roadmap: launch Omega feature in September. Team of 4 engineers.",
    "importance": 0.9,
    "tags": ["roadmap", "confidential"]
  }'

curl -X POST http://localhost:3000/v1/memory/recall \
  -H "Authorization: Bearer $ACME_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agent_id": "tenant-acme-corp", "query": "Q3 roadmap", "top_k": 5}'

# --- Tenant B's key cannot reach Tenant A's namespace ---
curl -X POST http://localhost:3000/v1/memory/recall \
  -H "Authorization: Bearer $BETACORP_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agent_id": "tenant-acme-corp", "query": "roadmap", "top_k": 5}'
# 403 {"code": "NAMESPACE_ACCESS_DENIED", "status": 403, ...}

# --- Audit: every agent on the server (admin key) ---
curl http://localhost:3000/v1/agents \
  -H "Authorization: Bearer $DAKERA_ADMIN_KEY"

# --- Offboarding: delete the namespace and all its data ---
curl -X DELETE http://localhost:3000/admin/namespaces/_dakera_agent_tenant-acme-corp \
  -H "Authorization: Bearer $DAKERA_ADMIN_KEY"
import os
import requests
from dakera import DakeraClient

DAKERA_URL = "http://localhost:3000"
ADMIN_KEY = os.environ["DAKERA_ADMIN_KEY"]  # server-side only, never exposed to tenants


def admin(method: str, path: str, **kwargs):
    """Admin routes (provisioning and offboarding) called over REST."""
    r = requests.request(method, f"{DAKERA_URL}{path}",
                         headers={"Authorization": f"Bearer {ADMIN_KEY}"}, **kwargs)
    r.raise_for_status()
    return r.json() if r.content else None


class TenantMemoryService:
    """Each tenant gets its own agent (so its own namespace) and a key pinned to it."""

    def __init__(self, secrets):
        self._secrets = secrets  # your secrets manager
        self._clients: dict[str, DakeraClient] = {}

    @staticmethod
    def agent_id(tenant_id: str) -> str:
        return f"tenant-{tenant_id}"

    @staticmethod
    def namespace(tenant_id: str) -> str:
        return f"_dakera_agent_tenant-{tenant_id}"

    def provision_tenant(self, tenant_id: str) -> None:
        """On signup: create the agent (v0.12.2) and a write key pinned to its namespace."""
        admin("POST", "/v1/agents", json={"agent_id": self.agent_id(tenant_id)})
        key = admin("POST", f"/v1/namespaces/{self.namespace(tenant_id)}/keys",
                    json={"name": f"{tenant_id}-app", "scope": "write"})
        self._secrets.put(f"dakera/{tenant_id}", key["key"])
        self._secrets.put(f"dakera/{tenant_id}/key_id", key["key_id"])

    def client(self, tenant_id: str) -> DakeraClient:
        if tenant_id not in self._clients:
            self._clients[tenant_id] = DakeraClient(
                base_url=DAKERA_URL, api_key=self._secrets.get(f"dakera/{tenant_id}"))
        return self._clients[tenant_id]

    def store_memory(self, tenant_id: str, content: str, **kwargs):
        return self.client(tenant_id).store_memory(
            agent_id=self.agent_id(tenant_id), content=content, **kwargs)

    def recall(self, tenant_id: str, query: str, top_k: int = 5):
        return self.client(tenant_id).recall(
            agent_id=self.agent_id(tenant_id), query=query, top_k=top_k)

    def offboard_tenant(self, tenant_id: str) -> None:
        """Account deletion / GDPR erasure: revoke the key, then delete the namespace and its data."""
        ns = self.namespace(tenant_id)
        admin("DELETE", f"/v1/namespaces/{ns}/keys/{self._secrets.get(f'dakera/{tenant_id}/key_id')}")
        admin("DELETE", f"/admin/namespaces/{ns}")
        self._clients.pop(tenant_id, None)


# --- Usage ---
service = TenantMemoryService(secrets=my_secrets_manager)
service.provision_tenant("acme-corp")
service.provision_tenant("betacorp")

service.store_memory("acme-corp", "Q3 roadmap: launch Omega feature in September. 4 engineers assigned.",
                     importance=0.9, tags=["roadmap", "confidential"])

for m in service.recall("acme-corp", "Q3 product roadmap").memories:
    print(f"[{m.importance:.2f}] {m.content}")

service.offboard_tenant("acme-corp")  # GDPR erasure
import { DakeraClient } from '@dakera-ai/dakera';

const DAKERA_URL = 'http://localhost:3000';
const ADMIN_KEY = process.env.DAKERA_ADMIN_KEY!; // server-side only

// Admin routes (provisioning and offboarding) called over REST
async function admin(method: string, path: string, body?: unknown) {
  const res = await fetch(`${DAKERA_URL}${path}`, {
    method,
    headers: { Authorization: `Bearer ${ADMIN_KEY}`, 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`${method} ${path}: ${res.status}`);
  return res.status === 204 ? null : res.json();
}

class TenantMemoryService {
  private clients = new Map<string, DakeraClient>();
  constructor(private secrets: { get(k: string): string; put(k: string, v: string): void }) {}

  agentId = (tenantId: string) => `tenant-${tenantId}`;
  namespace = (tenantId: string) => `_dakera_agent_tenant-${tenantId}`;

  async provisionTenant(tenantId: string) {
    await admin('POST', '/v1/agents', { agent_id: this.agentId(tenantId) }); // v0.12.2
    const key = await admin('POST', `/v1/namespaces/${this.namespace(tenantId)}/keys`,
      { name: `${tenantId}-app`, scope: 'write' });
    this.secrets.put(`dakera/${tenantId}`, key.key);
  }

  private client(tenantId: string) {
    if (!this.clients.has(tenantId)) {
      this.clients.set(tenantId, new DakeraClient({ baseUrl: DAKERA_URL, apiKey: this.secrets.get(`dakera/${tenantId}`) }));
    }
    return this.clients.get(tenantId)!;
  }

  storeMemory(tenantId: string, content: string, opts: { importance?: number; tags?: string[] } = {}) {
    return this.client(tenantId).storeMemory(this.agentId(tenantId), { content, ...opts });
  }

  recall(tenantId: string, query: string, topK = 5) {
    return this.client(tenantId).recall(this.agentId(tenantId), query, { top_k: topK });
  }

  async offboardTenant(tenantId: string) {
    await admin('DELETE', `/admin/namespaces/${this.namespace(tenantId)}`);
    this.clients.delete(tenantId);
  }
}

// --- Usage ---
const service = new TenantMemoryService(mySecretsManager);
await service.provisionTenant('acme-corp');
await service.storeMemory('acme-corp', 'Q3 roadmap: Omega feature in September', {
  importance: 0.9,
  tags: ['roadmap', 'confidential'],
});
const { memories } = await service.recall('acme-corp', 'Q3 product roadmap');
console.log(memories.map(m => `[${m.importance}] ${m.content}`));
await service.offboardTenant('acme-corp'); // GDPR erasure
use std::collections::HashMap;
use dakera_client::{DakeraClient, RecallRequest, StoreMemoryRequest};

/// One client per tenant, built from the key pinned to its agent namespace
/// (keys are created by your onboarding service, see the REST tab).
struct TenantService {
    admin: DakeraClient, // server-side only
    clients: HashMap<String, DakeraClient>,
}

fn agent_id(tenant_id: &str) -> String {
    format!("tenant-{tenant_id}")
}

impl TenantService {
    async fn store_memory(&self, tenant_id: &str, content: &str, importance: f64) -> dakera_client::Result<()> {
        self.clients[tenant_id]
            .store_memory(StoreMemoryRequest::new(agent_id(tenant_id), content).with_importance(importance))
            .await?;
        Ok(())
    }

    async fn print_recall(&self, tenant_id: &str, query: &str) -> dakera_client::Result<()> {
        let found = self.clients[tenant_id]
            .recall(RecallRequest::new(agent_id(tenant_id), query).with_top_k(5))
            .await?;
        for m in &found.memories {
            println!("{:.2} {}", m.score, m.content);
        }
        Ok(())
    }

    async fn offboard_tenant(&mut self, tenant_id: &str) -> dakera_client::Result<()> {
        self.clients.remove(tenant_id);
        self.admin.delete_namespace(&format!("_dakera_agent_tenant-{tenant_id}")).await?;
        Ok(())
    }
}
// One client per tenant, built from the key pinned to its agent namespace
// (keys are created by your onboarding service, see the REST tab).
type TenantService struct {
    admin   *dakera.Client // server-side only
    clients map[string]*dakera.Client
}

func agentID(tenantID string) string { return "tenant-" + tenantID }

func (s *TenantService) StoreMemory(ctx context.Context, tenantID, content string, importance float32) error {
    _, err := s.clients[tenantID].StoreMemory(ctx, agentID(tenantID), dakera.StoreMemoryRequest{
        Content:    content,
        Importance: &importance,
    })
    return err
}

func (s *TenantService) PrintRecall(ctx context.Context, tenantID, query string) error {
    found, err := s.clients[tenantID].Recall(ctx, agentID(tenantID), dakera.RecallRequest{Query: query, TopK: 5})
    if err != nil {
        return err
    }
    for _, m := range found.Memories {
        fmt.Printf("[%.2f] %s\n", m.Score, m.Content)
    }
    return nil
}

func (s *TenantService) OffboardTenant(ctx context.Context, tenantID string) error {
    delete(s.clients, tenantID)
    return s.admin.DeleteNamespace(ctx, "_dakera_agent_tenant-"+tenantID)
}

Building a multi-tenant AI product?

Dakera handles tenant isolation out of the box: one agent namespace per tenant and one pinned key, two API calls to provision.

Set Up Tenant Isolation →

Before / After Memory State

Before: one agent for every tenant, separated by tags
-- One agent for everyone: agent_id "app" --
[user_id: acme] Q3 roadmap: Omega in Sept
[user_id: acme] Hiring 4 ML engineers
[user_id: betacorp] Competitor analysis: Acme
[user_id: betacorp] Patent filing for widget
[user_id: acme] Budget: $2.4M allocated

# A recall without the user_id tag filter returns:
recall(query="company plans") ->
  [acme]    Q3 roadmap: Omega in Sept
  [betacorp] Competitor analysis: Acme  ← LEAK
  [acme]    Budget: $2.4M allocated
  [betacorp] Patent filing for widget   ← LEAK

One missing filter parameter leaks Betacorp's confidential data to Acme. Application-layer bugs become security incidents.

After: per-tenant agent namespaces
-- agent tenant-acme-corp (_dakera_agent_tenant-acme-corp) --
[0.9] Q3 roadmap: Omega feature in Sept
[0.8] Hiring 4 ML engineers
[0.85] Budget: $2.4M allocated

-- agent tenant-betacorp (_dakera_agent_tenant-betacorp) --
[0.9] Competitor analysis: Acme Corp
[0.95] Patent filing for widget X

# The Betacorp key is pinned to _dakera_agent_tenant-betacorp
recall(key=betacorp-key, query="company plans") ->
  [0.9] Competitor analysis...
  [0.95] Patent filing...
  (a recall for tenant-acme-corp answers 403)

The key decides what a request can reach. Even with a bug that omits filters, a tenant key cannot read another tenant's namespace.

Common Namespace Architectures

Per-User Isolation

Each user gets their own agent (user-{user_id}, namespace _dakera_agent_user-{user_id}) with a key pinned to that namespace only. Total isolation: no cross-user leakage possible. Suitable for consumer apps with strict privacy requirements.

Per-Tenant with Sub-User Namespaces

An organization agent (org-acme) for team-shared knowledge, plus per-user agents (org-acme-user-alice, org-acme-user-bob) for individual preferences. A key has one scope, so Alice holds two keys: a write key pinned to _dakera_agent_org-acme-user-alice and a read key pinned to _dakera_agent_org-acme. From v0.12.2, a prefix pattern such as _dakera_agent_org-acme-* lets the organization's backend key reach every user agent of that organization. Suitable for B2B SaaS.

Per-Agent Isolation (see Multi-Agent Pattern)

Each agent role has a private agent (agent-researcher, agent-writer) plus a shared collaboration agent (team-shared). Combines isolation with collaboration. See the Multi-Agent Shared Memory pattern for full details.

Hierarchical Org Namespaces

Enterprise deployments with multiple business units: corp-{corp_id} > dept-{dept_id} > user-{user_id}. Each level is its own agent with keys pinned to it. A department holds a write key on its dept agent and a read key on the corp agent; user keys are pinned to their own agent only. An admin key with no namespace restriction audits across all of them.

SDK Reference

OperationPythonTypeScriptPurpose
Create agentPOST /v1/agents (v0.12.2; otherwise the first store)Provision a new tenant
Pin a keyPOST /v1/namespaces/_dakera_agent_{id}/keysKey that reaches one tenant
List agentsGET /v1/agentsAdmin audit of all tenants
Get policyget_memory_policy("_dakera_agent_tenant-xyz")getMemoryPolicy('_dakera_agent_tenant-xyz')Check lifecycle config per namespace
Storestore_memory(agent_id="tenant-xyz", content=...)storeMemory('tenant-xyz', {content})Write to the tenant's namespace
Recallrecall(agent_id="tenant-xyz", query=..., top_k=5)recall('tenant-xyz', query, {top_k: 5})Search only within the tenant
Delete namespaceDELETE /admin/namespaces/_dakera_agent_{id}GDPR erasure / offboarding
Batch forgetbatch_forget(BatchForgetRequest(...))batchForget({agent_id, filter})Bulk delete by filter within one agent

Real-World Example: SaaS Customer Support Platform

A customer support SaaS platform serves 500 business customers. Each business's conversations, customer profiles, and support knowledge base must be completely isolated from every other business. Here is how the namespace architecture maps to the product:

Deleting a Tenant's Data

DELETE on /admin/namespaces/{ns} deletes a namespace and all its data. It needs an admin key; an admin key pinned to the namespace may call it for that namespace. Revoke the tenant's key in the same offboarding step (a DELETE on the key under /v1/namespaces/{ns}/keys), and remember that backups taken before the deletion still hold the data until you delete them.

Request Routing: Namespace Access Control Flow

This diagram shows how every API request is validated against namespace permissions before any data operation is performed — ensuring tenant isolation is enforced at the infrastructure level.

Workflow of the access check on every request. Your backend sends a request with a Bearer key and an agent_id. Dakera authenticates the key: a missing, invalid or expired key ends with 401. It then checks whether the key is pinned to the namespace of that agent, _dakera_agent_{agent_id}. If it is not, the request ends with 403 NAMESPACE_ACCESS_DENIED. If it is, the data operation runs inside that namespace only and the result returns with that tenant's data alone.Workflow of the access check on every request. Your backend sends a request with a Bearer key and an agent_id. Dakera authenticates the key: a missing, invalid or expired key ends with 401. It then checks whether the key is pinned to the namespace of that agent, _dakera_agent_{agent_id}. If it is not, the request ends with 403 NAMESPACE_ACCESS_DENIED. If it is, the data operation runs inside that namespace only and the result returns with that tenant's data alone.
The namespace check happens before any data is touched, so a key for one tenant gets a 403 on every other.Full size ↗Full size ↗

Performance Considerations

241 B
Longest agent ID, so _dakera_agent_{id} fits one path segment
100
Namespace grants per key, each at most 255 bytes
4
Key scopes: read, write, admin, super_admin

Pinning adds no calls: a tenant's request carries its key and agent ID as usual, and the server checks one against the other. Quotas are per namespace too: with hard enforcement (the default), a write that would take a tenant over its quota is refused with 413 QUOTA_EXCEEDED before anything is written, so one tenant cannot fill the store for the others.

The main performance consideration is index size per namespace. Each namespace has its own vector index (an exact scan up to 1,000 vectors, HNSW above), so a tenant's recall latency follows its own size, not other tenants'. Set lifecycle policies per namespace (default TTLs per memory type) to age out short-lived memories and keep each tenant's index lean.

Edge Cases

1. Agent creation race on concurrent signups

Two users sign up simultaneously with the same organization, triggering two concurrent POST /v1/agents calls for org-acme. The call is idempotent: the first answers 201 with created: true, the second 200 with created: false and leaves the agent unchanged. Fix: nothing to catch; make sure only one of the two flows creates the tenant's key, for example with a lock on the tenant ID in your signup flow.

2. Admin key exposure

An admin key with no namespace restriction reaches every namespace. If it leaks, all tenant data is accessible. Fix: the admin key should never be used at runtime for tenant operations. Use it only for provisioning. Store it in a hardware security module (HSM) or secrets manager. Rotate it on any suspected compromise.

3. Orphaned namespaces from failed offboarding

Your offboarding flow fails after the user record is deleted but before the namespace is deleted. The namespace persists with data but no corresponding user. Fix: use an event-driven architecture: publish a user.deleted event and have a Dakera cleanup consumer handle the namespace deletion with retry logic and a dead-letter queue for failures.

4. Tenant names in identifiers

Agent IDs appear in namespace names, audit entries and logs, and a pinned key's namespace listing shows it only the namespaces it can reach, but anyone holding a broader key sees every name. Fix: use opaque IDs for agent IDs rather than human-readable identifiers. tenant-550e8400-e29b-41d4-a716 says nothing about the customer; tenant-acme-corp does.

5. Memory import between namespaces

A legitimate business request: migrate tenant data from tenant-acme-old to tenant-acme-new after an account restructure. Fix: export the old agent's memories with GET /v1/export?agent_id=tenant-acme-old&format=jsonl and import them with POST /v1/import for tenant-acme-new; an import into another agent is rewritten to the importing agent. The key needs read on the old namespace and write on the new one.

Separate Agent IDs Are Not Enough Without Pinned Keys

Separate agent IDs give each tenant its own namespace, but a key that reaches every namespace (the root key, or a key whose namespaces are null or ["*"]) can query any of them. If your backend serves every tenant with one such key, isolation again depends on your code passing the right agent_id. Pin a key to each tenant's namespace for true tenant isolation.

Advanced Configuration: Per-Namespace Lifecycle Policies & Rate Limiting

Per-Tenant Lifecycle Policy

Different tenants may need different memory lifecycle policies. A policy is per namespace; read it, change the fields you need and write it back:

import os
from dakera import DakeraClient

admin = DakeraClient(base_url="http://localhost:3000", api_key=os.environ["DAKERA_ADMIN_KEY"])

# Free-tier tenant: episodic memories expire after 30 days, writes are rate limited
ns = "_dakera_agent_tenant-free-user-123"
policy = admin.get_memory_policy(ns)
policy.episodic_ttl_seconds = 2592000  # 30 days
policy.rate_limit_enabled = True
policy.rate_limit_stores_per_minute = 60
admin.set_memory_policy(ns, policy)

# Enterprise tenant: keep the defaults (no default TTL)
print(admin.get_memory_policy("_dakera_agent_tenant-enterprise-corp"))

Bulk Tenant Provisioning

import os
import requests

URL = "http://localhost:3000"
HEADERS = {"Authorization": f"Bearer {os.environ['DAKERA_ADMIN_KEY']}"}

def provision_tenants(tenant_ids: list[str]) -> None:
    """Create each tenant's agent (v0.12.2); repeating the call for an existing agent is harmless."""
    for tid in tenant_ids:
        r = requests.post(f"{URL}/v1/agents", json={"agent_id": f"tenant-{tid}"}, headers=HEADERS)
        if r.ok:
            print(f"tenant-{tid}: {'created' if r.json().get('created') else 'already there'}")
        else:
            print(f"Failed to provision {tid}: {r.status_code}")

Tenant Inventory Reconciliation

PREFIX = "_dakera_agent_tenant-"

def reconcile_namespaces(namespace_names: list[str], active_tenant_ids: set[str]) -> None:
    """namespace_names: the names from GET /v1/namespaces (admin key)."""
    for ns in namespace_names:
        if ns.startswith(PREFIX) and ns[len(PREFIX):] not in active_tenant_ids:
            print(f"ORPHANED: {ns}")
            # DELETE /admin/namespaces/{ns} once you have confirmed it
Try it live

Run this pattern against a real Dakera.

The Playground executes live API calls in your browser. No account, no install. Then run it on your own server in five minutes.