Say that months later somebody asks a lettings agency which of its AI agents served a notice to quit, and no part of the fleet can answer. Sirannon reports every committed change to its subscribers and then deletes it from the change table once it is older than cdcRetention. The default of one hour therefore leaves that table holding only the changes of the last hour.
A question put that late has an answer only where you keep a record of your own. Register onAfterQuery on the registry to write that record, because a single registration covers every database the registry opens. Each call includes the databaseId the agent's own database is registered under.
The agency gives every agent a database of its own, as a database per AI agent sets out. A compliance agent then answers the questions people put to the agency by reading one journal database, in place of opening every agent's file. Each code block below holds one whole file.
Ask an agent what the fleet did
tenancy-agent and arrears-agent each record what they have done through the recordAction tool, which writes one row into that agent's own file. askCompliance puts a person's question to a third agent, whose only tool reads the journal.
Nothing writes to the journal yet, and that third agent therefore answers from an empty table.
fleetModel and complianceModel are mock models, which is what fixes the text in the panels below. Point either of them at a model string once you want the model to choose.
import { mkdirSync, rmSync } from 'node:fs'
import { type Database, Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { complianceModel, fleetModel, type JournalEntry, type RecordOutcome, type TenancyAction } from './mock-model'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })
const journal = await sirannon.open('journal', './data/journal.db')
await journal.execute(`CREATE TABLE journal_entry (
id INTEGER PRIMARY KEY,
agent TEXT NOT NULL,
values_json TEXT NOT NULL,
outcome TEXT NOT NULL
)`)
const openAgent = async (agentId: string): Promise<Database> => {
const open = sirannon.get(agentId)
if (open !== undefined) return open
const db = await sirannon.open(agentId, `./data/${agentId}.db`)
await db.execute(`CREATE TABLE action (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
tenancy TEXT NOT NULL,
taken_on TEXT NOT NULL
)`)
return db
}
const deskTools = (db: Database) => ({
recordAction: tool({
description: 'Record an action this agent has taken on a tenancy.',
inputSchema: z.object({ kind: z.string(), tenancy: z.string(), takenOn: z.string() }),
execute: async ({ kind, tenancy, takenOn }: TenancyAction): Promise<RecordOutcome> => {
await db.execute('INSERT INTO action (kind, tenancy, taken_on) VALUES (?, ?, ?)', [kind, tenancy, takenOn])
return { outcome: 'recorded', reason: null }
},
}),
})
const act = async (agentId: string, action: TenancyAction): Promise<string> => {
const agent = new ToolLoopAgent({
model: fleetModel(action),
instructions: 'Record what you have done on this tenancy, then say what you recorded.',
tools: deskTools(await openAgent(agentId)),
})
const reply = await agent.generate({ prompt: `Record a ${action.kind} at ${action.tenancy} on ${action.takenOn}.` })
return `${agentId} says: ${reply.text}`
}
const complianceTools = {
searchJournal: tool({
description: 'Find every journal entry naming a tenancy.',
inputSchema: z.object({ tenancy: z.string() }),
execute: async ({ tenancy }: { tenancy: string }): Promise<JournalEntry[]> => {
const rows = await journal.query<{ agent: string; values_json: string; outcome: string }>(
'SELECT agent, values_json, outcome FROM journal_entry WHERE values_json LIKE ? ORDER BY id',
[`%${tenancy}%`]
)
return rows.map((row) => ({
agent: row.agent,
values: JSON.parse(row.values_json) as string[],
outcome: row.outcome,
}))
},
}),
}
const askCompliance = async (tenancy: string): Promise<string> => {
const agent = new ToolLoopAgent({
model: complianceModel(tenancy),
instructions: 'Answer from the journal, using the searchJournal tool.',
tools: complianceTools,
})
const reply = await agent.generate({ prompt: `What did our agents do at ${tenancy}?` })
return reply.text
}
const fleetSaid = [
await act('tenancy-agent', { kind: 'notice to quit', tenancy: '14 Rua do Sol', takenOn: '2026-02-03' }),
await act('arrears-agent', { kind: 'payment plan', tenancy: '9 Ulica Miodowa', takenOn: '2026-02-11' }),
]
console.log(JSON.stringify({ fleetSaid, askedAboutRuaDoSol: await askCompliance('14 Rua do Sol') }, null, 2))
await sirannon.shutdown(){
"fleetSaid": [
"tenancy-agent says: I have recorded a notice to quit at 14 Rua do Sol, dated 2026-02-03.",
"arrears-agent says: I have recorded a payment plan at 9 Ulica Miodowa, dated 2026-02-11."
],
"askedAboutRuaDoSol": "I hold no record of anything done at 14 Rua do Sol."
}Both agents do the work and report it, while the compliance agent reads an empty journal. Every action is in the file its own agent writes, which means the compliance agent would need the name of each agent, and a query against each file, before it could find one.
Record every write from one place
sirannon.onAfterQuery runs after each statement on every database in the registry, which is how the statements from tenancy-agent, arrears-agent, and any agent the registry opens later all reach the same handler. That handler skips the journal's own writes, because a journal entry is itself a statement and would otherwise record itself.
Sirannon never waits for what this hook returns. Two calls to an asynchronous handler can therefore overlap, and one journal write can still be under way when you read the table. Chaining each write onto journalled puts them in order. A service leaves that chain alone, while this file awaits it once, before the compliance agent reads the journal. The catch returns the chain to a resolved state, which lets the writes behind a failed one carry on. Send that error to your own logging system, and treat the console call here as a stand-in.
import { mkdirSync, rmSync } from 'node:fs'
import { type Database, Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { complianceModel, fleetModel, type JournalEntry, type RecordOutcome, type TenancyAction } from './mock-model'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })
const journal = await sirannon.open('journal', './data/journal.db')
await journal.execute(`CREATE TABLE journal_entry (
id INTEGER PRIMARY KEY,
agent TEXT NOT NULL,
values_json TEXT NOT NULL,
outcome TEXT NOT NULL
)`)
const WRITES = /^\s*(INSERT|UPDATE|DELETE)\b/i
let journalled: Promise<unknown> = Promise.resolve()
const record = (agent: string, values: unknown, outcome: string): void => {
const entry = [agent, JSON.stringify(values ?? []), outcome]
journalled = journalled
.then(() => journal.execute('INSERT INTO journal_entry (agent, values_json, outcome) VALUES (?, ?, ?)', entry))
.catch((error: Error) => console.error('journal write failed', error.message))
}
sirannon.onAfterQuery((ctx) => {
if (ctx.databaseId === 'journal' || !WRITES.test(ctx.sql)) return
record(ctx.databaseId, ctx.params, 'applied')
})
const openAgent = async (agentId: string): Promise<Database> => {
const open = sirannon.get(agentId)
if (open !== undefined) return open
const db = await sirannon.open(agentId, `./data/${agentId}.db`)
await db.execute(`CREATE TABLE action (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
tenancy TEXT NOT NULL,
taken_on TEXT NOT NULL
)`)
return db
}
const deskTools = (db: Database) => ({
recordAction: tool({
description: 'Record an action this agent has taken on a tenancy.',
inputSchema: z.object({ kind: z.string(), tenancy: z.string(), takenOn: z.string() }),
execute: async ({ kind, tenancy, takenOn }: TenancyAction): Promise<RecordOutcome> => {
await db.execute('INSERT INTO action (kind, tenancy, taken_on) VALUES (?, ?, ?)', [kind, tenancy, takenOn])
return { outcome: 'recorded', reason: null }
},
}),
})
const act = async (agentId: string, action: TenancyAction): Promise<string> => {
const agent = new ToolLoopAgent({
model: fleetModel(action),
instructions: 'Record what you have done on this tenancy, then say what you recorded.',
tools: deskTools(await openAgent(agentId)),
})
const reply = await agent.generate({ prompt: `Record a ${action.kind} at ${action.tenancy} on ${action.takenOn}.` })
return `${agentId} says: ${reply.text}`
}
const complianceTools = {
searchJournal: tool({
description: 'Find every journal entry naming a tenancy.',
inputSchema: z.object({ tenancy: z.string() }),
execute: async ({ tenancy }: { tenancy: string }): Promise<JournalEntry[]> => {
const rows = await journal.query<{ agent: string; values_json: string; outcome: string }>(
'SELECT agent, values_json, outcome FROM journal_entry WHERE values_json LIKE ? ORDER BY id',
[`%${tenancy}%`]
)
return rows.map((row) => ({
agent: row.agent,
values: JSON.parse(row.values_json) as string[],
outcome: row.outcome,
}))
},
}),
}
const askCompliance = async (tenancy: string): Promise<string> => {
const agent = new ToolLoopAgent({
model: complianceModel(tenancy),
instructions: 'Answer from the journal, using the searchJournal tool.',
tools: complianceTools,
})
const reply = await agent.generate({ prompt: `What did our agents do at ${tenancy}?` })
return reply.text
}
const fleetSaid = [
await act('tenancy-agent', { kind: 'notice to quit', tenancy: '14 Rua do Sol', takenOn: '2026-02-03' }),
await act('arrears-agent', { kind: 'payment plan', tenancy: '9 Ulica Miodowa', takenOn: '2026-02-11' }),
]
await journalled
console.log(JSON.stringify({ fleetSaid, askedAboutRuaDoSol: await askCompliance('14 Rua do Sol') }, null, 2))
await sirannon.shutdown(){
"fleetSaid": [
"tenancy-agent says: I have recorded a notice to quit at 14 Rua do Sol, dated 2026-02-03.",
"arrears-agent says: I have recorded a payment plan at 9 Ulica Miodowa, dated 2026-02-11."
],
"askedAboutRuaDoSol": "At 14 Rua do Sol, tenancy-agent recorded a notice to quit on 2026-02-03."
}The compliance agent names tenancy-agent and the date of the notice. The journal keeps that entry after the change feed prunes the change, and after somebody deletes the row itself.
sirannon.onAfterQuery
Register it once, before the first agent opens.
tenancy-agent.db
The file holds every notice to quit this agent serves.
arrears-agent.db
The file holds every payment plan this agent agrees.
every later agent
The hook covers a file the registry opens long after this line runs.
journal.db
It holds one entry for each write, naming the agent that ran it.
Record what a rule refused
A before-hook refuses a statement by throwing. A refused statement reaches no table, and onAfterQuery never runs for it. A journal built from that hook alone therefore holds the writes the fleet completes, and a person asking whether an agent tried something would read nothing about the attempt.
Catch HookDeniedError where the tool makes its write, and record the attempt under the same agent. The hook here refuses a notice to quit on a tenancy under six months old. The tool returns that reason to the model, which lets the agent explain itself to the person who asked.
import { mkdirSync, rmSync } from 'node:fs'
import { type Database, Sirannon } from '@delali/sirannon-db'
import { type Database, HookDeniedError, Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { complianceModel, fleetModel, type JournalEntry, type RecordOutcome, type TenancyAction } from './mock-model'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })
const journal = await sirannon.open('journal', './data/journal.db')
await journal.execute(`CREATE TABLE journal_entry (
id INTEGER PRIMARY KEY,
agent TEXT NOT NULL,
values_json TEXT NOT NULL,
outcome TEXT NOT NULL
)`)
const WRITES = /^\s*(INSERT|UPDATE|DELETE)\b/i
const UNDER_SIX_MONTHS = 'the tenancy has run for less than six months'
const RECENT_TENANCIES = new Set(['9 Ulica Miodowa'])
let journalled: Promise<unknown> = Promise.resolve()
const record = (agent: string, values: unknown, outcome: string): void => {
const entry = [agent, JSON.stringify(values ?? []), outcome]
journalled = journalled
.then(() => journal.execute('INSERT INTO journal_entry (agent, values_json, outcome) VALUES (?, ?, ?)', entry))
.catch((error: Error) => console.error('journal write failed', error.message))
}
sirannon.onAfterQuery((ctx) => {
if (ctx.databaseId === 'journal' || !WRITES.test(ctx.sql)) return
record(ctx.databaseId, ctx.params, 'applied')
})
sirannon.onBeforeQuery((ctx) => {
if (!ctx.sql.startsWith('INSERT INTO action')) return
const [kind, tenancy] = Array.isArray(ctx.params) ? (ctx.params as string[]) : []
if (kind === 'notice to quit' && tenancy !== undefined && RECENT_TENANCIES.has(tenancy)) {
throw new HookDeniedError(UNDER_SIX_MONTHS)
}
})
const openAgent = async (agentId: string): Promise<Database> => {
const open = sirannon.get(agentId)
if (open !== undefined) return open
const db = await sirannon.open(agentId, `./data/${agentId}.db`)
await db.execute(`CREATE TABLE action (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
tenancy TEXT NOT NULL,
taken_on TEXT NOT NULL
)`)
return db
}
const deskTools = (db: Database) => ({
const deskTools = (agentId: string, db: Database) => ({
recordAction: tool({
description: 'Record an action this agent has taken on a tenancy.',
inputSchema: z.object({ kind: z.string(), tenancy: z.string(), takenOn: z.string() }),
execute: async ({ kind, tenancy, takenOn }: TenancyAction): Promise<RecordOutcome> => {
await db.execute('INSERT INTO action (kind, tenancy, taken_on) VALUES (?, ?, ?)', [kind, tenancy, takenOn])
return { outcome: 'recorded', reason: null }
try {
await db.execute('INSERT INTO action (kind, tenancy, taken_on) VALUES (?, ?, ?)', [kind, tenancy, takenOn])
return { outcome: 'recorded', reason: null }
} catch (error) {
if (!(error instanceof HookDeniedError)) throw error
record(agentId, [kind, tenancy, takenOn], UNDER_SIX_MONTHS)
return { outcome: 'refused', reason: UNDER_SIX_MONTHS }
}
},
}),
})
const act = async (agentId: string, action: TenancyAction): Promise<string> => {
const agent = new ToolLoopAgent({
model: fleetModel(action),
instructions: 'Record what you have done on this tenancy, then say what you recorded.',
tools: deskTools(await openAgent(agentId)),
tools: deskTools(agentId, await openAgent(agentId)),
})
const reply = await agent.generate({ prompt: `Record a ${action.kind} at ${action.tenancy} on ${action.takenOn}.` })
return `${agentId} says: ${reply.text}`
}
const complianceTools = {
searchJournal: tool({
description: 'Find every journal entry naming a tenancy.',
inputSchema: z.object({ tenancy: z.string() }),
execute: async ({ tenancy }: { tenancy: string }): Promise<JournalEntry[]> => {
const rows = await journal.query<{ agent: string; values_json: string; outcome: string }>(
'SELECT agent, values_json, outcome FROM journal_entry WHERE values_json LIKE ? ORDER BY id',
[`%${tenancy}%`]
)
return rows.map((row) => ({
agent: row.agent,
values: JSON.parse(row.values_json) as string[],
outcome: row.outcome,
}))
},
}),
}
const askCompliance = async (tenancy: string): Promise<string> => {
const agent = new ToolLoopAgent({
model: complianceModel(tenancy),
instructions: 'Answer from the journal, using the searchJournal tool.',
tools: complianceTools,
})
const reply = await agent.generate({ prompt: `What did our agents do at ${tenancy}?` })
return reply.text
}
const fleetSaid = [
await act('tenancy-agent', { kind: 'notice to quit', tenancy: '14 Rua do Sol', takenOn: '2026-02-03' }),
await act('arrears-agent', { kind: 'payment plan', tenancy: '9 Ulica Miodowa', takenOn: '2026-02-11' }),
await act('tenancy-agent', { kind: 'notice to quit', tenancy: '9 Ulica Miodowa', takenOn: '2026-02-18' }),
]
await journalled
console.log(JSON.stringify({ fleetSaid, askedAboutRuaDoSol: await askCompliance('14 Rua do Sol') }, null, 2))
console.log(
JSON.stringify(
{
fleetSaid,
askedAboutRuaDoSol: await askCompliance('14 Rua do Sol'),
askedAboutUlicaMiodowa: await askCompliance('9 Ulica Miodowa'),
},
null,
2
)
)
await sirannon.shutdown(){
"fleetSaid": [
"tenancy-agent says: I have recorded a notice to quit at 14 Rua do Sol, dated 2026-02-03.",
"arrears-agent says: I have recorded a payment plan at 9 Ulica Miodowa, dated 2026-02-11.",
"tenancy-agent says: The desk refused a notice to quit at 9 Ulica Miodowa, because the tenancy has run for less than six months."
],
"askedAboutRuaDoSol": "At 14 Rua do Sol, tenancy-agent recorded a notice to quit on 2026-02-03.",
"askedAboutUlicaMiodowa": "At 9 Ulica Miodowa, arrears-agent recorded a payment plan on 2026-02-11. Then a hook refused tenancy-agent a notice to quit on 2026-02-18, because the tenancy has run for less than six months."
}The compliance agent now reports both the payment plan and the notice the six-month rule refuses, and tenancy-agent gives the same reason at the moment of the refusal. Both answers come from one journal, since the tool writes the refusal through the same record function the hook uses.
Where to go next
The journal is a Sirannon database, which means continuous backups copy it as it grows and a restore can rebuild it, as restoring a database sets out. Where the record shows an agent writing something you never allowed, rewinding memory puts that agent's database back to a moment before the write. The hooks reference lists every hook the registry takes, along with the metrics callbacks for each change event and its subscriber count.