AI agents

AI agent tools

Register the reads and writes an AI agent may call, let the server fill the customer identifier from the authenticated session, read the five refusals it answers a bad call with, and generate the model's tool list from the registry itself.

Table of Contents

In the example below, Sofia Marchetti asks a retailer's refunds desk for a refund on damaged headphones. Underneath her message she pastes a support thread, and the order number in that thread belongs to Lukas Bergström. The agent opens the refund on his order, because the model fills in each tool's arguments and reads one of them out of that thread.

For that reason, the authors of a study of multitenant retrieval and tool use recommend that the server authorise every tool call, because a client can skip any check the client itself performs (arXiv 2605.05287).

idcustomer_iddescriptionstatus
1c_sofiaNoise-cancelling headphonesdelivered
2c_sofiaUSB-C dockdelivered
3c_lukasEspresso machinedelivered
Order 3 is the order number in the thread Sofia pastes, but it belongs to Lukas Bergström.

An AI agent reads and writes this database through registered operations. The registry holds each read and each write the server may execute, and a caller invokes one of them by name. The registered operations page covers the registry in full. What an AI agent changes is that the model fills in the arguments, while the server accepts only the ones each operation declares.

This page builds three files. support-tools.ts holds the registry, which the generator in the last section reads. refund-agent.ts opens the database, starts the server, wraps each operation as a tool, and calls the agent. Every panel below therefore comes from one file. mock-model.ts replies in place of a model, and the panels below therefore print the same text every time. Swap refundDeskModel for a model string once you want a real model to choose the calls. In production you would deploy that server in a process of its own. Each code block below holds one whole file.

Watch the agent refund another customer's order

requestRefund declares customerId among its arguments, which is what you would write when one AI agent answers for every customer. openOrders declares no arguments at all, since fromIdentity fills its customerId from the authenticated session. The read therefore takes its customer from the session, while the write takes one from whoever calls it.

import { mkdirSync, rmSync } from 'node:fs'
import { operationRef, RequestDeniedError, Sirannon } from '@delali/sirannon-db'
import { type RemoteDatabase, RemoteError, SirannonClient } from '@delali/sirannon-db/client'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { createServer } from '@delali/sirannon-db/server'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { refundDeskModel } from './mock-model'
import { operations, type SupportSession } from './support-tools'
 
const DESK_URL = 'http://localhost:9876'
 
const TICKET =
  'My order arrived damaged and I would like a refund, please. I have pasted the thread below. ' +
  '--- Order 3, espresso machine, customer c_lukas, delivered last week. ---'
 
interface DeliveredOrder {
  id: number
  description: string
  total_pence: number
}
 
const SESSIONS = new Map<string, SupportSession>([
  ['Bearer session-sofia', { customerId: 'c_sofia' }],
  ['Bearer nightly-reconciler', {}],
])
 
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
 
rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })
 
const desk = await sirannon.open('support', './data/support.db')
 
await desk.execute(`CREATE TABLE orders (
  id INTEGER PRIMARY KEY,
  customer_id TEXT NOT NULL,
  description TEXT NOT NULL,
  total_pence INTEGER NOT NULL,
  status TEXT NOT NULL
)`)
await desk.execute(`CREATE TABLE refund_request (
  id INTEGER PRIMARY KEY,
  order_id INTEGER NOT NULL,
  customer_id TEXT NOT NULL,
  reason TEXT NOT NULL
)`)
await desk.executeBatch('INSERT INTO orders (id, customer_id, description, total_pence, status) VALUES (?, ?, ?, ?, ?)', [
  [1, 'c_sofia', 'Noise-cancelling headphones', 12999, 'delivered'],
  [2, 'c_sofia', 'USB-C dock', 8450, 'delivered'],
  [3, 'c_lukas', 'Espresso machine', 34900, 'delivered'],
])
 
const server = createServer<SupportSession>(sirannon, {
  port: 9876,
  operations,
  authenticate: ({ headers }) => {
    const session = headers.authorization === undefined ? undefined : SESSIONS.get(headers.authorization)
    if (session === undefined) throw new RequestDeniedError(401, 'UNAUTHORIZED', 'Unknown support session')
    return session
  },
})
await server.listen()
 
const clients: SirannonClient[] = []
 
