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

# Keep the index current

> Write every change in your records to the index as an update or a removal, and wait for the worker copies, since the index keeps the last version that you wrote.

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

Take a customer who asks a food manufacturer's labelling AI agent whether the lemon drizzle loaf contains nuts, and who receives an answer from revision 3 of the declaration, which lists wheat, egg, and milk. Since a recipe change a week ago, the loaf contains almond flour, and revision 4 in the manufacturer's records contains the warning 'Not suitable for a nut allergy'. No part of the system reports an error. The record changes while the index still holds the version from the last write, so the AI agent answers from a declaration that is out of date.

The authors of a 2026 diagnostic study measure how much stale context affects an answer. When the models retrieve from an obsolete snapshot alone, their answers contain obsolete references on 15 of 17 samples with one model and 13 of 17 with the other, which the authors report as increases of 88.2 and 76.5 percentage points over retrieval from the current state ([arXiv 2605.14478](https://arxiv.org/abs/2605.14478)). Their corpus is source code, and the same mechanism applies here, because a search index can return only what it holds.

The model in the files below is a scripted mock that builds each answer from the words of Narsil's hits. Replace it with a real model when you want the phrasing to vary. Each code block below holds one whole file.

## An index behind its own record

`onFile` holds the manufacturer's record of every declaration, and `insertBatch` copies it into the index. The code then writes the recipe change, revision 4, to the record alone. That is the usual form of this bug, in which your code writes a change to one of two stores.

<CodeGroup defaultTab="labelling.ts">

```ts title="labelling.ts"
import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type DeclarationHit, labellingModel, LOAF_QUESTION, LOAF_SEARCH } from './mock-model.ts'

type Declaration = {
  id: string
  product: string
  declaration: string
  revision: number
}

const onFile = new Map<string, Declaration>([
  [
    'lemon-drizzle-loaf',
    {
      id: 'lemon-drizzle-loaf',
      product: 'Lemon drizzle loaf',
      declaration: 'Contains wheat, egg, and milk. Made on a line that also handles sesame.',
      revision: 3,
    },
  ],
  [
    'cherry-bakewell-slice',
    {
      id: 'cherry-bakewell-slice',
      product: 'Cherry bakewell slice',
      declaration: 'Contains wheat, egg, milk, and almond. Not suitable for a nut allergy.',
      revision: 7,
    },
  ],
  [
    'oat-and-raisin-cookie',
    {
      id: 'oat-and-raisin-cookie',
      product: 'Oat and raisin cookie',
      declaration: 'Contains oats and wheat. Made on a line that also handles peanut.',
      revision: 2,
    },
  ],
])

const narsil = await createNarsil()

await narsil.createIndex('product_declarations', {
  schema: { product: 'string', declaration: 'string', revision: 'number' },
  language: 'english',
})

await narsil.insertBatch('product_declarations', [...onFile.values()])

onFile.set('lemon-drizzle-loaf', {
  id: 'lemon-drizzle-loaf',
  product: 'Lemon drizzle loaf',
  declaration: 'Contains wheat, egg, milk, and almond. Not suitable for a nut allergy.',
  revision: 4,
})

const labellingTools = {
  searchDeclarations: tool({
    description: 'Search the allergen declarations by what their text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<DeclarationHit[]> => {
      const found = await narsil.query<DeclarationHit>('product_declarations', {
        term: text,
        document: { include: ['product', 'declaration', 'revision'] },
        limit: 1,
      })
      return found.hits.map((hit) => hit.document)
    },
  }),
}

const askLabellingAgent = async (question: string, searchText: string): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: labellingModel(searchText),
    instructions: 'Answer from the allergen declarations alone.',
    tools: labellingTools,
  })
  const reply = await agent.generate({ prompt: question })
  return reply.text
}

console.log(
  JSON.stringify(
    {
      question: LOAF_QUESTION,
      agentAnswered: await askLabellingAgent(LOAF_QUESTION, LOAF_SEARCH),
      onFileNow: onFile.get('lemon-drizzle-loaf')?.declaration,
    },
    null,
    2
  )
)

await narsil.shutdown()
```

```json result open
{
  "question": "Does the lemon drizzle loaf contain nuts?",
  "agentAnswered": "Contains wheat, egg, and milk. Made on a line that also handles sesame. (Lemon drizzle loaf, revision 3)",
  "onFileNow": "Contains wheat, egg, milk, and almond. Not suitable for a nut allergy."
}
```

```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 LOAF_QUESTION = 'Does the lemon drizzle loaf contain nuts?'

export const LOAF_SEARCH = 'lemon drizzle loaf'

export interface DeclarationHit {
  product: string
  declaration: string
  revision: number
}

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 labellingModel = (searchText: string) =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const searches = everythingReturnedBy(prompt, 'searchDeclarations') as DeclarationHit[][]
      if (searches.length === 0) return callTool('searchDeclarations', { text: searchText })
      const found = searches[0]
      if (found.length === 0) return say('No declaration is on file for that product.')
      const top = found[0]
      return say(`${top.declaration} (${top.product}, revision ${top.revision})`)
    },
  })
```

</CodeGroup>

The panel holds both sides of the gap, which are the AI agent's answer and the record at the moment of that answer. People act on a labelling answer, and a reader can spot the problem only from the revision number in it.

<DeclarationDriftDiagram
  recordLabel="onFile"
  indexLabel="product_declarations"
  rows={[
    { product: 'Lemon drizzle loaf', recordRevision: 4, indexRevision: 3 },
    { product: 'Cherry bakewell slice', recordRevision: 7, indexRevision: 7 },
    { product: 'Oat and raisin cookie', recordRevision: 2, indexRevision: 2 },
  ]}
  caption="Each store holds its own revision of every declaration, and the AI agent searches the index on the right, where the lemon drizzle loaf is still at revision 3."
/>

## Write the change to both stores at once

`declare` is now the only function that changes a declaration, and it writes to the record and to the index together. `update` replaces the document with that id and reindexes all of its fields, so from the next query onwards the engine ranks the new declaration by its new words.

<CodeGroup defaultTab="labelling.ts">

```ts title="labelling.ts"
import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type DeclarationHit, labellingModel, LOAF_QUESTION, LOAF_SEARCH } from './mock-model.ts'

type Declaration = {
  id: string
  product: string
  declaration: string
  revision: number
}

const onFile = new Map<string, Declaration>([
  [
    'lemon-drizzle-loaf',
    {
      id: 'lemon-drizzle-loaf',
      product: 'Lemon drizzle loaf',
      declaration: 'Contains wheat, egg, and milk. Made on a line that also handles sesame.',
      revision: 3,
    },
  ],
  [
    'cherry-bakewell-slice',
    {
      id: 'cherry-bakewell-slice',
      product: 'Cherry bakewell slice',
      declaration: 'Contains wheat, egg, milk, and almond. Not suitable for a nut allergy.',
      revision: 7,
    },
  ],
  [
    'oat-and-raisin-cookie',
    {
      id: 'oat-and-raisin-cookie',
      product: 'Oat and raisin cookie',
      declaration: 'Contains oats and wheat. Made on a line that also handles peanut.',
      revision: 2,
    },
  ],
])

const narsil = await createNarsil()

await narsil.createIndex('product_declarations', {
  schema: { product: 'string', declaration: 'string', revision: 'number' },
  language: 'english',
})

await narsil.insertBatch('product_declarations', [...onFile.values()])

onFile.set('lemon-drizzle-loaf', { // [!code --]
const declare = async (declaration: Declaration): Promise<void> => { // [!code ++:6]
  onFile.set(declaration.id, declaration)
  await narsil.update('product_declarations', declaration.id, declaration)
}

await declare({
  id: 'lemon-drizzle-loaf',
  product: 'Lemon drizzle loaf',
  declaration: 'Contains wheat, egg, milk, and almond. Not suitable for a nut allergy.',
  revision: 4,
})

const labellingTools = {
  searchDeclarations: tool({
    description: 'Search the allergen declarations by what their text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<DeclarationHit[]> => {
      const found = await narsil.query<DeclarationHit>('product_declarations', {
        term: text,
        document: { include: ['product', 'declaration', 'revision'] },
        limit: 1,
      })
      return found.hits.map((hit) => hit.document)
    },
  }),
}

const askLabellingAgent = async (question: string, searchText: string): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: labellingModel(searchText),
    instructions: 'Answer from the allergen declarations alone.',
    tools: labellingTools,
  })
  const reply = await agent.generate({ prompt: question })
  return reply.text
}

console.log(
  JSON.stringify(
    {
      question: LOAF_QUESTION,
      agentAnswered: await askLabellingAgent(LOAF_QUESTION, LOAF_SEARCH),
      onFileNow: onFile.get('lemon-drizzle-loaf')?.declaration,
    },
    null,
    2
  )
)

await narsil.shutdown()
```

```json result open
{
  "question": "Does the lemon drizzle loaf contain nuts?",
  "agentAnswered": "Contains wheat, egg, milk, and almond. Not suitable for a nut allergy. (Lemon drizzle loaf, revision 4)",
  "onFileNow": "Contains wheat, egg, milk, and almond. Not suitable for a nut allergy."
}
```

</CodeGroup>

The AI agent now answers with revision 4 and the allergy warning. `update` throws `DOC_NOT_FOUND` when the index has no document with that id, and you can rely on that error to catch any case where your records and the index disagree. To branch on the code with `isNarsilError`, see [Error handling](/docs/errors).

## Withdraw a product, and wait for the copies

When the manufacturer withdraws a product line, you remove its document. `remove` deletes the document from the index, after which a query returns nothing for that product, and that is the true answer once the manufacturer stops making it.

`waitForWrites(indexName)` resolves once every worker copy of the index has applied every write that returned before the call. For an index with no copies the promise resolves at once, which means the call adds no delay on an embedded engine, and the same code is correct when you add worker copies. Pass `{ wait: true }` to a single write when you want the same guarantee for that write alone, and call `waitForWrites` after a series of writes. [Partitions and workers](/docs/partitions-and-workers) has the details of the worker copies and of how the engine routes queries to them.

<CodeGroup defaultTab="labelling.ts">

```ts title="labelling.ts"
import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { type DeclarationHit, labellingModel, LOAF_QUESTION, LOAF_SEARCH } from './mock-model.ts' // [!code --]
import { // [!code ++:8]
  COOKIE_QUESTION,
  COOKIE_SEARCH,
  type DeclarationHit,
  labellingModel,
  LOAF_QUESTION,
  LOAF_SEARCH,
} from './mock-model.ts'

type Declaration = {
  id: string
  product: string
  declaration: string
  revision: number
}

const onFile = new Map<string, Declaration>([
  [
    'lemon-drizzle-loaf',
    {
      id: 'lemon-drizzle-loaf',
      product: 'Lemon drizzle loaf',
      declaration: 'Contains wheat, egg, and milk. Made on a line that also handles sesame.',
      revision: 3,
    },
  ],
  [
    'cherry-bakewell-slice',
    {
      id: 'cherry-bakewell-slice',
      product: 'Cherry bakewell slice',
      declaration: 'Contains wheat, egg, milk, and almond. Not suitable for a nut allergy.',
      revision: 7,
    },
  ],
  [
    'oat-and-raisin-cookie',
    {
      id: 'oat-and-raisin-cookie',
      product: 'Oat and raisin cookie',
      declaration: 'Contains oats and wheat. Made on a line that also handles peanut.',
      revision: 2,
    },
  ],
])

const narsil = await createNarsil()

await narsil.createIndex('product_declarations', {
  schema: { product: 'string', declaration: 'string', revision: 'number' },
  language: 'english',
})

await narsil.insertBatch('product_declarations', [...onFile.values()])

const declare = async (declaration: Declaration): Promise<void> => {
  onFile.set(declaration.id, declaration)
  await narsil.update('product_declarations', declaration.id, declaration)
}

const withdraw = async (id: string): Promise<void> => { // [!code ++:5]
  onFile.delete(id)
  await narsil.remove('product_declarations', id)
}

await declare({
  id: 'lemon-drizzle-loaf',
  product: 'Lemon drizzle loaf',
  declaration: 'Contains wheat, egg, milk, and almond. Not suitable for a nut allergy.',
  revision: 4,
})

await withdraw('oat-and-raisin-cookie') // [!code ++:4]

await narsil.waitForWrites('product_declarations')

const labellingTools = {
  searchDeclarations: tool({
    description: 'Search the allergen declarations by what their text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<DeclarationHit[]> => {
      const found = await narsil.query<DeclarationHit>('product_declarations', {
        term: text,
        document: { include: ['product', 'declaration', 'revision'] },
        limit: 1,
      })
      return found.hits.map((hit) => hit.document)
    },
  }),
}

const askLabellingAgent = async (question: string, searchText: string): Promise<string> => {
  const agent = new ToolLoopAgent({
    model: labellingModel(searchText),
    instructions: 'Answer from the allergen declarations alone.',
    tools: labellingTools,
  })
  const reply = await agent.generate({ prompt: question })
  return reply.text
}

console.log(
  JSON.stringify(
    { // [!code --]
      question: LOAF_QUESTION, // [!code --]
      agentAnswered: await askLabellingAgent(LOAF_QUESTION, LOAF_SEARCH), // [!code --]
      onFileNow: onFile.get('lemon-drizzle-loaf')?.declaration, // [!code --]
    }, // [!code --]
    [ // [!code ++:4]
      { question: LOAF_QUESTION, agentAnswered: await askLabellingAgent(LOAF_QUESTION, LOAF_SEARCH) },
      { question: COOKIE_QUESTION, agentAnswered: await askLabellingAgent(COOKIE_QUESTION, COOKIE_SEARCH) },
    ],
    null,
    2
  )
)

await narsil.shutdown()
```

```json result open
[
  {
    "question": "Does the lemon drizzle loaf contain nuts?",
    "agentAnswered": "Contains wheat, egg, milk, and almond. Not suitable for a nut allergy. (Lemon drizzle loaf, revision 4)"
  },
  {
    "question": "Does the oat and raisin cookie contain nuts?",
    "agentAnswered": "No declaration is on file for that product."
  }
]
```

```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 LOAF_QUESTION = 'Does the lemon drizzle loaf contain nuts?'

export const LOAF_SEARCH = 'lemon drizzle loaf'

export const COOKIE_QUESTION = 'Does the oat and raisin cookie contain nuts?' // [!code ++:4]

export const COOKIE_SEARCH = 'oat and raisin cookie'

export interface DeclarationHit {
  product: string
  declaration: string
  revision: number
}

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 labellingModel = (searchText: string) =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const searches = everythingReturnedBy(prompt, 'searchDeclarations') as DeclarationHit[][]
      if (searches.length === 0) return callTool('searchDeclarations', { text: searchText })
      const found = searches[0]
      if (found.length === 0) return say('No declaration is on file for that product.')
      const top = found[0]
      return say(`${top.declaration} (${top.product}, revision ${top.revision})`)
    },
  })
```

</CodeGroup>

A search for the withdrawn cookie returns nothing, and the AI agent tells the customer so, which is why you remove the document. If you marked the cookie as withdrawn inside its own text, the words that rank it would stay in the index, and the model would still receive the allergen line alongside the marking.

When a database holds the records in place of a map, connect the same pair of writes to that database's change feed, with one subscriber that applies each committed change to the index. If the process stops between the commit and the index write, the index still holds the old version. To repair it, read the records from the source and write them again with `insertBatch`.

## Where to go next

- Read [Indexes and documents](/docs/indexes-and-documents) for `insert`, `update`, `remove`, the batch calls, and the errors that each of them throws.
- See [Partitions and workers](/docs/partitions-and-workers) for the worker copies that `waitForWrites` waits on, and for how the engine routes queries to them.
- Keep the hit ids behind every answer in the journal from [Record every search](/docs/agent-search-audit), so that a reader can find the revision behind each answer.
