Consider a student who asks a university's advising agent how to get help paying for a term abroad. The agent answers that it has no note on the subject, although the table already holds the note that student needs, filed under a subject nobody phrased that way.
A lookup by key works where you already know which row you need, while a person asking in their own words names no subject the AI agent ever stored. Full-text search covers the rest, because it scores each note by the words in the note itself. Narsil holds that index while Sirannon stays the system of record, and the change feed is what keeps the two in step.
One index over every agent's notes returns hits to whichever agent queries it. The authors of a paper on multitenant retrieval put the cause plainly: retrieval systems rank documents 'by relevance ... not by authorization', which lets a query from one tenant surface another tenant's confidential data whenever that data scores highest (arXiv 2605.05287). The filter in the last section keeps each agent's search inside its own notes.
The desk keeps its notes in one table, with agent_id on every row. advising-agent answers students about courses and exchanges, while funding-agent keeps notes on the money each student asks for. An index stays one store however you split the databases behind it, and the same filter is therefore the gate even where every agent has a file of its own. Each code block below holds one whole file.
Ask in a student's own words
recall reads the notes filed under one subject, which is the lookup an agent makes where it already holds the subject's name. The student names none of the subjects on the table, and the query therefore matches no row.
adviserModel is a mock, which is why this file prints the answers below word for word on every run. Pass a model string in its place when you want a real model to phrase them.
import { mkdirSync, rmSync } from 'node:fs'
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { adviserModel, type Note, QUESTION } from './mock-model'
const ADVISER = 'advising-agent'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })
const db = await sirannon.open('student-services', './data/student-services.db')
await db.execute(`CREATE TABLE note (
id INTEGER PRIMARY KEY,
agent_id TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL
)`)
const NOTES: string[][] = [
[
ADVISER,
'term abroad',
'A term abroad in Valparaíso needs the module board to approve it before the bursary office will look at the costs',
],
[ADVISER, 'Kraków exchange', 'The Kraków exchange opens in the spring semester only'],
['funding-agent', 'Ngozi Adeyemi', 'Ngozi Adeyemi asked for a hardship bursary to cover the costs of a term abroad'],
]
await db.executeBatch('INSERT INTO note (agent_id, subject, body) VALUES (?, ?, ?)', NOTES)
const adviserTools = {
recall: tool({
description: 'Read the notes this agent holds under one subject.',
inputSchema: z.object({ subject: z.string() }),
execute: async ({ subject }: { subject: string }): Promise<Note[]> =>
db.query<Note>('SELECT subject, body FROM note WHERE agent_id = ? AND subject = ?', [ADVISER, subject]),
}),
}
const askAdviser = async (): Promise<string> => {
const adviser = new ToolLoopAgent({
model: adviserModel(),
instructions: 'Answer the student from the notes this agent holds.',
tools: adviserTools,
})
const reply = await adviser.generate({ prompt: QUESTION })
return reply.text
}
console.log(JSON.stringify({ question: QUESTION, adviserAnswered: await askAdviser() }, null, 2))
await sirannon.shutdown(){
"question": "How do I get help paying for a term abroad?",
"adviserAnswered": "I hold no note under 'help paying for a term abroad'."
}The note the student needs is already on the table, filed under the subject 'term abroad'. The agent asks for 'help paying for a term abroad', which is the student's own phrasing, and an equality test on the subject column matches only the string somebody filed the note under.
Search the notes by what they say
createNarsil starts a search engine inside this process, and createIndex declares the three fields in each document.
The three notes reach the table before anything watches it, and no change event covers a write made that early. One insertBatch over a read of the table therefore fills the index with what is already there. db.watch records every change from that point, and the subscription applies each one to the index with a single call per kind of event.
Sirannon calls that subscriber without waiting for it. An async callback that returns its promise therefore sends a rejection to onError, and onError is where that rejection stops.
searchNotes puts the student's question to that index, and the full-text search page covers the scoring, the stemming, and the fuzzy matching behind it.
import { mkdirSync, rmSync } from 'node:fs'
import { createNarsil } from '@delali/narsil'
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { adviserModel, type Note, QUESTION } from './mock-model'
const ADVISER = 'advising-agent'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })
const db = await sirannon.open('student-services', './data/student-services.db')
await db.execute(`CREATE TABLE note (
id INTEGER PRIMARY KEY,
agent_id TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL
)`)
const NOTES: string[][] = [
[
ADVISER,
'term abroad',
'A term abroad in Valparaíso needs the module board to approve it before the bursary office will look at the costs',
],
[ADVISER, 'Kraków exchange', 'The Kraków exchange opens in the spring semester only'],
['funding-agent', 'Ngozi Adeyemi', 'Ngozi Adeyemi asked for a hardship bursary to cover the costs of a term abroad'],
]
await db.executeBatch('INSERT INTO note (agent_id, subject, body) VALUES (?, ?, ?)', NOTES)
interface NoteRow {
id: number
agent_id: string
subject: string
body: string
}
const asDocument = (row: NoteRow) => ({
id: String(row.id),
agentId: row.agent_id,
subject: row.subject,
body: row.body,
})
const narsil = await createNarsil()
await narsil.createIndex('student_notes', {
schema: { agentId: 'string', subject: 'string', body: 'string' },
language: 'english',
})
const onTheTable = await db.query<NoteRow>('SELECT id, agent_id, subject, body FROM note ORDER BY id')
await narsil.insertBatch('student_notes', onTheTable.map(asDocument))
await db.watch('note')
db.on('note').subscribe<NoteRow>(
async (event) => {
if (event.type === 'insert') await narsil.insert('student_notes', asDocument(event.row))
else if (event.type === 'update') await narsil.update('student_notes', String(event.row.id), asDocument(event.row))
else if (event.oldRow !== undefined) await narsil.remove('student_notes', String(event.oldRow.id))
},
{ onError: (error) => console.error('index write failed', error.message) }
)
const adviserTools = {
recall: tool({
description: 'Read the notes this agent holds under one subject.',
inputSchema: z.object({ subject: z.string() }),
execute: async ({ subject }: { subject: string }): Promise<Note[]> =>
db.query<Note>('SELECT subject, body FROM note WHERE agent_id = ? AND subject = ?', [ADVISER, subject]),
}),
searchNotes: tool({
description: 'Search the notes by what their text says.',
inputSchema: z.object({ text: z.string() }),
execute: async ({ text }: { text: string }): Promise<Note[]> => {
const found = await narsil.query<Note>('student_notes', { term: text, limit: 3 })
return found.hits.map((hit) => ({ subject: hit.document.subject, body: hit.document.body }))
},
}),
}
const askAdviser = async (): Promise<string> => {
const adviser = new ToolLoopAgent({
model: adviserModel(),
instructions: 'Answer the student from the notes this agent holds.',
tools: adviserTools,
})
const reply = await adviser.generate({ prompt: QUESTION })
return reply.text
}
console.log(JSON.stringify({ question: QUESTION, adviserAnswered: await askAdviser() }, null, 2))
await sirannon.shutdown(){
"question": "How do I get help paying for a term abroad?",
"adviserAnswered": "Here is what I hold: A term abroad in Valparaíso needs the module board to approve it before the bursary office will look at the costs; and Ngozi Adeyemi asked for a hardship bursary to cover the costs of a term abroad."
}The agent answers the student now, and that answer includes funding-agent's note about the hardship bursary Ngozi Adeyemi asked for. Nothing about that query is adversarial, since the note scores well for holding the words the student uses.
the index3 of 3 notes
Two notes belong to advising-agent, and one belongs to funding-agent.
filters2 of 3 notes
The condition on agentId admits the notes this agent owns, before Narsil scores anything.
term1 of 3 notes
Scoring ranks what is left, and one of the two notes holds the words the student uses.
Keep one agent out of another's notes
Every document holds an agentId, and a condition on that field narrows the candidates before any scoring happens. Narsil applies filters inside the query, which the filters page covers along with facets and paging.
Take the value from the identity your server authenticated. An agent free to supply its own agentId would be choosing whose notes it reads. Fill that field the way the server fills customerId in AI agent tools.
import { mkdirSync, rmSync } from 'node:fs'
import { createNarsil } from '@delali/narsil'
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { adviserModel, type Note, QUESTION } from './mock-model'
const ADVISER = 'advising-agent'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })
const db = await sirannon.open('student-services', './data/student-services.db')
await db.execute(`CREATE TABLE note (
id INTEGER PRIMARY KEY,
agent_id TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL
)`)
const NOTES: string[][] = [
[
ADVISER,
'term abroad',
'A term abroad in Valparaíso needs the module board to approve it before the bursary office will look at the costs',
],
[ADVISER, 'Kraków exchange', 'The Kraków exchange opens in the spring semester only'],
['funding-agent', 'Ngozi Adeyemi', 'Ngozi Adeyemi asked for a hardship bursary to cover the costs of a term abroad'],
]
await db.executeBatch('INSERT INTO note (agent_id, subject, body) VALUES (?, ?, ?)', NOTES)
interface NoteRow {
id: number
agent_id: string
subject: string
body: string
}
const asDocument = (row: NoteRow) => ({
id: String(row.id),
agentId: row.agent_id,
subject: row.subject,
body: row.body,
})
const narsil = await createNarsil()
await narsil.createIndex('student_notes', {
schema: { agentId: 'string', subject: 'string', body: 'string' },
language: 'english',
})
const onTheTable = await db.query<NoteRow>('SELECT id, agent_id, subject, body FROM note ORDER BY id')
await narsil.insertBatch('student_notes', onTheTable.map(asDocument))
await db.watch('note')
db.on('note').subscribe<NoteRow>(
async (event) => {
if (event.type === 'insert') await narsil.insert('student_notes', asDocument(event.row))
else if (event.type === 'update') await narsil.update('student_notes', String(event.row.id), asDocument(event.row))
else if (event.oldRow !== undefined) await narsil.remove('student_notes', String(event.oldRow.id))
},
{ onError: (error) => console.error('index write failed', error.message) }
)
const adviserTools = {
recall: tool({
description: 'Read the notes this agent holds under one subject.',
inputSchema: z.object({ subject: z.string() }),
execute: async ({ subject }: { subject: string }): Promise<Note[]> =>
db.query<Note>('SELECT subject, body FROM note WHERE agent_id = ? AND subject = ?', [ADVISER, subject]),
}),
searchNotes: tool({
description: 'Search the notes by what their text says.',
inputSchema: z.object({ text: z.string() }),
execute: async ({ text }: { text: string }): Promise<Note[]> => {
const found = await narsil.query<Note>('student_notes', { term: text, limit: 3 })
const found = await narsil.query<Note>('student_notes', {
term: text,
filters: { fields: { agentId: { eq: ADVISER } } },
limit: 3,
})
return found.hits.map((hit) => ({ subject: hit.document.subject, body: hit.document.body }))
},
}),
}
const askAdviser = async (): Promise<string> => {
const adviser = new ToolLoopAgent({
model: adviserModel(),
instructions: 'Answer the student from the notes this agent holds.',
tools: adviserTools,
})
const reply = await adviser.generate({ prompt: QUESTION })
return reply.text
}
console.log(JSON.stringify({ question: QUESTION, adviserAnswered: await askAdviser() }, null, 2))
await sirannon.shutdown(){
"question": "How do I get help paying for a term abroad?",
"adviserAnswered": "Here is what I hold: A term abroad in Valparaíso needs the module board to approve it before the bursary office will look at the costs."
}The agent now answers with the advising note alone. One index and one term produce both runs, and a single field condition separates them.
Where to go next
Keyword search matches the words a note holds. Where a question shares no word at all with the note a person needs, hybrid mode puts a vector search beside the keyword search and fuses the two rankings. Hybrid search covers that mode, along with the embedding adapters that encode the text. A process that stops between the commit and the index write leaves the index behind the table, and subscription resumption covers resuming the feed from the sequence you indexed last. Where several agents query one index, the Narsil HTTP server puts it behind a port of its own.