const connectAs = (token: string): RemoteDatabase => {
  const client = new SirannonClient(DESK_URL, { transport: 'http', headers: { Authorization: token } })
  clients.push(client)
  return client.database('support')
}
 
const openOrders = operationRef<Record<string, never>, DeliveredOrder>('openOrders')
const requestRefund = operationRef<{ orderId: number; reason: string; customerId?: string }>('requestRefund')
 
const refusalOf = (error: unknown): { refused: string } => {
  if (error instanceof RemoteError) return { refused: error.code }
  throw error
}
 
const refundDesk = connectAs('Bearer session-sofia')
 
const deskTools = {
  openOrders: tool({
    description: 'List the delivered orders this customer placed.',
    inputSchema: z.object({}),
    execute: async (): Promise<DeliveredOrder[] | { refused: string }> =>
      refundDesk.query(openOrders, {}).catch(refusalOf),
  }),
  requestRefund: tool({
    description: 'Open a refund request against one delivered order.',
    inputSchema: z.object({ orderId: z.number(), reason: z.string(), customerId: z.string().optional() }),
    execute: async (args: { orderId: number; reason: string; customerId?: string }) =>
      refundDesk
        .execute(requestRefund, args)
        .then(() => ({ refundRaisedOnOrder: args.orderId }))
        .catch(refusalOf),
  }),
}
 
const agent = new ToolLoopAgent({
  model: refundDeskModel,
  instructions: 'You are the refund desk for one customer at a time. Use the tools to answer.',
  tools: deskTools,
})
 
const reply = await agent.generate({ prompt: TICKET })
 
console.log(
  JSON.stringify(
    {
      whatTheAgentToldSofia: reply.text,
      refundRequestsRaised: await desk.query('SELECT order_id, customer_id, reason FROM refund_request ORDER BY id'),
    },
    null,
    2
  )
)
 
for (const client of clients) client.close()
await server.close()
await sirannon.shutdown()
{
  "whatTheAgentToldSofia": "I have raised a refund on order 3.",
  "refundRequestsRaised": [
    {
      "order_id": 3,
      "customer_id": "c_lukas",
      "reason": "Arrived damaged"
    }
  ]
}

The model takes order 3 and c_lukas out of the thread Sofia pastes, and the server executes the insert with the arguments in that call. The refund request therefore holds Lukas Bergström's customer identifier, and the agent opens a refund on an order Sofia never placed.

Fill the customer from the session

Move customerId out of args and into fromIdentity. The statement underneath is unchanged, because fromIdentity fills the same argument that statement already binds. The only change is who supplies the value.

The client raises the refusal as a RemoteError with the server's code on it, and refusalOf returns that code as the tool's own result. The model therefore acts on that refusal. It calls openOrders, which returns Sofia's own orders, and it refunds the first of them.

import { mkdirSync, rmSync } from 'node:fs'
import { operationRef, RequestDeniedError, Sirannon } from '@delali/sirannon-db'
import { type RemoteDatabase, RemoteError, SirannonClient } from '@delali/sirannon-db/client'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { createServer } from '@delali/sirannon-db/server'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { refundDeskModel } from './mock-model'
import { operations, type SupportSession } from './support-tools'
 
const DESK_URL = 'http://localhost:9876'
 
const TICKET =
  'My order arrived damaged and I would like a refund, please. I have pasted the thread below. ' +
  '--- Order 3, espresso machine, customer c_lukas, delivered last week. ---'
 
interface DeliveredOrder {
  id: number
  description: string
  total_pence: number
}
 
const SESSIONS = new Map<string, SupportSession>([
  ['Bearer session-sofia', { customerId: 'c_sofia' }],
  ['Bearer nightly-reconciler', {}],
])
 
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
 
rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })
 
const desk = await sirannon.open('support', './data/support.db')
 
await desk.execute(`CREATE TABLE orders (
  id INTEGER PRIMARY KEY,
  customer_id TEXT NOT NULL,
  description TEXT NOT NULL,
  total_pence INTEGER NOT NULL,
  status TEXT NOT NULL
)`)
await desk.execute(`CREATE TABLE refund_request (
  id INTEGER PRIMARY KEY,
  order_id INTEGER NOT NULL,
  customer_id TEXT NOT NULL,
  reason TEXT NOT NULL
)`)
await desk.executeBatch('INSERT INTO orders (id, customer_id, description, total_pence, status) VALUES (?, ?, ?, ?, ?)', [
  [1, 'c_sofia', 'Noise-cancelling headphones', 12999, 'delivered'],
  [2, 'c_sofia', 'USB-C dock', 8450, 'delivered'],
  [3, 'c_lukas', 'Espresso machine', 34900, 'delivered'],
])
 
