Picture a rail maintenance crew taking a tablet into the Ryfylke tunnel, where no route to any server exists for the length of the shift. An AI agent that calls a remote database has nowhere to write during that stretch, which leaves it either stopped or holding its results inside a process that can exit.
An agent whose database is on the device writes to a local file whether or not the network is there. Device sync then pushes those writes to the server once the connection returns, and it pulls back what everyone else wrote. None of that loop is yours to write. The device sync page covers the controller in full, including snapshots, resync, and the resolvers, which leaves this page to what an agent on a device needs.
Each code block below holds one whole file.
Answer with no connection at all
db.live registers the read the agent answers from, and Sirannon keeps its rows current for as long as that query is open. readDefects therefore returns whatever the tablet holds at the moment of the call, and it issues no query of its own.
db.watch records each local change from the start, and device sync pushes those changes once a connection exists. This step builds no SyncController at all, and that is the tablet's situation inside the tunnel.
crewModel stands in for a model, and the answer in the panel below comes from it. Give the agent a model string in its place once a real model should write those answers.
import { mkdirSync, rmSync } from 'node:fs'
import { type LiveQuery, Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { crewModel, type Defect, QUESTION } from './mock-model'
const AS_JUNCTION = 'Ås junction points motor'
const DEFECT_TABLE = `CREATE TABLE IF NOT EXISTS defect (
id TEXT PRIMARY KEY,
asset TEXT NOT NULL,
note TEXT NOT NULL,
status TEXT NOT NULL
)`
const driver = betterSqlite3()
const tablet = new Sirannon({ driver })
rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })
const onTablet = await tablet.open('defects', './data/tablet.db')
await onTablet.execute(DEFECT_TABLE)
await onTablet.watch('defect')
await onTablet.execute('INSERT INTO defect (id, asset, note, status) VALUES (?, ?, ?, ?)', [
'd-as-points',
AS_JUNCTION,
'It sticks on the second throw',
'open',
])
const board = await onTablet.live<Defect>('SELECT asset, note, status FROM defect ORDER BY id')
const crewTools = (view: LiveQuery<Defect>) => ({
readDefects: tool({
description: 'Read every defect this tablet holds.',
inputSchema: z.object({}),
execute: async (): Promise<Defect[]> => {
const state = view.getState()
return state.status === 'ready' ? [...state.rows] : []
},
}),
})
const askCrewAgent = async (): Promise<string> => {
const agent = new ToolLoopAgent({
model: crewModel(),
instructions: 'Answer from the defects on this tablet.',
tools: crewTools(board),
})
const reply = await agent.generate({ prompt: QUESTION })
return reply.text
}
console.log(JSON.stringify({ withNoConnection: await askCrewAgent() }, null, 2))
await board.close()
await tablet.shutdown(){
"withNoConnection": "The Ås junction points motor needs a crew."
}The agent answers from a file on the tablet, and that answer covers exactly what this crew has written down.
Reconcile once the connection returns
Three options connect the tablet to the depot's server: the server's address, the identifier of the database on it, and the tables this device syncs. start fetches the server's capabilities, settles the schema handshake, opens the live pull, and begins the push loop. Nothing else in the file below changes, and the tool the agent calls is the one you already have.
Point url at a Sirannon server of your own, which the server page covers, since the controller connects as soon as you start it. Call status when you want to know where this device stands, and its pendingPushCount reads zero once every local change has reached the server.
Sirannon commits each pulled change into the same database the live query reads, and the query takes that change the way it takes a local one. The agent therefore answers from the rows as they stand at the moment of the call, and this file holds no onChange handler and no re-read of its own.
import { mkdirSync, rmSync } from 'node:fs'
import { type LiveQuery, Sirannon } from '@delali/sirannon-db'
import { SyncController } from '@delali/sirannon-db/client'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { crewModel, type Defect, QUESTION } from './mock-model'
const AS_JUNCTION = 'Ås junction points motor'
const DEFECT_TABLE = `CREATE TABLE IF NOT EXISTS defect (
id TEXT PRIMARY KEY,
asset TEXT NOT NULL,
note TEXT NOT NULL,
status TEXT NOT NULL
)`
const driver = betterSqlite3()
const tablet = new Sirannon({ driver })
rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })
const onTablet = await tablet.open('defects', './data/tablet.db')
await onTablet.execute(DEFECT_TABLE)
await onTablet.watch('defect')
await onTablet.execute('INSERT INTO defect (id, asset, note, status) VALUES (?, ?, ?, ?)', [
'd-as-points',
AS_JUNCTION,
'It sticks on the second throw',
'open',
])
const board = await onTablet.live<Defect>('SELECT asset, note, status FROM defect ORDER BY id')
const crewTools = (view: LiveQuery<Defect>) => ({
readDefects: tool({
description: 'Read every defect this tablet holds.',
inputSchema: z.object({}),
execute: async (): Promise<Defect[]> => {
const state = view.getState()
return state.status === 'ready' ? [...state.rows] : []
},
}),
})
const askCrewAgent = async (): Promise<string> => {
const agent = new ToolLoopAgent({
model: crewModel(),
instructions: 'Answer from the defects on this tablet.',
tools: crewTools(board),
})
const reply = await agent.generate({ prompt: QUESTION })
return reply.text
}
console.log(JSON.stringify({ withNoConnection: await askCrewAgent() }, null, 2))
const withNoConnection = await askCrewAgent()
const sync = new SyncController(onTablet, {
url: 'https://depot.example.com',
databaseId: 'defects',
tables: ['defect'],
})
await sync.start()
console.log(JSON.stringify({ withNoConnection }, null, 2))
await sync.stop()
await board.close()
await tablet.shutdown()In the tunnel, with no connection
tablet.db
the depot server
This database holds nothing yet.
Connected, with the day crew logging a defect
tablet.db
the depot server
Still connected, after the day crew clears the motor
tablet.db
the depot server
Where to go next
Two crews editing one row while they are apart need a resolver. The device applies the resolver you pass the controller, while the server applies its own. A merge made on the device therefore leaves the two sides holding different rows until one of them writes again. Keeping each crew's facts in rows of their own avoids that merge, and the device sync page covers what to write in a resolver. A tablet that falls too far behind, loses its file, or receives a schema it cannot apply needs device sync recovery. The database this agent writes to is the same shape as any other agent's, and memory and forgetting applies to it unchanged.