> Complete content index: https://sirannon.sondelali.com/llms.txt

# Offline AI agents

> Give an AI agent a database on the device it works from, let it answer with no connection at all, and keep every later answer current from one live query as device sync reconciles the two sides.

URL: https://sirannon.sondelali.com/docs/agent-offline
Section: AI agents
Version: 0.3 (@delali/sirannon-db@0.3.0, latest)

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](/docs/device-sync) 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.

<CodeGroup defaultTab="field-agent.ts">

```ts title="field-agent.ts"

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()
```

```json result open
{
  "withNoConnection": "The Ås junction points motor needs a crew."
}
```

```ts title="mock-model.ts"

const NO_USAGE = {
  inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined },
  outputTokens: { total: 0, text: 0, reasoning: undefined },
}

export const QUESTION = 'What still needs a crew?'

export interface Defect {
  asset: string
  note: string
  status: string
}

interface ToolResultPart {
  toolName?: string
  output?: { value?: unknown }
}

interface PromptMessage {
  role: string
  content: ToolResultPart[] | string
}

const answered = (prompt: unknown, toolName: string): unknown => {
  for (const message of prompt as PromptMessage[]) {
    if (message.role !== 'tool' || typeof message.content === 'string') continue
    for (const part of message.content) {
      if (part.toolName === toolName) return part.output?.value
    }
  }
  return undefined
}

const callTool = (toolName: string) => ({
  content: [{ type: 'tool-call' as const, toolCallId: `${toolName}-1`, toolName, input: '{}' }],
  finishReason: { unified: 'tool-calls' as const, raw: undefined },
  usage: NO_USAGE,
  warnings: [],
})

const say = (text: string) => ({
  content: [{ type: 'text' as const, text }],
  finishReason: { unified: 'stop' as const, raw: undefined },
  usage: NO_USAGE,
  warnings: [],
})

export const crewModel = () =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const defects = answered(prompt, 'readDefects') as Defect[] | undefined
      if (defects === undefined) return callTool('readDefects')
      const open = defects.filter((defect) => defect.status === 'open')
      if (open.length === 0) return say('Every defect on this tablet is cleared.')
      const assets = open.map((defect) => `the ${defect.asset}`).join(' and ')
      const verb = open.length === 1 ? 'needs' : 'need'
      return say(`${assets.charAt(0).toUpperCase()}${assets.slice(1)} ${verb} a crew.`)
    },
  })
```

</CodeGroup>

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](/docs/server) 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.

<CodeGroup defaultTab="field-agent.ts">

```ts title="field-agent.ts"

import { SyncController } from '@delali/sirannon-db/client' // [!code ++]

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)) // [!code --]
const withNoConnection = await askCrewAgent() // [!code ++]

// [!code ++:11]
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()
```

</CodeGroup>

<SyncMomentsDiagram
  deviceLabel="tablet.db"
  hubLabel="the depot server"
  agent="askCrewAgent"
  moments={[
    {
      when: 'In the tunnel, with no connection',
      device: [{ name: 'Ås junction points motor', status: 'open' }],
      hub: [],
      says: 'The Ås junction points motor needs a crew.',
    },
    {
      when: 'Connected, with the day crew logging a defect',
      device: [
        { name: 'Ås junction points motor', status: 'open' },
        { name: 'Ryfylke tunnel lighting', status: 'open' },
      ],
      hub: [
        { name: 'Ås junction points motor', status: 'open' },
        { name: 'Ryfylke tunnel lighting', status: 'open' },
      ],
      says: 'The Ås junction points motor and the Ryfylke tunnel lighting need a crew.',
    },
    {
      when: 'Still connected, after the day crew clears the motor',
      device: [
        { name: 'Ås junction points motor', status: 'cleared' },
        { name: 'Ryfylke tunnel lighting', status: 'open' },
      ],
      hub: [
        { name: 'Ås junction points motor', status: 'cleared' },
        { name: 'Ryfylke tunnel lighting', status: 'open' },
      ],
      says: 'The Ryfylke tunnel lighting needs a crew.',
    },
  ]}
  caption="Each row names what the two sides hold at that moment and the answer askCrewAgent gives there. The same tool and the same live query produce all three, which is why the answer changes with no extra code between the calls."
/>

## 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](/docs/device-sync#what-happens-when-two-people-edit-the-same-note) 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](/docs/device-sync-recovery). The database this agent writes to is the same shape as any other agent's, and [memory and forgetting](/docs/agent-memory) applies to it unchanged.