const server = createServer<SupportSession>(sirannon, {
  port: 9876,
  operations,
  authenticate: ({ headers }) => {
    const session = headers.authorization === undefined ? undefined : SESSIONS.get(headers.authorization)
    if (session === undefined) throw new RequestDeniedError(401, 'UNAUTHORIZED', 'Unknown support session')
    return session
  },
})
await server.listen()
 
const clients: SirannonClient[] = []
 
const connectAs = (token: string): RemoteDatabase => {
  const client = new SirannonClient(DESK_URL, { transport: 'http', headers: { Authorization: token } })
  clients.push(client)
  return client.database('support')
}
 
const openOrders = operationRef<Record<string, never>, DeliveredOrder>('openOrders')
const requestRefund = operationRef<{ orderId: number; reason: string; customerId?: string }>('requestRefund')
 
const refusalOf = (error: unknown): { refused: string } => {
  if (error instanceof RemoteError) return { refused: error.code }
  throw error
}
 
const refundDesk = connectAs('Bearer session-sofia')
 
const deskTools = {
  openOrders: tool({
    description: 'List the delivered orders this customer placed.',
    inputSchema: z.object({}),
    execute: async (): Promise<DeliveredOrder[] | { refused: string }> =>
      refundDesk.query(openOrders, {}).catch(refusalOf),
  }),
  requestRefund: tool({
    description: 'Open a refund request against one delivered order.',
    inputSchema: z.object({ orderId: z.number(), reason: z.string(), customerId: z.string().optional() }),
    execute: async (args: { orderId: number; reason: string; customerId?: string }) =>
      refundDesk
        .execute(requestRefund, args)
        .then(() => ({ refundRaisedOnOrder: args.orderId }))
        .catch(refusalOf),
  }),
}
 
const agent = new ToolLoopAgent({
  model: refundDeskModel,
  instructions: 'You are the refund desk for one customer at a time. Use the tools to answer.',
  tools: deskTools,
})
 
const reply = await agent.generate({ prompt: TICKET })
 
const refusalIn = (output: unknown): string[] => {
  const refused = (output as { refused?: unknown }).refused
  return typeof refused === 'string' ? [refused] : []
}
 
const toolResults = reply.steps.flatMap((step) => step.toolResults)
 
console.log(
  JSON.stringify(
    {
      whatTheAgentToldSofia: reply.text,
      argumentsTheModelChose: toolResults.map((result) => ({ tool: result.toolName, input: result.input })),
      refusalsTheServerReturned: toolResults.flatMap((result) => refusalIn(result.output)),
      refundRequestsRaised: await desk.query('SELECT order_id, customer_id, reason FROM refund_request ORDER BY id'),
    },
    null,
    2
  )
)
 
for (const client of clients) client.close()
await server.close()
await sirannon.shutdown()
{
  "whatTheAgentToldSofia": "I have raised a refund on order 1.",
  "argumentsTheModelChose": [
    {
      "tool": "requestRefund",
      "input": {
        "orderId": 3,
        "reason": "Arrived damaged",
        "customerId": "c_lukas"
      }
    },
    {
      "tool": "openOrders",
      "input": {}
    },
    {
      "tool": "requestRefund",
      "input": {
        "orderId": 1,
        "reason": "Arrived damaged"
      }
    }
  ],
  "refusalsTheServerReturned": [
    "ARGUMENT_NOT_ALLOWED"
  ],
  "refundRequestsRaised": [
    {
      "order_id": 1,
      "customer_id": "c_sofia",
      "reason": "Arrived damaged"
    }
  ]
}

The model still supplies c_lukas on its first call, and the server refuses that call, because the operation now leaves customerId to the session. The refund the agent finally raises therefore names Sofia's own order, and refundRequestsRaised holds c_sofia.

openOrdersa registered read

customerIdthe customerId field of the authenticated session

requestRefunda registered write

