Architecture · Multi-Tenant··7 min read

One Brain per customer: multi-tenant AI memory with scoped keys

Most AI features don't fail on the model — they fail on memory. An assistant that starts from zero every session is glorified autocomplete. It gets interesting when it remembers — and, for a SaaS product, when it remembers separately for each customer. Here is how we built exactly that on cachly.

The problem: one tenant, many customers

We built this for Kanzlei-Kompass, an OKR platform for tax-advisory firms. Every firm gets its own AI coach that learns from that firm'spast analyses and decisions. No shared pool, no mixed data. But Kanzlei-Kompass is a single SaaS app: one operator, many firms. For AI memory that means firm A's knowledge must never surface in firm B's coach. Two naive approaches:

  • One shared brain, separated by a filter. One bug in the filter, one forgotten WHERE tenant = …, and data leaks. For the client confidentiality of tax firms, that's a non-starter.
  • One cachly account per firm. Cleanly isolated, but a billing and provisioning nightmare at hundreds of customers.

We wanted the isolation of the second with the operability of the first. The answer: one tenant, many instances, and a per-customer key scoped to a single instance.

The building blocks in cachly

cachly has three layers: a tenant (the account), an instance (an isolated brain with its own datastore), and an API key. For the multi-tenant case we added two capabilities.

1. A per-tenant instance limit. An app tenant that provisions one instance per end customer needs headroom. The instance limit is now overridable per tenant, so "one firm = one instance" scales cleanly without loosening the default for everyone else.

2. Instance-scoped API keys. This is the heart of it. A key can optionally bind to exactly one instance:

POST /api/v1/api-keys
Authorization: Bearer <tenant-admin-key>

{ "name": "kanzlei-mueller", "scope": "read_write",
  "instance_id": "…this firm's instance…" }

A key bound this way may touch only that one instance. Any attempt to hit a different instance — or a tenant-wide route — is rejected server-side with a 403: deny-by-default. The guard reads the instance id from the path (the segment after instances), independent of the concrete route:

POST /api/v1/instances/<bound-id>/recall   OK
POST /api/v1/instances/<other-id>/recall   403
POST /api/v1/instances                      403  (tenant-wide)
GET  /api/v1/api-keys                        403  (tenant-wide)

One detail from practice that cost us time: the guard runs as group middleware — and in Fiber, a downstream route's params are not populated inside Use middleware. A c.Params("id")-based guard would have blocked every scoped-key request, including the legitimate ones. So the guard parses the id from the raw path instead. A table test pins that down: JWT user, tenant-wide key, matching vs. foreign instance, and the org-route form.

The trust model: two keys, two roles

From these blocks comes a clean split that confines a leaked credential to exactly one customer:

  • The tenant-admin key lives only on the app server, never at a client. It is used once per customer, to createthat customer's instance and its scoped key. Because it can reach every instance, it never leaves the provisioning path.
  • The instance-scoped key is stored (encrypted) per customer in the app database and used for day-to-day recall / learn. Even in the worst case it can only reach the one brain it belongs to.

The lifecycle of a customer

  • Provision (idempotent, at login). On a firm's first login the app creates a free instance, waits briefly for running, mints a read_write key scoped to that instance, and stores it AES-GCM encrypted. The write only lands while no instance is recorded yet, so parallel logins can't create a second brain.
  • Recall (before every answer). The coach calls recalland folds the firm's experience into the prompt. Finds nothing? It just answers normally.
  • Learn (after analyses). From every OKR analysis the coach writes a lesson back via learn. The memory grows with every cycle.

Fail-soft is not a nice-to-have

One principle runs through all of it: no brain outage may block a user path. Every call has a hard timeout. If the brain is down, recall returns an empty list, learn evaporates with a log line, provisioning is retried at the next login. The coach then answers without memory context — but it answers.

Why this matters beyond one app

The pattern isn't firm-specific. Any multi-tenant app that wants an isolated AI memory per customer — agencies, practices, consultancies, portfolios — can use the same three layers: one tenant, one instance per customer, one scoped key per instance. The isolation lives in a server-side guard, not in a filter line you can forget.

cachly is a persistent AI Brain for developers — memory shared across Claude Code, Cursor, GitHub Copilot & Windsurf simultaneously. Auto-detects every editor. Bootstraps from your git history. 126 MCP tools. Free tier, EU servers, no credit card.

Your AI is forgetting everything right now.

Every session starts blank. Every bug re-discovered. Every deploy procedure re-explained. cachly fixes that in 30 seconds — your AI remembers every lesson, every fix, every teammate's hard-won knowledge. Forever.

🇪🇺 EU servers · GDPR-compliant🆓 Free tier — forever, no credit card⚡ 30-second setup via npx🔌 Claude Code · Cursor · Copilot · Windsurf