Ir al contenido principal
AI Agent Multitenancy: Isolated Context per Client

AI Agent Multitenancy: Isolated Context per Client

AI Integration
6 min readPor Daily Miranda Pardo

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.

I want to review my multi-tenant agent architecture →

Compartir artículo

LinkedInXWhatsApp

¿Procesos repetitivos en tu empresa?

Descarga gratis el Mapa de Automatización IA — los 5 procesos que más tiempo roban y cómo resolverlos.

Sin spam. Solo el PDF. Puedes darte de baja cuando quieras.

Escrito por Daily Miranda Pardo

Ayudo a empresas a automatizar procesos, crear agentes IA y conectar sistemas inteligentes.