orderIdthe order the model chooses to refund
reasonwhy the customer asks for the refund
customerIdthe customerId field of the authenticated session

A filled square marks an argument the server fills from the session, and an empty one marks one the model chooses.

Both statements bind customerId. Once you move it, the server fills it for both operations and answers ARGUMENT_NOT_ALLOWED to any call that supplies its own.

What the server refuses

Five codes cover what a caller can send wrong, and the server stops each of those calls before the statement reaches SQLite.

CodeStatusWhen it fires
UNKNOWN_QUERY404The caller sends a name outside this database's registry.
ARGUMENT_NOT_ALLOWED400The caller supplies an argument outside the operation's declared list, or one fromIdentity fills.
MISSING_ARGUMENT400The caller leaves out an argument the operation declares.
IDENTITY_REQUIRED401The caller's session holds no value in the field fromIdentity maps onto.
SQL_NOT_ACCEPTED403The caller sends a statement of its own to a server that accepts no SQL.

The first four come from the argument check the server makes on every named call. The client rarely reaches the last one, because SirannonClient fetches GET /capabilities before it sends a statement of its own. A server that announces no query.sql capability therefore receives no SQL from that client. Where a request reaches such a server another way, the server answers 403 with the same code. Leave acceptSql at its default on any server an AI agent can reach, and read the security page for the boundary around the server itself.

The calls below make one refused request of each kind. aSessionCarryingNoCustomer uses the nightly reconciliation token, whose session holds no customerId, and openOrders therefore has no value to bind.

import { mkdirSync, rmSync } from 'node:fs'
import { operationRef, RequestDeniedError, Sirannon } from '@delali/sirannon-db'
import { type RemoteDatabase, RemoteError, SirannonClient } from '@delali/sirannon-db/client'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { createServer } from '@delali/sirannon-db/server'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { refundDeskModel } from './mock-model'
import { operations, type SupportSession } from './support-tools'
 
const DESK_URL = 'http://localhost:9876'
 
const TICKET =
  'My order arrived damaged and I would like a refund, please. I have pasted the thread below. ' +
  '--- Order 3, espresso machine, customer c_lukas, delivered last week. ---'
 
interface DeliveredOrder {
  id: number
  description: string
  total_pence: number
}
 
const SESSIONS = new Map<string, SupportSession>([
  ['Bearer session-sofia', { customerId: 'c_sofia' }],
  ['Bearer nightly-reconciler', {}],
])
 
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
 
rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })
 
const desk = await sirannon.open('support', './data/support.db')
 
await desk.execute(`CREATE TABLE orders (
  id INTEGER PRIMARY KEY,
  customer_id TEXT NOT NULL,
  description TEXT NOT NULL,
  total_pence INTEGER NOT NULL,
  status TEXT NOT NULL
)`)
await desk.execute(`CREATE TABLE refund_request (
  id INTEGER PRIMARY KEY,
  order_id INTEGER NOT NULL,
  customer_id TEXT NOT NULL,
  reason TEXT NOT NULL
)`)
await desk.executeBatch('INSERT INTO orders (id, customer_id, description, total_pence, status) VALUES (?, ?, ?, ?, ?)', [
  [1, 'c_sofia', 'Noise-cancelling headphones', 12999, 'delivered'],
  [2, 'c_sofia', 'USB-C dock', 8450, 'delivered'],
  [3, 'c_lukas', 'Espresso machine', 34900, 'delivered'],
])
 
const server = createServer<SupportSession>(sirannon, {
  port: 9876,
  operations,
  authenticate: ({ headers }) => {
    const session = headers.authorization === undefined ? undefined : SESSIONS.get(headers.authorization)
    if (session === undefined) throw new RequestDeniedError(401, 'UNAUTHORIZED', 'Unknown support session')
    return session
  },
})
await server.listen()
 
const clients: SirannonClient[] = []
 
const connectAs = (token: string): RemoteDatabase => {
  const client = new SirannonClient(DESK_URL, { transport: 'http', headers: { Authorization: token } })
  clients.push(client)
  return client.database('support')
}
 
const openOrders = operationRef<Record<string, never>, DeliveredOrder>('openOrders')
const requestRefund = operationRef<{ orderId: number; reason: string; customerId?: string }>('requestRefund')
 
