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).
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.
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(){
"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."
}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.
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
type Client = (typeof CLIENTS)[number]
const indexFor = (client: Client): string => `handbook-${client}`
const narsil = await createNarsil()
await narsil.createIndex('handbooks', {
schema: { client: 'enum', section: 'string', body: 'string' },
language: 'english',
})
for (const client of CLIENTS) {
await narsil.createIndex(indexFor(client), {
schema: { section: 'string', body: 'string' },
language: 'english',
})
}
await narsil.insertBatch('handbooks', [
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 askFor = async (client: string): Promise<string> => {
const askFor = async (client: Client): 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', {
const found = await narsil.query<HandbookHit>(indexFor(client), {
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))
console.log(
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(){
"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."
}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 before you open an address.
import { createNarsil } from '@delali/narsil'
import { createServer } from '@delali/narsil/server'
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> = {
'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, {
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> => {
const askFor = async (token: string, client: Client): 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>(indexFor(client), {
term: text,
document: { include: ['section', 'body'] },
limit: 1,
})
return found.hits.map((hit) => hit.document)
const response = await search(token, indexFor(client), text)
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')
console.log(
JSON.stringify(
{
question: QUESTION,
indexes: narsil.listIndexes().map((index) => index.name),
aldridgeStaffHeard: await askFor('aldridge-joinery'),
nakagawaStaffHeard: await askFor('nakagawa-optics'),
aldridgeStaffHeard: await askFor('aldridge-9f21', 'aldridge-joinery'),
aldridgeTokenOnAnotherIndex: { status: reachedAcross.status, body: await reachedAcross.json() },
},
null,
2
)
)
await narsil.shutdown()
await server.shutdown() {
"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."
}
}
}
}server.shutdown() stops the server and the engine together, after which the process can exit.
- 200
POST /indexes/handbook-aldridge-joinery/searchBearer aldridge-9f21 - 403
POST /indexes/handbook-nakagawa-optics/searchBearer aldridge-9f21 - 401
POST /indexes/handbook-aldridge-joinery/searchno authorization header
Where to go next
- Read The 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.
- Record the index name with every search in the journal from Record every search.