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

# 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.

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

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](https://arxiv.org/abs/2605.05287)).

<DataTable
  columns={['id', 'customer_id', 'description', 'status']}
  rows={[
    [1, 'c_sofia', 'Noise-cancelling headphones', 'delivered'],
    [2, 'c_sofia', 'USB-C dock', 'delivered'],
    [3, 'c_lukas', 'Espresso machine', 'delivered'],
  ]}
  emphasisRow={2}
  caption="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](/docs/registered-operations) 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.

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

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

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

```json result open
{
  "whatTheAgentToldSofia": "I have raised a refund on order 3.",
  "refundRequestsRaised": [
    {
      "order_id": 3,
      "customer_id": "c_lukas",
      "reason": "Arrived damaged"
    }
  ]
}
```

```ts title="support-tools.ts"

export interface SupportSession {
  customerId?: string
}

export const support: DatabaseOperations<SupportSession> = {
  reads: {
    openOrders: {
      fromIdentity: { customerId: 'customerId' },
      columns: ['id', 'description', 'total_pence'],
      statement: (args) => ({
        sql: 'SELECT id, description, total_pence FROM orders WHERE customer_id = ? AND status = ? ORDER BY id',
        params: [args.customerId, 'delivered'],
      }),
    },
  },
  writes: {
    requestRefund: {
      args: ['orderId', 'reason', 'customerId'],
      statements: (args) => [
        {
          sql: 'INSERT INTO refund_request (order_id, customer_id, reason) VALUES (?, ?, ?)',
          params: [args.orderId, args.customerId, args.reason],
        },
      ],
    },
  },
}

export const operations = { support }
```

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

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

interface ToolOutput {
  role: string
  content: { output?: { value?: unknown } }[]
}

const outputsSoFar = (prompt: unknown): unknown[] => {
  const outputs: unknown[] = []
  for (const message of prompt as ToolOutput[]) {
    if (message.role !== 'tool') continue
    for (const part of message.content) {
      if (part.output !== undefined) outputs.push(part.output.value)
    }
  }
  return outputs
}

const toolCall = (name: string, input: unknown, id: string) => ({
  content: [{ type: 'tool-call' as const, toolCallId: id, toolName: name, input: JSON.stringify(input) }],
  finishReason: { unified: 'tool-calls' as const, raw: undefined },
  usage: NO_USAGE,
  warnings: [],
})

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

export const refundDeskModel = new MockLanguageModelV4({
  doGenerate: async ({ prompt }) => {
    const outputs = outputsSoFar(prompt)
    const last = outputs[outputs.length - 1] as Record<string, unknown> | unknown[] | undefined

    if (last === undefined) {
      return toolCall(
        'requestRefund',
        { orderId: 3, reason: 'Arrived damaged', customerId: 'c_lukas' },
        'call-refund-from-thread'
      )
    }

    if (!Array.isArray(last) && typeof last === 'object' && 'refused' in last) {
      return toolCall('openOrders', {}, 'call-open-orders')
    }

    if (Array.isArray(last)) {
      const first = last[0] as { id: number } | undefined
      if (first === undefined) return answer('Your account has no delivered order I can refund.')
      return toolCall('requestRefund', { orderId: first.id, reason: 'Arrived damaged' }, 'call-refund-own-order')
    }

    const raised = (last as { refundRaisedOnOrder?: number }).refundRaisedOnOrder
    return answer(`I have raised a refund on order ${raised}.`)
  },
})
```

</CodeGroup>

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.

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

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

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 })

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

```json result open
{
  "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"
    }
  ]
}
```

```ts title="support-tools.ts"

export interface SupportSession {
  customerId?: string
}