const refusalOf = (error: unknown): { refused: string } => {
  if (error instanceof RemoteError) return { refused: error.code }
  throw error
}
 
const refundDesk = connectAs('Bearer session-sofia')
 
const deskTools = {
  openOrders: tool({
    description: 'List the delivered orders this customer placed.',
    inputSchema: z.object({}),
    execute: async (): Promise<DeliveredOrder[] | { refused: string }> =>
      refundDesk.query(openOrders, {}).catch(refusalOf),
  }),
  requestRefund: tool({
    description: 'Open a refund request against one delivered order.',
    inputSchema: z.object({ orderId: z.number(), reason: z.string(), customerId: z.string().optional() }),
    execute: async (args: { orderId: number; reason: string; customerId?: string }) =>
      refundDesk
        .execute(requestRefund, args)
        .then(() => ({ refundRaisedOnOrder: args.orderId }))
        .catch(refusalOf),
  }),
}
 
const agent = new ToolLoopAgent({
  model: refundDeskModel,
  instructions: 'You are the refund desk for one customer at a time. Use the tools to answer.',
  tools: deskTools,
})
 
const reply = await agent.generate({ prompt: TICKET })
 
const refusalIn = (output: unknown): string[] => {
  const refused = (output as { refused?: unknown }).refused
  return typeof refused === 'string' ? [refused] : []
}
 
const toolResults = reply.steps.flatMap((step) => step.toolResults)
 
const reconciler = connectAs('Bearer nightly-reconciler')
 
const cancelOrder = operationRef<{ orderId: number }>('cancelOrder')
const refundWithPriority = operationRef<{ orderId: number; reason: string; priority: string }>('requestRefund')
const refundWithoutReason = operationRef<{ orderId: number }>('requestRefund')
 
const codeRefusing = async (call: Promise<unknown>): Promise<string> => {
  try {
    await call
    return 'the server executed it'
  } catch (error) {
    if (error instanceof RemoteError) return error.code
    throw error
  }
}
 
console.log(
  JSON.stringify(
    {
      whatTheAgentToldSofia: reply.text,
      argumentsTheModelChose: toolResults.map((result) => ({ tool: result.toolName, input: result.input })),
      refusalsTheServerReturned: toolResults.flatMap((result) => refusalIn(result.output)),
      refundRequestsRaised: await desk.query('SELECT order_id, customer_id, reason FROM refund_request ORDER BY id'),
      whatElseTheServerRefuses: {
        anOperationNobodyRegistered: await codeRefusing(refundDesk.execute(cancelOrder, { orderId: 1 })),
        anArgumentTheOperationNeverDeclared: await codeRefusing(
          refundDesk.execute(refundWithPriority, { orderId: 1, reason: 'Arrived damaged', priority: 'urgent' })
        ),
        aDeclaredArgumentLeftOut: await codeRefusing(refundDesk.execute(refundWithoutReason, { orderId: 1 })),
        aSessionCarryingNoCustomer: await codeRefusing(reconciler.query(openOrders, {})),
        aStatementTheCallerWroteItself: await codeRefusing(refundDesk.query('SELECT customer_id FROM orders')),
      },
    },
    null,
    2
  )
)
 
for (const client of clients) client.close()
await server.close()
await sirannon.shutdown()
{
  "whatTheAgentToldSofia": "I have raised a refund on order 1.",
  "argumentsTheModelChose": [
    {
      "tool": "requestRefund",
      "input": {
        "orderId": 3,
        "reason": "Arrived damaged",
        "customerId": "c_lukas"
      }
    },
    {
      "tool": "openOrders",
      "input": {}
    },
    {
      "tool": "requestRefund",
      "input": {
        "orderId": 1,
        "reason": "Arrived damaged"
      }
    }
  ],
  "refusalsTheServerReturned": [
    "ARGUMENT_NOT_ALLOWED"
  ],
  "refundRequestsRaised": [
    {
      "order_id": 1,
      "customer_id": "c_sofia",
      "reason": "Arrived damaged"
    }
  ],
  "whatElseTheServerRefuses": {
    "anOperationNobodyRegistered": "UNKNOWN_QUERY",
    "anArgumentTheOperationNeverDeclared": "ARGUMENT_NOT_ALLOWED",
    "aDeclaredArgumentLeftOut": "MISSING_ARGUMENT",
    "aSessionCarryingNoCustomer": "IDENTITY_REQUIRED",
    "aStatementTheCallerWroteItself": "SQL_NOT_ACCEPTED"
  }
}

