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

# Record every search

> Record every query and every preflight with one plugin on the engine, so that your journal includes the searches from every tool.

URL: https://narsil.sondelali.com/docs/agent-search-audit
Section: AI agents
Version: 0.3 (@delali/narsil@0.3.0, latest)

Assume that a grant-awarding trust's AI agent tells a community choir that it cannot apply to the building repair fund, and that eight months later the choir's trustees ask which passage of the guidance the AI agent used. The trust's log holds one line, which is the search that its `searchGuidance` tool records. The AI agent also counts the matching sections with a second tool, and that call writes nothing to the log, which means the log holds only part of the retrieval behind the answer.

The authors of a 2026 position paper on auditable agents state the standard in one line, which is that an agent system cannot be accountable unless it is auditable. They add that error paths, retries, fallbacks, approvals, and handoffs are usually missing from a record ([arXiv 2604.05485](https://arxiv.org/abs/2604.05485)). A search that your code never logs is a gap of the same kind, since the retrieval works and the recording is what is missing.

A mock model with a fixed script produces each answer below, so the journals are identical every time you execute the file. Put a real model in the same place when you want it to choose the calls. Each code block below holds one whole file.

## A journal that one tool writes

`searchGuidance` pushes an entry before it queries, while `countGuidance` pushes nothing, because nobody writes that line into it. `reply.steps` holds the calls that the model makes, and `console.log` prints the tools that the model calls alongside the journal.

<CodeGroup defaultTab="trust.ts">

```ts title="trust.ts"
import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type GuidanceHit, QUESTION, trustModel } from './mock-model.ts'

const narsil = await createNarsil()

await narsil.createIndex('grant_guidance', {
  schema: { programme: 'enum', section: 'string', body: 'string' },
  language: 'english',
})

await narsil.insertBatch('grant_guidance', [
  {
    id: 'repair-eligibility',
    programme: 'building-repair',
    section: 'Who may apply to the building repair fund',
    body: 'The building repair fund is open to a charity or a community group that holds the freehold or a lease of at least fifteen years on the building it repairs.',
  },
  {
    id: 'repair-excluded-work',
    programme: 'building-repair',
    section: 'Work the building repair fund excludes',
    body: 'The fund pays for structural repair, roofing, and damp work, and it pays for no redecoration, furniture, or musical equipment.',
  },
  {
    id: 'performance-eligibility',
    programme: 'performance',
    section: 'Who may apply to the performance fund',
    body: 'The performance fund is open to a choir, an orchestra, or a theatre company that has performed in public in the last two years.',
  },
  {
    id: 'reporting',
    programme: 'all',
    section: 'Reporting on a grant',
    body: 'Every grant holder sends a report within three months of the final payment, with receipts for each item above 500 pounds.',
  },
])

interface JournalEntry {
  tool: string
  term: string
}

const journal: JournalEntry[] = []

const trustTools = {
  countGuidance: tool({
    description: 'Count the guidance sections that match some words, without reading them.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<number> => {
      const counted = await narsil.preflight('grant_guidance', { term: text })
      return counted.count
    },
  }),
  searchGuidance: tool({
    description: 'Search the grant guidance by what its text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<GuidanceHit[]> => {
      journal.push({ tool: 'searchGuidance', term: text })
      const found = await narsil.query<GuidanceHit>('grant_guidance', {
        term: text,
        document: { include: ['section', 'body'] },
        limit: 1,
      })
      return found.hits.map((hit) => hit.document)
    },
  }),
}

const askTrustAgent = async (): Promise<{ answer: string; toolsCalled: string[] }> => {
  const agent = new ToolLoopAgent({
    model: trustModel(),
    instructions: 'Answer the applicant from the grant guidance.',
    tools: trustTools,
  })
  const reply = await agent.generate({ prompt: QUESTION })
  const toolsCalled: string[] = []
  for (const step of reply.steps) {
    for (const call of step.toolCalls) toolsCalled.push(call.toolName)
  }
  return { answer: reply.text, toolsCalled }
}

const asked = await askTrustAgent()

console.log(
  JSON.stringify(
    { question: QUESTION, agentAnswered: asked.answer, toolsCalled: asked.toolsCalled, journal },
    null,
    2
  )
)

await narsil.shutdown()
```

```json result open
{
  "question": "Can a community choir apply to the building repair fund?",
  "agentAnswered": "The building repair fund is open to a charity or a community group that holds the freehold or a lease of at least fifteen years on the building it repairs. That is the 'Who may apply to the building repair fund' section.",
  "toolsCalled": [
    "countGuidance",
    "searchGuidance"
  ],
  "journal": [
    {
      "tool": "searchGuidance",
      "term": "building repair eligibility"
    }
  ]
}
```

```ts title="mock-model.ts"
import { MockLanguageModelV4 } from 'ai/test'

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

export const QUESTION = 'Can a community choir apply to the building repair fund?'

export interface GuidanceHit {
  section: string
  body: string
}

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

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

const everythingReturnedBy = (prompt: unknown, toolName: string): unknown[] => {
  const outputs: 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) outputs.push(part.output?.value)
    }
  }
  return outputs
}

const callTool = (toolName: string, input: Record<string, unknown>) => ({
  content: [{ type: 'tool-call' as const, toolCallId: `${toolName}-1`, toolName, input: JSON.stringify(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 trustModel = () =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const counts = everythingReturnedBy(prompt, 'countGuidance') as number[]
      if (counts.length === 0) return callTool('countGuidance', { text: 'building repair choir' })
      if (counts[0] === 0) return say('No guidance section covers that fund.')
      const searches = everythingReturnedBy(prompt, 'searchGuidance') as GuidanceHit[][]
      if (searches.length === 0) return callTool('searchGuidance', { text: 'building repair eligibility' })
      const found = searches[0]
      if (found.length === 0) return say('No guidance section covers that fund.')
      return say(`${found[0].body} That is the '${found[0].section}' section.`)
    },
  })
```

</CodeGroup>

The AI agent calls two tools, while the journal holds one entry. You could add a line to `countGuidance`; however, the next tool that somebody writes would need the same line, because a journal kept this way depends on the author of every tool remembering to write to it.

## One plugin over every search

The `plugins` option of `createNarsil` takes hooks that the engine calls as it works. The engine calls `afterSearch` once a search returns, with a context that holds the index name, the query's parameters, and a copy of the results. One hook can therefore record the term and the hit ids of every query in the process.

The engine passes a copy so that a change inside `afterSearch` leaves the caller's own result as it is. If the hook throws, the engine logs a warning and still returns the results to the caller, since the search is already complete. If your journal has to be complete, its writes have to succeed without help from the engine.

<CodeGroup defaultTab="trust.ts">

```ts title="trust.ts"
import { createNarsil } from '@delali/narsil' // [!code --]
import { createNarsil, type NarsilPlugin } from '@delali/narsil' // [!code ++]
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type GuidanceHit, QUESTION, trustModel } from './mock-model.ts'

interface JournalEntry { // [!code ++:20]
  hook: string
  index: string
  term: string
  hits?: string[]
}

const journal: JournalEntry[] = []

const searchJournal: NarsilPlugin = {
  name: 'grant-search-journal',
  afterSearch: (ctx) => {
    journal.push({
      hook: 'afterSearch',
      index: ctx.indexName,
      term: ctx.params.term ?? '',
      hits: (ctx.results?.hits ?? []).map((hit) => hit.id),
    })
  },
}

const narsil = await createNarsil() // [!code --]
const narsil = await createNarsil({ plugins: [searchJournal] }) // [!code ++]

await narsil.createIndex('grant_guidance', {
  schema: { programme: 'enum', section: 'string', body: 'string' },
  language: 'english',
})

await narsil.insertBatch('grant_guidance', [
  {
    id: 'repair-eligibility',
    programme: 'building-repair',
    section: 'Who may apply to the building repair fund',
    body: 'The building repair fund is open to a charity or a community group that holds the freehold or a lease of at least fifteen years on the building it repairs.',
  },
  {
    id: 'repair-excluded-work',
    programme: 'building-repair',
    section: 'Work the building repair fund excludes',
    body: 'The fund pays for structural repair, roofing, and damp work, and it pays for no redecoration, furniture, or musical equipment.',
  },
  {
    id: 'performance-eligibility',
    programme: 'performance',
    section: 'Who may apply to the performance fund',
    body: 'The performance fund is open to a choir, an orchestra, or a theatre company that has performed in public in the last two years.',
  },
  {
    id: 'reporting',
    programme: 'all',
    section: 'Reporting on a grant',
    body: 'Every grant holder sends a report within three months of the final payment, with receipts for each item above 500 pounds.',
  },
])

interface JournalEntry { // [!code --]
  tool: string // [!code --]
  term: string // [!code --]
} // [!code --]

const journal: JournalEntry[] = [] // [!code --]

const trustTools = {
  countGuidance: tool({
    description: 'Count the guidance sections that match some words, without reading them.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<number> => {
      const counted = await narsil.preflight('grant_guidance', { term: text })
      return counted.count
    },
  }),
  searchGuidance: tool({
    description: 'Search the grant guidance by what its text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<GuidanceHit[]> => {
      journal.push({ tool: 'searchGuidance', term: text }) // [!code --]
      const found = await narsil.query<GuidanceHit>('grant_guidance', {
        term: text,
        document: { include: ['section', 'body'] },
        limit: 1,
      })
      return found.hits.map((hit) => hit.document)
    },
  }),
}

const askTrustAgent = async (): Promise<{ answer: string; toolsCalled: string[] }> => {
  const agent = new ToolLoopAgent({
    model: trustModel(),
    instructions: 'Answer the applicant from the grant guidance.',
    tools: trustTools,
  })
  const reply = await agent.generate({ prompt: QUESTION })
  const toolsCalled: string[] = []
  for (const step of reply.steps) {
    for (const call of step.toolCalls) toolsCalled.push(call.toolName)
  }
  return { answer: reply.text, toolsCalled }
}

const asked = await askTrustAgent()

console.log(
  JSON.stringify(
    { question: QUESTION, agentAnswered: asked.answer, toolsCalled: asked.toolsCalled, journal },
    null,
    2
  )
)

await narsil.shutdown()
```

```json result open
{
  "question": "Can a community choir apply to the building repair fund?",
  "agentAnswered": "The building repair fund is open to a charity or a community group that holds the freehold or a lease of at least fifteen years on the building it repairs. That is the 'Who may apply to the building repair fund' section.",
  "toolsCalled": [
    "countGuidance",
    "searchGuidance"
  ],
  "journal": [
    {
      "hook": "afterSearch",
      "index": "grant_guidance",
      "term": "building repair eligibility",
      "hits": [
        "repair-eligibility"
      ]
    }
  ]
}
```

</CodeGroup>

The journal now holds the hit behind the answer, and the tools contain no logging code. The count from `countGuidance` is still missing, because `preflight` returns a count with no hits, and the engine calls no `afterSearch` for it.

## Record the request as well as the result

The engine calls `beforeSearch` before every search and before every preflight, so a `beforeSearch` hook records the count as well. The engine also calls it before it embeds a `vector.text` query, which means a hook that throws there prevents the call to the embedding provider, and `ctx.params.vector` still holds the text that the caller passes.

If `beforeSearch` throws, the engine rejects the query, and you can use that to refuse any search until your code writes it to the journal. The engine calls neither hook for `suggest`, because a suggestion comes from the term dictionary and involves no document search.

<CodeGroup defaultTab="trust.ts">

```ts title="trust.ts"
import { createNarsil, type NarsilPlugin } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type GuidanceHit, QUESTION, trustModel } from './mock-model.ts'

interface JournalEntry {
  hook: string
  index: string
  term: string
  hits?: string[]
}

const journal: JournalEntry[] = []

const searchJournal: NarsilPlugin = {
  name: 'grant-search-journal',
  beforeSearch: (ctx) => { // [!code ++:3]
    journal.push({ hook: 'beforeSearch', index: ctx.indexName, term: ctx.params.term ?? '' })
  },
  afterSearch: (ctx) => {
    journal.push({
      hook: 'afterSearch',
      index: ctx.indexName,
      term: ctx.params.term ?? '',
      hits: (ctx.results?.hits ?? []).map((hit) => hit.id),
    })
  },
}

const narsil = await createNarsil({ plugins: [searchJournal] })

await narsil.createIndex('grant_guidance', {
  schema: { programme: 'enum', section: 'string', body: 'string' },
  language: 'english',
})

await narsil.insertBatch('grant_guidance', [
  {
    id: 'repair-eligibility',
    programme: 'building-repair',
    section: 'Who may apply to the building repair fund',
    body: 'The building repair fund is open to a charity or a community group that holds the freehold or a lease of at least fifteen years on the building it repairs.',
  },
  {
    id: 'repair-excluded-work',
    programme: 'building-repair',
    section: 'Work the building repair fund excludes',
    body: 'The fund pays for structural repair, roofing, and damp work, and it pays for no redecoration, furniture, or musical equipment.',
  },
  {
    id: 'performance-eligibility',
    programme: 'performance',
    section: 'Who may apply to the performance fund',
    body: 'The performance fund is open to a choir, an orchestra, or a theatre company that has performed in public in the last two years.',
  },
  {
    id: 'reporting',
    programme: 'all',
    section: 'Reporting on a grant',
    body: 'Every grant holder sends a report within three months of the final payment, with receipts for each item above 500 pounds.',
  },
])

const trustTools = {
  countGuidance: tool({
    description: 'Count the guidance sections that match some words, without reading them.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<number> => {
      const counted = await narsil.preflight('grant_guidance', { term: text })
      return counted.count
    },
  }),
  searchGuidance: tool({
    description: 'Search the grant guidance by what its text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<GuidanceHit[]> => {
      const found = await narsil.query<GuidanceHit>('grant_guidance', {
        term: text,
        document: { include: ['section', 'body'] },
        limit: 1,
      })
      return found.hits.map((hit) => hit.document)
    },
  }),
}

const askTrustAgent = async (): Promise<{ answer: string; toolsCalled: string[] }> => {
  const agent = new ToolLoopAgent({
    model: trustModel(),
    instructions: 'Answer the applicant from the grant guidance.',
    tools: trustTools,
  })
  const reply = await agent.generate({ prompt: QUESTION })
  const toolsCalled: string[] = []
  for (const step of reply.steps) {
    for (const call of step.toolCalls) toolsCalled.push(call.toolName)
  }
  return { answer: reply.text, toolsCalled }
}

const asked = await askTrustAgent()

console.log(
  JSON.stringify(
    { question: QUESTION, agentAnswered: asked.answer, toolsCalled: asked.toolsCalled, journal },
    null,
    2
  )
)

await narsil.shutdown()
```

```json result open
{
  "question": "Can a community choir apply to the building repair fund?",
  "agentAnswered": "The building repair fund is open to a charity or a community group that holds the freehold or a lease of at least fifteen years on the building it repairs. That is the 'Who may apply to the building repair fund' section.",
  "toolsCalled": [
    "countGuidance",
    "searchGuidance"
  ],
  "journal": [
    {
      "hook": "beforeSearch",
      "index": "grant_guidance",
      "term": "building repair choir"
    },
    {
      "hook": "beforeSearch",
      "index": "grant_guidance",
      "term": "building repair eligibility"
    },
    {
      "hook": "afterSearch",
      "index": "grant_guidance",
      "term": "building repair eligibility",
      "hits": [
        "repair-eligibility"
      ]
    }
  ]
}
```

</CodeGroup>

The three entries hold the whole retrieval account of one answer, which is the count that the AI agent asks for first, the search after it, and the section that the search returns. The trust can now answer the trustees' question from a record that the engine writes, whichever tools the model chooses.

<HookCoverageDiagram
  rows={[
    { call: 'query', work: 'search', firesBeforeSearch: true, firesAfterSearch: true },
    { call: 'preflight', work: 'count', firesBeforeSearch: true, firesAfterSearch: false },
    { call: 'suggest', work: 'dictionary', firesBeforeSearch: false, firesAfterSearch: false },
  ]}
  caption="The engine calls each filled hook and skips each dashed one. A journal built on afterSearch alone holds only the query, and a record of suggest has to come from your own code."
/>

A journal in memory lasts only as long as the process. Send each entry to the store that already holds your other records, and keep the index name and the hit ids in it. When you store those ids with the revision of each document from [Keep the index current](/docs/agent-freshness), a reader can find the exact version of the passage behind an answer months later.

## Where to go next

- Read [Observability](/docs/observability) for the engine's own events and its memory figures.
- See [Keep each agent to its own index](/docs/agent-isolation) for why the index name in each journal entry is enough to identify the client.
- To show the reader a citation as well as keeping this record, follow [Cite the passage behind an answer](/docs/agent-grounded-answers).