export const support: DatabaseOperations<SupportSession> = {
  reads: {
    openOrders: {
      fromIdentity: { customerId: 'customerId' },
      columns: ['id', 'description', 'total_pence'],
      statement: (args) => ({
        sql: 'SELECT id, description, total_pence FROM orders WHERE customer_id = ? AND status = ? ORDER BY id',
        params: [args.customerId, 'delivered'],
      }),
    },
  },
  writes: {
    requestRefund: {
      args: ['orderId', 'reason', 'customerId'], // [!code --]
// [!code ++:2]
      args: ['orderId', 'reason'],
      fromIdentity: { customerId: 'customerId' },
      statements: (args) => [
        {
          sql: 'INSERT INTO refund_request (order_id, customer_id, reason) VALUES (?, ?, ?)',
          params: [args.orderId, args.customerId, args.reason],
        },
      ],
    },
  },
}

export const operations = { support }
```

</CodeGroup>

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`.

<ToolArgumentSourceDiagram
  operations={[
    {
      operation: 'openOrders',
      kind: 'a registered read',
      args: [
        { name: 'customerId', filledByTheServer: true, detail: 'the customerId field of the authenticated session' },
      ],
    },
    {
      operation: 'requestRefund',
      kind: 'a registered write',
      args: [
        { name: 'orderId', filledByTheServer: false, detail: 'the order the model chooses to refund' },
        { name: 'reason', filledByTheServer: false, detail: 'why the customer asks for the refund' },
        { name: 'customerId', filledByTheServer: true, detail: 'the customerId field of the authenticated session' },
      ],
    },
  ]}
  caption="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.

| Code | Status | When it fires |
| --- | --- | --- |
| `UNKNOWN_QUERY` | 404 | The caller sends a name outside this database's registry. |
| `ARGUMENT_NOT_ALLOWED` | 400 | The caller supplies an argument outside the operation's declared list, or one `fromIdentity` fills. |
| `MISSING_ARGUMENT` | 400 | The caller leaves out an argument the operation declares. |
| `IDENTITY_REQUIRED` | 401 | The caller's session holds no value in the field `fromIdentity` maps onto. |
| `SQL_NOT_ACCEPTED` | 403 | The 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](/docs/security) 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.

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

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

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)

// [!code ++:16]
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'),
// [!code ++:9]
      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()
```

```json result open
{
  "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"
  }
}
```

</CodeGroup>

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.

```bash
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](/docs/code-generation) 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.

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

```ts title="refund-agent.ts"
import { mkdirSync, rmSync } from 'node:fs' // [!code --]
import { mkdirSync, readFileSync, rmSync } from 'node:fs' // [!code ++]

import type { OperationManifest, OperationShape } from '@delali/sirannon-db/codegen' // [!code ++]

import { registryDigest } from './support-operations.generated' // [!code ++]

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
// [!code ++:44]
}

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 })

// [!code ++:6]
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 })), // [!code --]
      refusalsTheServerReturned: toolResults.flatMap((result) => refusalIn(result.output)),
      refundRequestsRaised: await desk.query('SELECT order_id, customer_id, reason FROM refund_request ORDER BY id'), // [!code --]
      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')),
      },
// [!code ++:3]
      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()
```

```json result open
{
  "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
}
```

```ts title="support-operations.generated.ts"

export const registryDigest = "ef76673d3b71f342246afceed6669944d71ac5ba8048253fe41653f0a0a443a0"

export interface SupportOpenOrdersRow {
  id: unknown
  description: unknown
  total_pence: unknown
}

export const support = {
  reads: {
    openOrders: { name: "openOrders" } as OperationRef<Record<string, never>, SupportOpenOrdersRow>,
  },
  writes: {
    requestRefund: { name: "requestRefund" } as OperationRef<{ orderId: unknown; reason: unknown }, never>,
  },
}
```

</CodeGroup>

`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](/docs/live-queries) 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](/docs/agent-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](/docs/agent-audit). Where the database already holds a write you meant to refuse, [rewinding memory](/docs/agent-memory-rewind) rebuilds it as it stood before the moment you name.