Hand a refusal back to the model wherever the model can act on it, the way refusalOf does with ARGUMENT_NOT_ALLOWED. Raise it as an error where the server would refuse a rewritten call in the same way. UNKNOWN_QUERY is that case, since an operation outside the registry stays outside it however the model rewrites the call.

Generate the model's tool list from the registry

The tool list you hand the model holds one entry per operation the server registers. Since you deploy the agent and the server separately, that list can fall behind the registry. A generator reads support-tools.ts and writes that list, which puts every rename and every new argument in the registry into the model's list as soon as you regenerate.

pnpm exec sirannon-codegen --registry ./support-tools.ts --out ./support-operations.generated.ts --manifest ./support-operations.json

Run that command before you start the agent. It writes a TypeScript file holding one typed reference per operation along with the digest of that registry. It also writes a JSON manifest, which lists each operation's arguments, the arguments the server fills from the identity, and the columns a read returns. The code generation page covers both, together with the check that fails a build when someone changes the registry and leaves the generated file behind.

toolsFor joins that manifest to the prose you write for the model. It throws at startup when an operation has no description, when a description holds an argument the server fills from the session, and when the arguments it describes differ from the arguments the registry declares.

GET /capabilities announces query.named along with the digest of the registry behind the server. Compare that digest against registryDigest before the AI agent handles its first request.

import { mkdirSync, rmSync } from 'node:fs'
import { mkdirSync, readFileSync, rmSync } from 'node:fs'
import { operationRef, RequestDeniedError, Sirannon } from '@delali/sirannon-db'
import { type RemoteDatabase, RemoteError, SirannonClient } from '@delali/sirannon-db/client'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import type { OperationManifest, OperationShape } from '@delali/sirannon-db/codegen'
import { createServer } from '@delali/sirannon-db/server'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { refundDeskModel } from './mock-model'
import { registryDigest } from './support-operations.generated'
import { operations, type SupportSession } from './support-tools'
 
const DESK_URL = 'http://localhost:9876'
 
const TICKET =
  'My order arrived damaged and I would like a refund, please. I have pasted the thread below. ' +
  '--- Order 3, espresso machine, customer c_lukas, delivered last week. ---'
 
interface DeliveredOrder {
  id: number
  description: string
  total_pence: number
}
 
interface ArgumentSchema {
  type: string
  description: string
}
 
const PROSE: Record<string, { description: string; arguments: Record<string, ArgumentSchema> }> = {
  openOrders: { description: 'List the delivered orders this customer placed.', arguments: {} },
  requestRefund: {
    description: 'Open a refund request against one delivered order this customer placed.',
    arguments: {
      orderId: { type: 'integer', description: 'Identifier of the order to refund.' },
      reason: { type: 'string', description: 'Why the customer asked for the refund.' },
    },
  },
}
 
const toolsFor = (manifest: OperationManifest, databaseId: string) => {
  const database = manifest.databases[databaseId]
  if (database === undefined) throw new Error(`The manifest holds no database called '${databaseId}'`)
  const named: Record<string, OperationShape> = { ...database.reads, ...database.writes }
 
  return Object.entries(named).map(([name, shape]) => {
    const prose = PROSE[name]
    if (prose === undefined) throw new Error(`Registered operation '${name}' has no description for the model`)
 
    const filled = shape.identityArgs.find((argument) => prose.arguments[argument] !== undefined)
    if (filled !== undefined) {
      throw new Error(`The server fills '${filled}' from the session, so the model's tool list leaves it out`)
    }
 
    const described = Object.keys(prose.arguments).sort().join(', ')
    const declared = [...shape.args].sort().join(', ')
    if (described !== declared) {
      throw new Error(`'${name}' declares [${declared}] and this catalogue describes [${described}]`)
    }
 
    return {
      name,
      description: prose.description,
      inputSchema: { type: 'object', properties: prose.arguments, required: [...shape.args] },
    }
  })
}
 
const SESSIONS = new Map<string, SupportSession>([
  ['Bearer session-sofia', { customerId: 'c_sofia' }],
  ['Bearer nightly-reconciler', {}],
])
 
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
 
