AI Agent Multitenancy: Isolated Context per Client
The agent works perfectly in the demo. You deploy it for the first client — all good. You add a second client. Then a third.
And then something unexpected happens: Client B's agent starts responding with Client A's context. Or worse: you don't notice until Client B calls you to say their AI assistant is referencing information that doesn't belong to them.
That's not a model bug. It's an architecture bug. And it's far more common than it should be.
The mistake nobody documents until it happens
Most multi-tenant AI systems start as single-client builds that get extended without rethinking the architecture. The system prompt gets parameterized, the vector store is reused, memory is shared, and everything seems to work.
Until it doesn't.
There are three failure patterns that consistently appear in production.
Shared vector store without tenant filtering
A single index in Pinecone, Weaviate, or pgvector for all clients. Queries hit the same embedding space and the LLM receives fragments from any client.
In production, this produces responses where the context belongs to a different tenant. Not always. Sometimes. Which is the worst outcome: it looks like it works until it doesn't — and by then you don't know how long it's been leaking.
Monolithic system prompt with variables
A single template with {company_name}, {tone}, {products}. The problem isn't the template itself — it's that a template can't capture the behavioral semantics specific to each client. The law firm agent ends up behaving too much like the medical clinic agent or the e-commerce store.
Stateful memory without tenant scope
If you use persistent memory — Redis, Supabase, any database — every memory fragment needs a tenant_id. Without that field, the agent can retrieve conversation context from a different client during semantic memory lookups.
Architecture patterns that actually work
Tenant ID as a first-class citizen
Don't add it at the end as a patch. Design from the start so every request carries a verified tenant_id, every database write includes that field, and every query filters by it.
async function handleAgentRequest(tenantId: string, userMessage: string) {
// 1. Load per-tenant config
const config = await tenantConfigRepo.findOrThrow(tenantId)
// 2. Retrieve memory with isolated scope
const memories = await memoryStore.query({
tenantId, // always present
query: userMessage,
topK: 5,
})
// 3. Build context with tenant-specific system prompt
const response = await llm.chat({
system: buildSystemPrompt(config),
messages: [...memories.map(toMessage), { role: 'user', content: userMessage }],
})
// 4. Write with tenant_id in every save operation
await memoryStore.save({
tenantId,
userMessage,
assistantResponse: response.content,
timestamp: Date.now(),
})
return response
}
The tenantId travels through the entire call chain: config loading, vector search, memory read/write, audit log. If any point in that chain can execute without tenantId, you have a potential leak.
Vector store: per-tenant collection vs metadata filter
Two options with real tradeoffs:
Separate collection per tenant (Pinecone namespace, Weaviate class, separate pgvector schema):
- Perfect isolation, impossible cross-tenant leakage
- Higher cost as clients scale
- More complex maintenance (reindex, migrate, schema management)
- Recommended for sensitive data or enterprise clients with privacy SLAs
Shared index with metadata filter:
const results = await vectorStore.similaritySearch(userQuery, {
filter: { tenant_id: { $eq: tenantId } },
k: 5,
})
// CRITICAL: verify results only contain the correct tenant's documents
if (results.some(r => r.metadata.tenant_id !== tenantId)) {
throw new Error(`Context leak detected for tenant ${tenantId}`)
}
This is cheaper at scale but depends on the filter working correctly in your chosen vector DB. Always add the post-query verification — if the filter fails due to a library update or an implementation bug, you want to catch it before your client does.
Config loading: a full object per tenant, not a template
Instead of interpolating variables into a string, load a complete configuration object from your database:
interface TenantConfig {
systemPrompt: string
allowedTools: ToolName[]
escalationRules: EscalationRule[]
model: 'claude-haiku-4-5-20251001' | 'claude-sonnet-5' | 'claude-opus-5'
maxTokensPerRequest: number
language: 'es' | 'en' | 'fr'
}
const config = await tenantConfigRepo.findOrThrow(tenantId)
This allows each client to have different tools, different escalation rules, even different models depending on their plan. The system stops assuming all clients are the same — because they're not.
What you won't catch without isolation tests
Context leakage between tenants is silent. It doesn't generate a 500 error. It doesn't show up in server error logs. The agent responds, the request returns 200, and nobody notices the response contains information it shouldn't.
That's why you need specific isolation tests in your integration test suite:
describe('Tenant isolation', () => {
it('Tenant B cannot see Tenant A documents', async () => {
// Index a document with a unique marker for Tenant A
await vectorStore.upsert([{
id: 'doc_secret_a',
content: 'CONFIDENTIAL_MARKER_TENANT_A_XK9',
metadata: { tenant_id: 'tenant_a' },
}])
// Query from Tenant B's perspective
const results = await vectorStore.similaritySearch(
'CONFIDENTIAL_MARKER_TENANT_A_XK9',
{ filter: { tenant_id: { $eq: 'tenant_b' } }, k: 5 }
)
// Tenant B must see zero results from Tenant A
expect(results.length).toBe(0)
expect(results.every(r => r.metadata.tenant_id === 'tenant_b')).toBe(true)
})
})
These tests run in CI. If any change in the data access layer or vector DB breaks isolation, you catch it before your client does.
The real cost of getting it wrong from the start
Retrofitting multitenancy isn't a clean refactor. It's surgery: changing the database schema, migrating existing data, finding every place in the codebase that queries without tenant_id, updating the entire call stack.
On systems in production with active clients, that process can take weeks and requires extreme care not to lose data or introduce new leaks during the migration itself.
The cost of designing it correctly from day one: two or three days of architecture design before writing the first line of business logic.
The cost of fixing it in production with active clients: three weeks minimum, data incident risk, and an uncomfortable conversation with affected clients.
At DAILYMP we build AI agent systems with correct multi-tenant architecture from the initial design phase. If you're starting a system that will serve more than one client, it's worth reviewing the architecture before it becomes a problem. And if you already have something in production and suspect the isolation isn't working properly, the first step is auditing how tenant_id flows through your system.
Before you write the first line
Context separation between tenants isn't a feature you add when you scale. It's an architecture decision that determines whether your system can grow without compromising your clients' data privacy.
If you have questions about the design of your multi-tenant agent system, DAILYMP does that kind of architecture review — with concrete code, not generic diagrams.