> Complete content index: https://narsil.sondelali.com/llms.txt

# Keep each agent to its own index

> Give each client's AI agent an index of its own, because the engine ranks passages by relevance and can put another client's handbook first.

URL: https://narsil.sondelali.com/docs/agent-isolation
Section: AI agents
Version: 0.3 (@delali/narsil@0.3.0, latest)

Consider staff at a joinery who ask their payroll bureau's AI agent how much notice to give for annual leave, and who hear four weeks. The notice period in their own handbook is two weeks, while four weeks is the rule at another of the bureau's clients. The question is an ordinary one from a member of staff. One index holds all three handbooks, and the engine scores the other client's section at 5.94 against the joinery's 2.15, because that section contains the words notice and annual leave in both its heading and its body.

The authors of a 2026 paper on multitenant enterprise retrieval explain why this happens. Whether a retrieval system matches keywords, vectors, or both, it ranks by relevance alone, so an ordinary question can return the highest-scoring passage from another tenant ([arXiv 2605.05287](https://arxiv.org/abs/2605.05287)).

The model in each file is a mock that follows a fixed script, and every answer below therefore has the same wording on any machine. Put a real model in its place when you want a model to choose the calls. Each code block below holds one whole file.

## One index over every client

Every section here has a `client` field of type `enum`, and a filter on that field would remove the other clients' sections before the engine scores anything. The tool sets no filter, because in an application built for one client, nobody needs a filter until a second client signs up.

<CodeGroup defaultTab="bureau.ts">

```ts title="bureau.ts"
import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type HandbookHit, QUESTION, staffModel } from './mock-model.ts'

const narsil = await createNarsil()

await narsil.createIndex('handbooks', {
  schema: { client: 'enum', section: 'string', body: 'string' },
  language: 'english',
})

await narsil.insertBatch('handbooks', [
  {
    id: 'aldridge-leave',
    client: 'aldridge-joinery',
    section: 'Annual leave',
    body: 'Book annual leave through the workshop diary two weeks ahead, and the foreman confirms it the same day.',
  },
  {
    id: 'aldridge-overtime',
    client: 'aldridge-joinery',
    section: 'Overtime',
    body: 'Overtime on a Saturday pays time and a half, agreed with the foreman before the shift.',
  },
  {
    id: 'nakagawa-leave',
    client: 'nakagawa-optics',
    section: 'Annual leave notice',
    body: 'Give four weeks of notice for annual leave of a week or more, and two weeks of notice for a single day of annual leave.',
  },
  {
    id: 'brennan-leave',
    client: 'brennan-haulage',
    section: 'Leave and rest days',
    body: 'A driver takes leave in blocks of seven days, agreed with the transport manager a month ahead.',
  },
])

const askFor = async (client: string): Promise<string> => {
  const staffAgent = new ToolLoopAgent({
    model: staffModel(),
    instructions: `Answer a member of staff at ${client} from the staff handbook.`,
    tools: {
      searchHandbook: tool({
        description: 'Search the staff handbook by what its text says.',
        inputSchema: z.object({ text: z.string() }),
        execute: async ({ text }: { text: string }): Promise<HandbookHit[]> => {
          const found = await narsil.query<HandbookHit>('handbooks', {
            term: text,
            document: { include: ['section', 'body'] },
            limit: 1,
          })
          return found.hits.map((hit) => hit.document)
        },
      }),
    },
  })
  const reply = await staffAgent.generate({ prompt: QUESTION })
  return reply.text
}

console.log(JSON.stringify({ question: QUESTION, aldridgeStaffHeard: await askFor('Aldridge Joinery') }, null, 2))

await narsil.shutdown()
```

```json result open
{
  "question": "How much notice do I give for annual leave?",
  "aldridgeStaffHeard": "Under 'Annual leave notice': Give four weeks of notice for annual leave of a week or more, and two weeks of notice for a single day of annual leave."
}
```

```ts title="mock-model.ts"
import { MockLanguageModelV4 } from 'ai/test'

const NO_USAGE = {
  inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined },
  outputTokens: { total: 0, text: 0, reasoning: undefined },
}

export const QUESTION = 'How much notice do I give for annual leave?'

export interface HandbookHit {
  section: string
  body: string
}

interface ToolResultPart {
  toolName?: string
  output?: { value?: unknown }
}

interface PromptMessage {
  role: string
  content: ToolResultPart[] | string
}

const everythingReturnedBy = (prompt: unknown, toolName: string): unknown[] => {
  const outputs: unknown[] = []
  for (const message of prompt as PromptMessage[]) {
    if (message.role !== 'tool' || typeof message.content === 'string') continue
    for (const part of message.content) {
      if (part.toolName === toolName) outputs.push(part.output?.value)
    }
  }
  return outputs
}

const callTool = (toolName: string, input: Record<string, unknown>) => ({
  content: [{ type: 'tool-call' as const, toolCallId: `${toolName}-1`, toolName, input: JSON.stringify(input) }],
  finishReason: { unified: 'tool-calls' as const, raw: undefined },
  usage: NO_USAGE,
  warnings: [],
})

const say = (text: string) => ({
  content: [{ type: 'text' as const, text }],
  finishReason: { unified: 'stop' as const, raw: undefined },
  usage: NO_USAGE,
  warnings: [],
})

export const staffModel = () =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const searches = everythingReturnedBy(prompt, 'searchHandbook') as HandbookHit[][]
      if (searches.length === 0) return callTool('searchHandbook', { text: QUESTION })
      const found = searches[0]
      if (found.length === 0) return say('Your handbook holds nothing on annual leave.')
      return say(`Under '${found[0].section}': ${found[0].body}`)
    },
  })
```

</CodeGroup>

Staff at the joinery hear the optics firm's notice period. The engine ranks by the words in the question, which is its job. The `client` field on each section has no effect on the results until a query filters on it.

Inside one trusted process a filter is enough, because your own code fills in the client identifier from an authenticated session. Once an AI agent queries the engine from outside the process, however, the protection depends on every caller sending the right value in that field.

## An index for each client

`indexFor` turns a client identifier into an index name, which can contain letters, digits, dots, hyphens, and underscores, up to 255 characters. Each AI agent's tool closes over its own client, which means the index name is never an argument that a model can set.

With one index for each client, you have to plan for the hundredth client and the thousandth. `lifecycle.idleTimeoutMs` closes an index once it receives no requests for that long, and `lifecycle.maxOpenIndexes` caps how many indexes are open at once. A query on a closed index reopens it. An index can close only when the engine has a durability directory, and without one `close` throws `CONFIG_INVALID` with the message 'An index needs durability before it can close'. You can set up the directory and those lifecycle settings from [Persistence and durability](/docs/persistence-and-durability).

<CodeGroup defaultTab="bureau.ts">

```ts title="bureau.ts"
import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type HandbookHit, QUESTION, staffModel } from './mock-model.ts'

const CLIENTS = ['aldridge-joinery', 'nakagawa-optics', 'brennan-haulage'] as const // [!code ++:5]

type Client = (typeof CLIENTS)[number]

const indexFor = (client: Client): string => `handbook-${client}`

const narsil = await createNarsil()

await narsil.createIndex('handbooks', { // [!code --]
  schema: { client: 'enum', section: 'string', body: 'string' }, // [!code --]
  language: 'english', // [!code --]
}) // [!code --]
for (const client of CLIENTS) { // [!code ++:6]
  await narsil.createIndex(indexFor(client), {
    schema: { section: 'string', body: 'string' },
    language: 'english',
  })
}

await narsil.insertBatch('handbooks', [ // [!code --]
const SECTIONS: Array<{ id: string; client: Client; section: string; body: string }> = [ // [!code ++]
  {
    id: 'aldridge-leave',
    client: 'aldridge-joinery',
    section: 'Annual leave',
    body: 'Book annual leave through the workshop diary two weeks ahead, and the foreman confirms it the same day.',
  },
  {
    id: 'aldridge-overtime',
    client: 'aldridge-joinery',
    section: 'Overtime',
    body: 'Overtime on a Saturday pays time and a half, agreed with the foreman before the shift.',
  },
  {
    id: 'nakagawa-leave',
    client: 'nakagawa-optics',
    section: 'Annual leave notice',
    body: 'Give four weeks of notice for annual leave of a week or more, and two weeks of notice for a single day of annual leave.',
  },
  {
    id: 'brennan-leave',
    client: 'brennan-haulage',
    section: 'Leave and rest days',
    body: 'A driver takes leave in blocks of seven days, agreed with the transport manager a month ahead.',
  },
]) // [!code --]
] // [!code ++:5]

for (const entry of SECTIONS) {
  await narsil.insert(indexFor(entry.client), { id: entry.id, section: entry.section, body: entry.body })
}

const askFor = async (client: string): Promise<string> => { // [!code --]
const askFor = async (client: Client): Promise<string> => { // [!code ++]
  const staffAgent = new ToolLoopAgent({
    model: staffModel(),
    instructions: `Answer a member of staff at ${client} from the staff handbook.`,
    tools: {
      searchHandbook: tool({
        description: 'Search the staff handbook by what its text says.',
        inputSchema: z.object({ text: z.string() }),
        execute: async ({ text }: { text: string }): Promise<HandbookHit[]> => {
          const found = await narsil.query<HandbookHit>('handbooks', { // [!code --]
          const found = await narsil.query<HandbookHit>(indexFor(client), { // [!code ++]
            term: text,
            document: { include: ['section', 'body'] },
            limit: 1,
          })
          return found.hits.map((hit) => hit.document)
        },
      }),
    },
  })
  const reply = await staffAgent.generate({ prompt: QUESTION })
  return reply.text
}

console.log(JSON.stringify({ question: QUESTION, aldridgeStaffHeard: await askFor('Aldridge Joinery') }, null, 2)) // [!code --]
console.log( // [!code ++:12]
  JSON.stringify(
    {
      question: QUESTION,
      indexes: narsil.listIndexes().map((index) => index.name),
      aldridgeStaffHeard: await askFor('aldridge-joinery'),
      nakagawaStaffHeard: await askFor('nakagawa-optics'),
    },
    null,
    2
  )
)

await narsil.shutdown()
```

```json result open
{
  "question": "How much notice do I give for annual leave?",
  "indexes": [
    "handbook-aldridge-joinery",
    "handbook-nakagawa-optics",
    "handbook-brennan-haulage"
  ],
  "aldridgeStaffHeard": "Under 'Annual leave': Book annual leave through the workshop diary two weeks ahead, and the foreman confirms it the same day.",
  "nakagawaStaffHeard": "Under 'Annual leave notice': Give four weeks of notice for annual leave of a week or more, and two weeks of notice for a single day of annual leave."
}
```

</CodeGroup>

Each client's staff now hear their own notice period from one engine with three indexes. `listIndexes()` returns the indexes in that engine, and the bureau's staff can check that list to see whose handbooks are loaded.

## Hold each token to one index over HTTP

`createServer` puts a REST API in front of the same engine, and the server calls `onRequest` before every routed request with the method, the path, the headers keyed by lower-case name, and the peer address. Return `undefined` to let a request through, or a `status`, a `code`, and a `message` to refuse it.

The hook resolves the bearer token to a client, then compares the path with that client's index. When a request has one client's token and another client's index in its path, the server returns a 403 before the engine parses the body. `TOKEN_HOLDERS` is a literal in this file, whereas in production you would look the token up in the store that already holds your clients.

Install `uWebSockets.js` alongside Narsil for the server, and set the limits from [the HTTP server page](/docs/http-server) before you open an address.

<CodeGroup defaultTab="bureau.ts">

```ts title="bureau.ts"
import { createNarsil } from '@delali/narsil'
import { createServer } from '@delali/narsil/server' // [!code ++]
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type HandbookHit, QUESTION, staffModel } from './mock-model.ts'

const CLIENTS = ['aldridge-joinery', 'nakagawa-optics', 'brennan-haulage'] as const

type Client = (typeof CLIENTS)[number]

const indexFor = (client: Client): string => `handbook-${client}`

const TOKEN_HOLDERS: Record<string, Client> = { // [!code ++:6]
  'aldridge-9f21': 'aldridge-joinery',
  'nakagawa-4c07': 'nakagawa-optics',
  'brennan-71be': 'brennan-haulage',
}

const narsil = await createNarsil()

for (const client of CLIENTS) {
  await narsil.createIndex(indexFor(client), {
    schema: { section: 'string', body: 'string' },
    language: 'english',
  })
}

const SECTIONS: Array<{ id: string; client: Client; section: string; body: string }> = [
  {
    id: 'aldridge-leave',
    client: 'aldridge-joinery',
    section: 'Annual leave',
    body: 'Book annual leave through the workshop diary two weeks ahead, and the foreman confirms it the same day.',
  },
  {
    id: 'aldridge-overtime',
    client: 'aldridge-joinery',
    section: 'Overtime',
    body: 'Overtime on a Saturday pays time and a half, agreed with the foreman before the shift.',
  },
  {
    id: 'nakagawa-leave',
    client: 'nakagawa-optics',
    section: 'Annual leave notice',
    body: 'Give four weeks of notice for annual leave of a week or more, and two weeks of notice for a single day of annual leave.',
  },
  {
    id: 'brennan-leave',
    client: 'brennan-haulage',
    section: 'Leave and rest days',
    body: 'A driver takes leave in blocks of seven days, agreed with the transport manager a month ahead.',
  },
]

for (const entry of SECTIONS) {
  await narsil.insert(indexFor(entry.client), { id: entry.id, section: entry.section, body: entry.body })
}

const server = createServer(narsil, { // [!code ++:20]
  host: '127.0.0.1',
  port: 7711,
  onRequest: (request) => {
    const token = request.headers.authorization?.replace('Bearer ', '') ?? ''
    const client = TOKEN_HOLDERS[token]
    if (client === undefined) return { status: 401, code: 'UNAUTHORISED', message: 'Send a bureau token.' }
    if (request.path.startsWith(`/indexes/${indexFor(client)}/`)) return undefined
    return { status: 403, code: 'INDEX_FORBIDDEN', message: `That token reaches ${indexFor(client)} alone.` }
  },
})

await server.listen()

const search = (token: string, indexName: string, text: string): Promise<Response> =>
  fetch(`http://127.0.0.1:7711/indexes/${indexName}/search`, {
    method: 'POST',
    headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
    body: JSON.stringify({ term: text, document: { include: ['section', 'body'] }, limit: 1 }),
  })