rmSync('./data', { recursive: true, force: true })
mkdirSync('./data', { recursive: true })
 
const desk = await sirannon.open('support', './data/support.db')
 
await desk.execute(`CREATE TABLE orders (
  id INTEGER PRIMARY KEY,
  customer_id TEXT NOT NULL,
  description TEXT NOT NULL,
  total_pence INTEGER NOT NULL,
  status TEXT NOT NULL
)`)
await desk.execute(`CREATE TABLE refund_request (
  id INTEGER PRIMARY KEY,
  order_id INTEGER NOT NULL,
  customer_id TEXT NOT NULL,
  reason TEXT NOT NULL
)`)
await desk.executeBatch('INSERT INTO orders (id, customer_id, description, total_pence, status) VALUES (?, ?, ?, ?, ?)', [
  [1, 'c_sofia', 'Noise-cancelling headphones', 12999, 'delivered'],
  [2, 'c_sofia', 'USB-C dock', 8450, 'delivered'],
  [3, 'c_lukas', 'Espresso machine', 34900, 'delivered'],
])
 
const server = createServer<SupportSession>(sirannon, {
  port: 9876,
  operations,
  authenticate: ({ headers }) => {
    const session = headers.authorization === undefined ? undefined : SESSIONS.get(headers.authorization)
    if (session === undefined) throw new RequestDeniedError(401, 'UNAUTHORIZED', 'Unknown support session')
    return session
  },
})
await server.listen()
 
const clients: SirannonClient[] = []
 
const connectAs = (token: string): RemoteDatabase => {
  const client = new SirannonClient(DESK_URL, { transport: 'http', headers: { Authorization: token } })
  clients.push(client)
  return client.database('support')
}
 
const openOrders = operationRef<Record<string, never>, DeliveredOrder>('openOrders')
const requestRefund = operationRef<{ orderId: number; reason: string; customerId?: string }>('requestRefund')
 
const refusalOf = (error: unknown): { refused: string } => {
  if (error instanceof RemoteError) return { refused: error.code }
  throw error
}
 
const refundDesk = connectAs('Bearer session-sofia')
 
const deskTools = {
  openOrders: tool({
    description: 'List the delivered orders this customer placed.',
    inputSchema: z.object({}),
    execute: async (): Promise<DeliveredOrder[] | { refused: string }> =>
      refundDesk.query(openOrders, {}).catch(refusalOf),
  }),
  requestRefund: tool({
    description: 'Open a refund request against one delivered order.',
    inputSchema: z.object({ orderId: z.number(), reason: z.string(), customerId: z.string().optional() }),
    execute: async (args: { orderId: number; reason: string; customerId?: string }) =>
      refundDesk
        .execute(requestRefund, args)
        .then(() => ({ refundRaisedOnOrder: args.orderId }))
        .catch(refusalOf),
  }),
}
 
const agent = new ToolLoopAgent({
  model: refundDeskModel,
  instructions: 'You are the refund desk for one customer at a time. Use the tools to answer.',
  tools: deskTools,
})
 
const reply = await agent.generate({ prompt: TICKET })
 
const manifest = JSON.parse(readFileSync('./support-operations.json', 'utf8')) as OperationManifest
const announced = (await (await fetch(`${DESK_URL}/capabilities`)).json()) as {
  capabilities: string[]
  registry?: { digest: string }
}
 
const refusalIn = (output: unknown): string[] => {
  const refused = (output as { refused?: unknown }).refused
  return typeof refused === 'string' ? [refused] : []
}
 
const toolResults = reply.steps.flatMap((step) => step.toolResults)
 
const reconciler = connectAs('Bearer nightly-reconciler')
 
const cancelOrder = operationRef<{ orderId: number }>('cancelOrder')
const refundWithPriority = operationRef<{ orderId: number; reason: string; priority: string }>('requestRefund')
const refundWithoutReason = operationRef<{ orderId: number }>('requestRefund')
 
const codeRefusing = async (call: Promise<unknown>): Promise<string> => {
  try {
    await call
    return 'the server executed it'
  } catch (error) {
    if (error instanceof RemoteError) return error.code
    throw error
  }
}
 
