Namespace Isolation
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 →- 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:
- Cross-tenant data leakage: User A's recall query returns results from User B's memory store due to a missing filter parameter or a bug in application-layer access checks
- Noisy recall: A single large tenant's memory volume dominates search results for smaller tenants sharing the same namespace
- Compliance failures: GDPR right-to-erasure, HIPAA data segmentation, or SOC2 access control requirements cannot be met if all tenants share a single memory partition
- Cascading deletions: A "delete user data" operation that queries by tag instead of namespace may accidentally affect other users if tags collide
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.
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:
- Isolated indexes: each namespace has its own HNSW and BM25 indexes, so search operates only within the tenant's memories
- Isolated knowledge graph: graph edges are keyed by namespace, so entity relationships are tenant-specific
- Isolated lifecycle policy: memory policies (TTL, decay, deduplication, rate limits) are set per namespace
- Pinned API keys: one key per tenant, pinned to that namespace; Dakera checks it on every request
Namespace Architecture Diagram
Step-by-Step Implementation
-
Choose your namespace naming conventionPick a consistent pattern before you start. The agent ID is the tenant boundary, and its namespace is
_dakera_agent_{agent_id}. Common choices:tenant-{tenant_id}for SaaS (e.g.,tenant-acme-corp),user-{user_id}for per-user isolation (e.g.,user-u_8f3k2j), ororg-{org_id}for organizational boundaries. Agent IDs are at most 241 bytes. Prefer opaque IDs to human-readable names. -
Create the tenant's agent on signupHook your onboarding flow to
POST /v1/agentswith the admin key (v0.12.2). It creates the agent's namespace before the first memory, so a key can be pinned to it right away; it answers 201 for a new agent and 200 for an existing one, so a retry is safe. Keep the admin key server-side only; never expose it to clients. -
Issue a scoped API key per tenantAfter creating the agent, issue a key pinned to its namespace
_dakera_agent_{agent_id}withPOST /v1/namespaces/{ns}/keysand"scope": "write". Store this key securely (environment variable or secrets manager) and provide it to your backend service for that tenant's requests. Rotate keys on schedule and on any suspected compromise. -
Route all memory operations through the scoped keyYour API layer should instantiate a Dakera client using the tenant's scoped key for every request. Use a per-request client instantiation or a keyed client pool; never share a single admin-key client across tenants. The server checks the key against the namespace of each request, so no additional filtering code is needed.
-
Delete the namespace on tenant offboardingWhen a tenant deletes their account (or triggers a GDPR erasure request), revoke its key and call
DELETE /admin/namespaces/{ns}on_dakera_agent_{agent_id}, which deletes the namespace and all its data. An admin key pinned to that namespace may call it too. -
Audit tenants with the agent listPeriodically call
GET /v1/agents(orGET /v1/namespaces, which marks each namespace as agent or data from v0.12.2) to audit which tenants exist, compare against your user database, and detect orphaned namespaces from deactivated accounts. Automate this as a weekly cron job that flags any namespace with no corresponding active tenant record.
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 erasureimport { 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 erasureuse 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.
Before / After Memory State
-- 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.
-- 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
| Operation | Python | TypeScript | Purpose |
|---|---|---|---|
| Create agent | POST /v1/agents (v0.12.2; otherwise the first store) | Provision a new tenant | |
| Pin a key | POST /v1/namespaces/_dakera_agent_{id}/keys | Key that reaches one tenant | |
| List agents | GET /v1/agents | Admin audit of all tenants | |
| Get policy | get_memory_policy("_dakera_agent_tenant-xyz") | getMemoryPolicy('_dakera_agent_tenant-xyz') | Check lifecycle config per namespace |
| Store | store_memory(agent_id="tenant-xyz", content=...) | storeMemory('tenant-xyz', {content}) | Write to the tenant's namespace |
| Recall | recall(agent_id="tenant-xyz", query=..., top_k=5) | recall('tenant-xyz', query, {top_k: 5}) | Search only within the tenant |
| Delete namespace | DELETE /admin/namespaces/_dakera_agent_{id} | GDPR erasure / offboarding | |
| Batch forget | batch_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:
- Agent per business:
tenant-{business_id}created on account signup. Contains all conversation histories, customer profiles, and KB articles. - Pinned key per business: a key with the prefix pattern
_dakera_agent_tenant-{business_id}*(v0.12.2) reaches the business agent and its customer agents. The platform's backend uses this key for all Dakera API calls on behalf of that business. The key is stored in the platform's secrets manager, associated with the business_id. - Customer agents: each customer of a business is its own agent,
tenant-{business_id}-customer-c_9k2m, so every customer has its own namespace inside the business's key pattern. - GDPR compliance: When a customer requests data deletion, the platform deletes that customer's agent namespace (or removes part of it with
batch_forget()and a filter). When a business cancels, the platform deletes the namespaces of the business agent and its customer agents and revokes the key.
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.
Performance Considerations
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 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
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.