Sirannon opens a SQLite database as a file on a disk you control, so you can give every AI agent a database of its own. In the example below, a support desk gives each customer several agents, and each of those agents reads and writes one file. The registry opens that file the first time your code asks for the agent. It closes the file once the agent goes quiet, and the next question opens it again.
A support agent that works for two customers answers questions from both of them, and the two contracts set different refund windows. Where one table holds both agreements, only the WHERE clause in each query keeps them apart. The authors of a benchmark published in July 2026 built 85 scenarios, each holding four to ten users, and measured how much of one user's information each agent design let another user reach. Where every user talked to a single agent over one store, they measured a visibility-violation rate of 100% on each of the three models they tested, because each of those users reached whatever that agent could see (arXiv 2607.05318). With one file for each agent, the boundary is the file path, and your own code sets that path before the model runs.
Give every customer's agent its own file
createTenantResolver maps an identifier onto a file under the directory you name, which turns sundara-logistics__support-agent into data/tenants/sundara-logistics__support-agent.db. Sirannon checks that identifier itself, accepting one that opens on a letter or a digit and holds nothing beyond letters, digits, hyphens, and underscores. An identifier such as sundara-logistics/../kestrel-analytics__support-agent therefore yields no path, and the registry opens nothing for it.
agentId is the one line of your own that touches the identifier, and it joins the customer to the agent's name. Pass the resolver to the registry as lifecycle.autoOpen.resolver. The registry then calls it the first time your code resolves an agent, opens the file at the path it returns, and applies the migrations list to that file. The migrations guide covers what the registry does when a later version reaches a file that is already on disk, and configuration lists every option the resolver accepts.
brainTools takes the agent identifier as an argument, which leaves the recall tool the model calls already holding one identifier, with the model supplying only the subject. Swap mockModel for a model string when you want a real model to choose the calls, since the mock is here so that the panels below print the same text on every run.
import { readdir } from 'node:fs/promises'
import { ToolLoopAgent } from 'ai'
import { agentId, resetTenantRoot, resolveAgentFile, sirannon, TENANT_ROOT } from './agent-store'
import { brainTools, remember } from './memory-tools'
import { mockModel } from './mock-model'
const AGREED_ON = Date.UTC(2026, 3, 9)
resetTenantRoot()
const agreeRefundWindow = async (customer: string, days: string): Promise<void> => {
await remember(sirannon, agentId(customer, 'support-agent'), {
subject: 'refund window',
predicate: 'length',
value: days,
source: 'signed-contract',
writtenBy: 'contract-loader',
learnedAt: AGREED_ON,
})
}
const askSupportAgent = async (customer: string): Promise<string> => {
const agent = new ToolLoopAgent({
model: mockModel('refund window'),
instructions: 'Answer from this customer store, using the recall tool.',
tools: brainTools(sirannon, agentId(customer, 'support-agent')),
})
const reply = await agent.generate({ prompt: 'How long is the refund window?' })
return reply.text
}
await agreeRefundWindow('sundara-logistics', '30 days')
await agreeRefundWindow('kestrel-analytics', '14 days')
await remember(sirannon, agentId('sundara-logistics', 'billing-agent'), {
subject: 'invoice currency',
predicate: 'code',
value: 'USD',
source: 'signed-contract',
writtenBy: 'contract-loader',
learnedAt: AGREED_ON,
})
const databaseFiles = async (): Promise<string[]> => {
const entries = await readdir(TENANT_ROOT)
return entries.filter((name) => name.endsWith('.db')).sort()
}
console.log(
JSON.stringify(
{
sundaraSupportAnswer: await askSupportAgent('sundara-logistics'),
kestrelSupportAnswer: await askSupportAgent('kestrel-analytics'),
rejectedIdentifier: resolveAgentFile('sundara-logistics/../kestrel-analytics__support-agent') ?? null,
filesOnDisk: await databaseFiles(),
},
null,
2
)
)
await sirannon.shutdown(){
"sundaraSupportAnswer": "The refund window is 30 days.",
"kestrelSupportAnswer": "The refund window is 14 days.",
"rejectedIdentifier": null,
"filesOnDisk": [
"kestrel-analytics__support-agent.db",
"sundara-logistics__billing-agent.db",
"sundara-logistics__support-agent.db"
]
}Each agent answers with its own customer's refund window. The resolver returns no path for the identifier holding .., and the registry therefore opens no database for that one. Sundara Logistics has two files and Kestrel Analytics has one.
Decide where the boundary falls
The identifier is what puts two agents in one store or in two, because the resolver turns it into a path and every query then runs against that one file. agentId joins the customer to the role, so a customer's support agent and its billing agent hold separate files. An identifier without the role would give a whole customer one store, and an identifier holding the session would give each conversation a file of its own.
The questions below put that boundary to the test. Sundara Logistics agreed an invoice currency with its billing agent, and the example then puts that same question to the support agent serving the same customer.
import { readdir } from 'node:fs/promises'
import { ToolLoopAgent } from 'ai'
import { agentId, resetTenantRoot, resolveAgentFile, sirannon, TENANT_ROOT } from './agent-store'
import { brainTools, remember } from './memory-tools'
import { mockModel } from './mock-model'
const AGREED_ON = Date.UTC(2026, 3, 9)
resetTenantRoot()
const agreeRefundWindow = async (customer: string, days: string): Promise<void> => {
await remember(sirannon, agentId(customer, 'support-agent'), {
subject: 'refund window',
predicate: 'length',
value: days,
source: 'signed-contract',
writtenBy: 'contract-loader',
learnedAt: AGREED_ON,
})
}
const askSupportAgent = async (customer: string): Promise<string> => {
const ask = async (customer: string, role: string, subject: string, question: string): Promise<string> => {
const agent = new ToolLoopAgent({
model: mockModel('refund window'),
instructions: 'Answer from this customer store, using the recall tool.',
tools: brainTools(sirannon, agentId(customer, 'support-agent')),
model: mockModel(subject),
instructions: 'Answer from the store this agent holds, using the recall tool.',
tools: brainTools(sirannon, agentId(customer, role)),
})
const reply = await agent.generate({ prompt: 'How long is the refund window?' })
const reply = await agent.generate({ prompt: question })
return reply.text
}
const askSupportAgent = (customer: string): Promise<string> =>
ask(customer, 'support-agent', 'refund window', 'How long is the refund window?')
const askForTheCurrency = (customer: string, role: string): Promise<string> =>
ask(customer, role, 'invoice currency', 'Which currency do we invoice in?')
await agreeRefundWindow('sundara-logistics', '30 days')
await agreeRefundWindow('kestrel-analytics', '14 days')
await remember(sirannon, agentId('sundara-logistics', 'billing-agent'), {
subject: 'invoice currency',
predicate: 'code',
value: 'USD',
source: 'signed-contract',
writtenBy: 'contract-loader',
learnedAt: AGREED_ON,
})
const databaseFiles = async (): Promise<string[]> => {
const entries = await readdir(TENANT_ROOT)
return entries.filter((name) => name.endsWith('.db')).sort()
}
console.log(
JSON.stringify(
{
sundaraSupportAnswer: await askSupportAgent('sundara-logistics'),
kestrelSupportAnswer: await askSupportAgent('kestrel-analytics'),
sundaraBillingOnTheCurrency: await askForTheCurrency('sundara-logistics', 'billing-agent'),
sundaraSupportOnTheCurrency: await askForTheCurrency('sundara-logistics', 'support-agent'),
rejectedIdentifier: resolveAgentFile('sundara-logistics/../kestrel-analytics__support-agent') ?? null,
filesOnDisk: await databaseFiles(),
},
null,
2
)
)
await sirannon.shutdown(){
"sundaraSupportAnswer": "The refund window is 30 days.",
"kestrelSupportAnswer": "The refund window is 14 days.",
"sundaraBillingOnTheCurrency": "The invoice currency is USD.",
"sundaraSupportOnTheCurrency": "I have no record of the invoice currency.",
"rejectedIdentifier": null,
"filesOnDisk": [
"kestrel-analytics__support-agent.db",
"sundara-logistics__billing-agent.db",
"sundara-logistics__support-agent.db"
]
}The billing agent answers with the currency its own file holds, and the support agent for that same customer has no record of it. Neither query names a customer or a role, since the file each one runs against has already settled both.
Hold only a few of those files open at once
A fleet can hold more agents than your process has file handles for. maxOpen therefore bounds how many of these databases stay open together, while idleTimeout closes any database that goes without a read or a write for that long. Set both in the lifecycle block you already pass the resolver to. Where a resolve reaches the registry at its cap, it closes the least recently used database to make room, and it raises MAX_DATABASES when the count is still at the cap after that close.
The cap below is two and the timeout is one second, which is enough for a single run to reach both. Take your own cap from the file descriptors your process may hold along with the number of agents you expect to work at the same time, and set the timeout in minutes.
onDatabaseClose reports each close as the registry makes it. The file below records those closes, and it then waits on that hook until the registry holds nothing open. That wait is here so that one file can show the sweep, and your own service would register the same hook and act on each close as it arrives. The lifecycle guide covers the same two options for tenants that are not agents.
The dashed line marks maxOpen, the 2 file handles the registry holds open at once.
import { readdir } from 'node:fs/promises'
import { ToolLoopAgent } from 'ai'
import { agentId, resetTenantRoot, resolveAgentFile, sirannon, TENANT_ROOT } from './agent-store'
import { brainTools, remember } from './memory-tools'
import { mockModel } from './mock-model'
const AGREED_ON = Date.UTC(2026, 3, 9)
resetTenantRoot()
const agreeRefundWindow = async (customer: string, days: string): Promise<void> => {
await remember(sirannon, agentId(customer, 'support-agent'), {
subject: 'refund window',
predicate: 'length',
value: days,
source: 'signed-contract',
writtenBy: 'contract-loader',
learnedAt: AGREED_ON,
})
}
const ask = async (customer: string, role: string, subject: string, question: string): Promise<string> => {
const agent = new ToolLoopAgent({
model: mockModel(subject),
instructions: 'Answer from the store this agent holds, using the recall tool.',
tools: brainTools(sirannon, agentId(customer, role)),
})
const reply = await agent.generate({ prompt: question })
return reply.text
}
const askSupportAgent = (customer: string): Promise<string> =>
ask(customer, 'support-agent', 'refund window', 'How long is the refund window?')
const askForTheCurrency = (customer: string, role: string): Promise<string> =>
ask(customer, role, 'invoice currency', 'Which currency do we invoice in?')
await agreeRefundWindow('sundara-logistics', '30 days')
await agreeRefundWindow('kestrel-analytics', '14 days')
await remember(sirannon, agentId('sundara-logistics', 'billing-agent'), {
subject: 'invoice currency',
predicate: 'code',
value: 'USD',
source: 'signed-contract',
writtenBy: 'contract-loader',
learnedAt: AGREED_ON,
})
const databaseFiles = async (): Promise<string[]> => {
const entries = await readdir(TENANT_ROOT)
return entries.filter((name) => name.endsWith('.db')).sort()
}
const closedByTheRegistry: string[] = []
sirannon.onDatabaseClose(({ databaseId }) => {
closedByTheRegistry.push(databaseId)
})
const untilTheRegistryClosesEveryDatabase = (): Promise<void> =>
new Promise((settle, fail) => {
const giveUp = setTimeout(() => {
fail(new Error('the idle sweep closed no database'))
}, 10_000)
sirannon.onDatabaseClose(() => {
if (sirannon.databases().size > 0) return
clearTimeout(giveUp)
settle()
})
})
const sundaraSupportAnswer = await askSupportAgent('sundara-logistics')
const kestrelSupportAnswer = await askSupportAgent('kestrel-analytics')
const sundaraBillingOnTheCurrency = await askForTheCurrency('sundara-logistics', 'billing-agent')
const sundaraSupportOnTheCurrency = await askForTheCurrency('sundara-logistics', 'support-agent')
const filesOnDisk = await databaseFiles()
const openAfterTheQuestions = sirannon.databases().size
const closedByTheCap = [...closedByTheRegistry]
await untilTheRegistryClosesEveryDatabase()
const closedByTheIdleSweep = closedByTheRegistry.slice(closedByTheCap.length)
const openAfterTheIdleSweep = sirannon.databases().size
const sundaraAnswerAfterTheSweep = await askSupportAgent('sundara-logistics')
const openAfterThatQuestion = sirannon.databases().size
console.log(
JSON.stringify(
{
sundaraSupportAnswer: await askSupportAgent('sundara-logistics'),
kestrelSupportAnswer: await askSupportAgent('kestrel-analytics'),
sundaraBillingOnTheCurrency: await askForTheCurrency('sundara-logistics', 'billing-agent'),
sundaraSupportOnTheCurrency: await askForTheCurrency('sundara-logistics', 'support-agent'),
sundaraSupportAnswer,
kestrelSupportAnswer,
sundaraBillingOnTheCurrency,
sundaraSupportOnTheCurrency,
rejectedIdentifier: resolveAgentFile('sundara-logistics/../kestrel-analytics__support-agent') ?? null,
filesOnDisk: await databaseFiles(),
filesOnDisk,
openAfterTheQuestions,
closedByTheCap,
closedByTheIdleSweep,
openAfterTheIdleSweep,
sundaraAnswerAfterTheSweep,
openAfterThatQuestion,
},
null,
2
)
)
await sirannon.shutdown(){
"sundaraSupportAnswer": "The refund window is 30 days.",
"kestrelSupportAnswer": "The refund window is 14 days.",
"sundaraBillingOnTheCurrency": "The invoice currency is USD.",
"sundaraSupportOnTheCurrency": "I have no record of the invoice currency.",
"rejectedIdentifier": null,
"filesOnDisk": [
"kestrel-analytics__support-agent.db",
"sundara-logistics__billing-agent.db",
"sundara-logistics__support-agent.db"
],
"openAfterTheQuestions": 2,
"closedByTheCap": [
"kestrel-analytics__support-agent",
"sundara-logistics__billing-agent",
"sundara-logistics__support-agent",
"kestrel-analytics__support-agent"
],
"closedByTheIdleSweep": [
"sundara-logistics__billing-agent",
"sundara-logistics__support-agent"
],
"openAfterTheIdleSweep": 0,
"sundaraAnswerAfterTheSweep": "The refund window is 30 days.",
"openAfterThatQuestion": 1
}Three agents write three files, and the cap holds the registry to two open handles, which is why it closes four databases along the way to keep room for the next one. The idle sweep then closes the last two, and onDatabaseClose firing for the second of those releases the example from its wait. The question after that opens one of those files again and the agent gives the same answer, because closing a database leaves the file where it is.
Delete every file a customer's agents wrote
A customer who leaves is done with their agents, and the files those agents wrote are therefore yours to delete. forgetCustomer closes every open database whose identifier starts with that customer, since SQLite holds each file open until its close returns. It then rebuilds each path with tenantPath and removes the database file along with its write-ahead log and its shared-memory file, because a process killed part-way through leaves all three of them behind.
import { readdir } from 'node:fs/promises'
import { ToolLoopAgent } from 'ai'
import { agentId, resetTenantRoot, resolveAgentFile, sirannon, TENANT_ROOT } from './agent-store'
import { brainTools, remember } from './memory-tools'
import { brainTools, forgetCustomer, remember } from './memory-tools'
import { mockModel } from './mock-model'
const AGREED_ON = Date.UTC(2026, 3, 9)
resetTenantRoot()
const agreeRefundWindow = async (customer: string, days: string): Promise<void> => {
await remember(sirannon, agentId(customer, 'support-agent'), {
subject: 'refund window',
predicate: 'length',
value: days,
source: 'signed-contract',
writtenBy: 'contract-loader',
learnedAt: AGREED_ON,
})
}
const ask = async (customer: string, role: string, subject: string, question: string): Promise<string> => {
const agent = new ToolLoopAgent({
model: mockModel(subject),
instructions: 'Answer from the store this agent holds, using the recall tool.',
tools: brainTools(sirannon, agentId(customer, role)),
})
const reply = await agent.generate({ prompt: question })
return reply.text
}
const askSupportAgent = (customer: string): Promise<string> =>
ask(customer, 'support-agent', 'refund window', 'How long is the refund window?')
const askForTheCurrency = (customer: string, role: string): Promise<string> =>
ask(customer, role, 'invoice currency', 'Which currency do we invoice in?')
await agreeRefundWindow('sundara-logistics', '30 days')
await agreeRefundWindow('kestrel-analytics', '14 days')
await remember(sirannon, agentId('sundara-logistics', 'billing-agent'), {
subject: 'invoice currency',
predicate: 'code',
value: 'USD',
source: 'signed-contract',
writtenBy: 'contract-loader',
learnedAt: AGREED_ON,
})
const databaseFiles = async (): Promise<string[]> => {
const entries = await readdir(TENANT_ROOT)
return entries.filter((name) => name.endsWith('.db')).sort()
}
const closedByTheRegistry: string[] = []
sirannon.onDatabaseClose(({ databaseId }) => {
closedByTheRegistry.push(databaseId)
})
const untilTheRegistryClosesEveryDatabase = (): Promise<void> =>
new Promise((settle, fail) => {
const giveUp = setTimeout(() => {
fail(new Error('the idle sweep closed no database'))
}, 10_000)
sirannon.onDatabaseClose(() => {
if (sirannon.databases().size > 0) return
clearTimeout(giveUp)
settle()
})
})
const sundaraSupportAnswer = await askSupportAgent('sundara-logistics')
const kestrelSupportAnswer = await askSupportAgent('kestrel-analytics')
const sundaraBillingOnTheCurrency = await askForTheCurrency('sundara-logistics', 'billing-agent')
const sundaraSupportOnTheCurrency = await askForTheCurrency('sundara-logistics', 'support-agent')
const filesOnDisk = await databaseFiles()
const openAfterTheQuestions = sirannon.databases().size
const closedByTheCap = [...closedByTheRegistry]
await untilTheRegistryClosesEveryDatabase()
const closedByTheIdleSweep = closedByTheRegistry.slice(closedByTheCap.length)
const openAfterTheIdleSweep = sirannon.databases().size
const sundaraAnswerAfterTheSweep = await askSupportAgent('sundara-logistics')
const openAfterThatQuestion = sirannon.databases().size
const removedForSundara = await forgetCustomer(sirannon, 'sundara-logistics')
const filesAfterTheDelete = await databaseFiles()
const sundaraAnswerAfterTheDelete = await askSupportAgent('sundara-logistics')
const kestrelAnswerAfterTheDelete = await askSupportAgent('kestrel-analytics')
console.log(
JSON.stringify(
{
sundaraSupportAnswer,
kestrelSupportAnswer,
sundaraBillingOnTheCurrency,
sundaraSupportOnTheCurrency,
rejectedIdentifier: resolveAgentFile('sundara-logistics/../kestrel-analytics__support-agent') ?? null,
filesOnDisk,
openAfterTheQuestions,
closedByTheCap,
closedByTheIdleSweep,
openAfterTheIdleSweep,
sundaraAnswerAfterTheSweep,
openAfterThatQuestion,
removedForSundara,
filesAfterTheDelete,
sundaraAnswerAfterTheDelete,
kestrelAnswerAfterTheDelete,
},
null,
2
)
)
await sirannon.shutdown(){
"sundaraSupportAnswer": "The refund window is 30 days.",
"kestrelSupportAnswer": "The refund window is 14 days.",
"sundaraBillingOnTheCurrency": "The invoice currency is USD.",
"sundaraSupportOnTheCurrency": "I have no record of the invoice currency.",
"rejectedIdentifier": null,
"filesOnDisk": [
"kestrel-analytics__support-agent.db",
"sundara-logistics__billing-agent.db",
"sundara-logistics__support-agent.db"
],
"openAfterTheQuestions": 2,
"closedByTheCap": [
"kestrel-analytics__support-agent",
"sundara-logistics__billing-agent",
"sundara-logistics__support-agent",
"kestrel-analytics__support-agent"
],
"closedByTheIdleSweep": [
"sundara-logistics__billing-agent",
"sundara-logistics__support-agent"
],
"openAfterTheIdleSweep": 0,
"sundaraAnswerAfterTheSweep": "The refund window is 30 days.",
"openAfterThatQuestion": 1,
"removedForSundara": 2,
"filesAfterTheDelete": [
"kestrel-analytics__support-agent.db"
],
"sundaraAnswerAfterTheDelete": "I have no record of the refund window.",
"kestrelAnswerAfterTheDelete": "The refund window is 14 days."
}The delete removes both of that customer's databases along with their write-ahead and shared-memory files. Kestrel Analytics answers as it did before, because forgetCustomer matches only the identifiers opening with the customer you pass it. The identifier stays valid, and the next question therefore opens an empty database and writes the file again. Deleting those files removes what a customer's agents stored, while refusing those agents a database at all is a decision your own code makes.
Where to go next
Memory and forgetting covers the fact table in each of these files, where every row has a source and an expiry date, and a newer value marks the older row as superseded. Where a false fact reaches one of these files, rewinding memory restores that one database to the state before that write, leaving every other customer's files untouched. For a record of which agent wrote each row, read auditing a fleet.