Skip to content

Multi-Tenant Setup

Heartbit’s daemon mode supports multi-tenant isolation out of the box. Each authenticated user gets isolated memory, workspace, and audit context.

Configure JWT validation in the daemon auth section:

[daemon.auth]
bearer_tokens = ["$ADMIN_API_KEY"] # static keys (optional)
jwks_url = "https://idp.example.com/.well-known/jwks.json" # JWKS endpoint
issuer = "https://idp.example.com" # validate iss claim
audience = "heartbit-daemon" # validate aud claim
user_id_claim = "sub" # JWT claim for user ID (default: "sub")
tenant_id_claim = "tid" # JWT claim for tenant ID (default: "tid")
roles_claim = "roles" # JWT claim for roles (default: "roles")

Both bearer token and JWT auth can be active simultaneously. Bearer tokens are useful for service-to-service calls, while JWT provides per-user identity.

When a request arrives with a valid JWT, Heartbit extracts a UserContext:

  • user_id — from the configured user_id_claim (default: sub)
  • tenant_id — from the configured tenant_id_claim (default: tid)
  • roles — from the configured roles_claim (default: roles)
  • raw_token — the original JWT, carried through for token exchange

This context flows through the entire task lifecycle: agent execution, memory operations, tool calls, and audit records.

Memory is automatically namespaced per tenant and user. The NamespacedMemory wrapper prefixes all memory operations with tenant:{tid}:user:{uid}:

tenant:acme:user:alice: -> Alice's memories in Acme org
tenant:acme:user:bob: -> Bob's memories in Acme org
tenant:globex:user:alice: -> Alice's memories in Globex org (separate)

This ensures complete isolation — users in different tenants never see each other’s memories, even if they share the same user_id.

Memory pruning is also namespace-scoped: pruning via a NamespacedMemory only affects entries within that namespace.

Each tenant/user combination gets an isolated workspace directory:

{workspace_root}/{tenant_id}/{user_id}/

Path traversal prevention is enforced at the tool layer — agents cannot access files outside their scoped workspace.

Control which roles can write to shared (institutional) memory:

[daemon.memory]
shared_write_roles = ["admin", "lead"]

When configured, only users with matching roles get write access to shared memory. All users retain read access. This prevents junior agents or external users from polluting the shared knowledge base.

All audit records include tenant context:

  • user_id — the authenticated user
  • tenant_id — the tenant organization
  • delegation_chain — tracks the chain of delegations from orchestrator to sub-agents

The AuditTrail trait provides entries_for_tenant() for tenant-scoped audit queries.

Tasks are filtered by tenant context:

  • GET /tasks returns only tasks belonging to the authenticated tenant
  • GET /tasks/{id} rejects unauthenticated access to tenant-scoped tasks
  • DELETE /tasks/{id} enforces tenant boundaries
[provider]
name = "anthropic"
model = "claude-sonnet-4-20250514"
[daemon]
bind = "0.0.0.0:3000"
max_concurrent_tasks = 8
[daemon.auth]
jwks_url = "https://auth.example.com/.well-known/jwks.json"
issuer = "https://auth.example.com"
audience = "heartbit"
tenant_id_claim = "org_id"
[daemon.memory]
shared_write_roles = ["admin"]
[daemon.kafka]
brokers = "kafka:9092"
[memory]
type = "postgres"
database_url = "postgresql://heartbit:password@db/heartbit"
[[agents]]
name = "assistant"
description = "General purpose assistant"
system_prompt = "You are a helpful assistant."

Submit a task with JWT authentication:

Terminal window
curl -X POST http://localhost:3000/tasks \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <jwt-token>' \
-d '{"task": "Summarize recent activity"}'

The agent runs with the user’s identity: memory is namespaced, workspace is scoped, and all audit records include the tenant context.