const askFor = async (client: Client): Promise<string> => { // [!code --]
const askFor = async (token: string, client: Client): Promise<string> => { // [!code ++]
  const staffAgent = new ToolLoopAgent({
    model: staffModel(),
    instructions: `Answer a member of staff at ${client} from the staff handbook.`,
    tools: {
      searchHandbook: tool({
        description: 'Search the staff handbook by what its text says.',
        inputSchema: z.object({ text: z.string() }),
        execute: async ({ text }: { text: string }): Promise<HandbookHit[]> => {
          const found = await narsil.query<HandbookHit>(indexFor(client), { // [!code --]
            term: text, // [!code --]
            document: { include: ['section', 'body'] }, // [!code --]
            limit: 1, // [!code --]
          }) // [!code --]
          return found.hits.map((hit) => hit.document) // [!code --]
          const response = await search(token, indexFor(client), text) // [!code ++:3]
          const body = (await response.json()) as { hits?: Array<{ document: HandbookHit }> }
          return (body.hits ?? []).map((hit) => hit.document)
        },
      }),
    },
  })
  const reply = await staffAgent.generate({ prompt: QUESTION })
  return reply.text
}

const reachedAcross = await search('aldridge-9f21', indexFor('nakagawa-optics'), 'annual leave') // [!code ++]

console.log(
  JSON.stringify(
    {
      question: QUESTION,
      indexes: narsil.listIndexes().map((index) => index.name), // [!code --]
      aldridgeStaffHeard: await askFor('aldridge-joinery'), // [!code --]
      nakagawaStaffHeard: await askFor('nakagawa-optics'), // [!code --]
      aldridgeStaffHeard: await askFor('aldridge-9f21', 'aldridge-joinery'), // [!code ++]
      aldridgeTokenOnAnotherIndex: { status: reachedAcross.status, body: await reachedAcross.json() }, // [!code ++]
    },
    null,
    2
  )
)

await narsil.shutdown() // [!code --]
await server.shutdown() // [!code ++]
```

```json result open
{
  "question": "How much notice do I give for annual leave?",
  "aldridgeStaffHeard": "Under 'Annual leave': Book annual leave through the workshop diary two weeks ahead, and the foreman confirms it the same day.",
  "aldridgeTokenOnAnotherIndex": {
    "status": 403,
    "body": {
      "error": {
        "code": "INDEX_FORBIDDEN",
        "message": "That token reaches handbook-aldridge-joinery alone."
      }
    }
  }
}
```

</CodeGroup>

`server.shutdown()` stops the server and the engine together, after which the process can exit.

<RequestGateDiagram
  tokenIndex="handbook-aldridge-joinery"
  rows={[
    { token: 'aldridge-9f21', path: 'POST /indexes/handbook-aldridge-joinery/search', status: 200 },
    { token: 'aldridge-9f21', path: 'POST /indexes/handbook-nakagawa-optics/search', status: 403 },
    { token: null, path: 'POST /indexes/handbook-aldridge-joinery/search', status: 401 },
  ]}
  caption="The hook compares the index name in each path with the index of the token's client, which is why the query body needs no client argument. The server calls the hook for every route except the health and version endpoints."
/>

## Where to go next

- Read [The HTTP server](/docs/http-server) for `onRequest`, the request limits, and the shutdown call to make before the process exits.
- Set up the durability directory and the lifecycle settings for idle indexes with [Persistence and durability](/docs/persistence-and-durability).
- Record the index name with every search in the journal from [Record every search](/docs/agent-search-audit).