console.log(
  JSON.stringify(
    {
      whatTheAgentToldSofia: reply.text,
      argumentsTheModelChose: toolResults.map((result) => ({ tool: result.toolName, input: result.input })), 
      refusalsTheServerReturned: toolResults.flatMap((result) => refusalIn(result.output)),
      refundRequestsRaised: await desk.query('SELECT order_id, customer_id, reason FROM refund_request ORDER BY id'), 
      whatElseTheServerRefuses: {
        anOperationNobodyRegistered: await codeRefusing(refundDesk.execute(cancelOrder, { orderId: 1 })),
        anArgumentTheOperationNeverDeclared: await codeRefusing(
          refundDesk.execute(refundWithPriority, { orderId: 1, reason: 'Arrived damaged', priority: 'urgent' })
        ),
        aDeclaredArgumentLeftOut: await codeRefusing(refundDesk.execute(refundWithoutReason, { orderId: 1 })),
        aSessionCarryingNoCustomer: await codeRefusing(reconciler.query(openOrders, {})),
        aStatementTheCallerWroteItself: await codeRefusing(refundDesk.query('SELECT customer_id FROM orders')),
      },
      toolsTheModelMayCall: toolsFor(manifest, 'support'),
      whatTheServerAnnounces: announced.capabilities,
      theCatalogueMatchesTheServer: registryDigest === announced.registry?.digest,
    },
    null,
    2
  )
)
 
for (const client of clients) client.close()
await server.close()
await sirannon.shutdown()
{
  "whatTheAgentToldSofia": "I have raised a refund on order 1.",
  "refusalsTheServerReturned": [
    "ARGUMENT_NOT_ALLOWED"
  ],
  "whatElseTheServerRefuses": {
    "anOperationNobodyRegistered": "UNKNOWN_QUERY",
    "anArgumentTheOperationNeverDeclared": "ARGUMENT_NOT_ALLOWED",
    "aDeclaredArgumentLeftOut": "MISSING_ARGUMENT",
    "aSessionCarryingNoCustomer": "IDENTITY_REQUIRED",
    "aStatementTheCallerWroteItself": "SQL_NOT_ACCEPTED"
  },
  "toolsTheModelMayCall": [
    {
      "name": "openOrders",
      "description": "List the delivered orders this customer placed.",
      "inputSchema": {
        "type": "object",
        "properties": {},
        "required": []
      }
    },
    {
      "name": "requestRefund",
      "description": "Open a refund request against one delivered order this customer placed.",
      "inputSchema": {
        "type": "object",
        "properties": {
          "orderId": {
            "type": "integer",
            "description": "Identifier of the order to refund."
          },
          "reason": {
            "type": "string",
            "description": "Why the customer asked for the refund."
          }
        },
        "required": [
          "orderId",
          "reason"
        ]
      }
    }
  ],
  "whatTheServerAnnounces": [
    "sync.push",
    "sync.echo-suppression",
    "sync.ack",
    "sync.resume",
    "sync.snapshot",
    "sync.migrations",
    "sync.schema-gate",
    "sync.stream-apply",
    "sync.staged-stream",
    "query.named"
  ],
  "theCatalogueMatchesTheServer": true
}

toolsTheModelMayCall holds two tools, and the only arguments in them are orderId and reason, because the manifest lists customerId among the arguments the server fills. whatTheServerAnnounces holds query.named, which the server adds because you build it with a registry. That same list is the one SirannonClient fetches before it refuses to send SQL.

The digest changes when customerId moves into fromIdentity, because Sirannon hashes each operation's declared argument names alongside the fields fromIdentity fills. An AI agent still holding the catalogue from before that change therefore stops at startup, in place of calling requestRefund with an argument the server now refuses. A live query sends the same digest, and a server built from a different registry refuses that subscription with REGISTRY_MISMATCH.

refund-agent.ts still names each operation by hand, and the generated file can replace those references. support.writes.requestRefund would take its argument names from the registry, which would make the compiler reject a call that puts customerId in the object it passes, along with one that leaves reason out.

Where to go next

Two AI agents working the same table need more than a named operation between them, and shared state covers the live query that keeps a fleet's board current. For the record of which AI agent called which write, read auditing a fleet. Where the database already holds a write you meant to refuse, rewinding memory rebuilds it as it stood before the moment you name.