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

# Cite the passage behind an answer

> Give every answer from an AI agent the rule's reference and its exact sentence, and check the quote against the stored text before a passenger reads it.

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

Suppose that an airline's AI agent tells a passenger that the airline carries a folding pushchair free of charge, and a gate supervisor then asks for the rule behind that answer. The answer contains no reference. Its paraphrase also has no condition in it, although the free allowance applies only to a child under two. You can close that gap with a citation in three parts, which are the rule's reference, the sentence that states the condition, and the character offsets of that sentence in the stored text.

A model can cite a real page and still quote words that are not on it. The authors of a 2026 evaluation of fourteen models report that the strongest of them score above 94% on link validity and above 80% on relevance, while they score between 39% and 77% on factual accuracy against the sources that they cite ([arXiv 2605.06635](https://arxiv.org/abs/2605.06635)).

A scripted mock returns every model response on this page, which is why the code prints the same words each time you execute it. To let a real model write the answers, pass a model string in place of the mock. Each code block below holds one whole file.

## An answer with no source

`createNarsil()` starts the engine inside this process, after which `createIndex` declares a title field and a body field for the rules. `insertBatch` then writes the four rules in one call, with each rule's `id` set to its reference in the airline's rulebook.

`searchRules` is the one tool that the model can call. It takes the text of a question and returns the body of each rule that matches, and because a body contains only text, the model has no reference to put in its answer.

<CodeGroup defaultTab="baggage.ts">

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

const narsil = await createNarsil()

await narsil.createIndex('baggage_rules', {
  schema: { title: 'string', body: 'string' },
  language: 'english',
})

await narsil.insertBatch('baggage_rules', [
  {
    id: 'CB-04',
    title: 'Pushchairs and child seats',
    body: 'One folding pushchair and one child seat travel free of charge for each child under two, carried in the hold and collected at the baggage belt. A pushchair that does not fold is carried as hold baggage against the adult allowance.',
  },
  {
    id: 'CB-01',
    title: 'Cabin baggage allowance',
    body: 'Each passenger carries one cabin bag of up to 10 kilogrammes measuring 55 by 40 by 20 centimetres, along with one small personal item that fits under the seat in front.',
  },
  {
    id: 'CB-09',
    title: 'Musical instruments',
    body: 'A violin or a guitar in a soft case counts as the cabin bag. A larger instrument needs a seat of its own, booked before departure.',
  },
  {
    id: 'CB-12',
    title: 'Mobility equipment',
    body: 'A wheelchair, a walking frame, or a pair of crutches travels free of charge and counts against no allowance.',
  },
])

const assistantTools = {
  searchRules: tool({
    description: 'Search the baggage rules by what their text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<RuleText[]> => {
      const found = await narsil.query<{ body: string }>('baggage_rules', {
        term: text,
        document: { include: ['body'] },
        limit: 2,
      })
      return found.hits.map((hit) => ({ body: hit.document.body }))
    },
  }),
}

const askAssistant = async (): Promise<string> => {
  const assistant = new ToolLoopAgent({
    model: assistantModel(),
    instructions: 'Answer the passenger from the baggage rules.',
    tools: assistantTools,
  })
  const reply = await assistant.generate({ prompt: QUESTION })
  return reply.text
}

console.log(JSON.stringify({ question: QUESTION, assistantAnswered: await askAssistant() }, null, 2))

await narsil.shutdown()
```

```json result open
{
  "question": "Does a folding pushchair count against my cabin baggage allowance?",
  "assistantAnswered": "A folding pushchair travels free and does not count against your cabin baggage allowance."
}
```

```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 = 'Does a folding pushchair count against my cabin baggage allowance?'

export interface RuleText {
  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 assistantModel = () =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const searches = everythingReturnedBy(prompt, 'searchRules') as RuleText[][]
      if (searches.length === 0) return callTool('searchRules', { text: 'folding pushchair cabin baggage' })
      if (searches[0].length === 0) return say('I hold no rule on that.')
      return say('A folding pushchair travels free and does not count against your cabin baggage allowance.')
    },
  })
```

</CodeGroup>

The model mentions the allowance without its condition, and the answer contains no reference, because the tool returns each rule's body alone. When a passenger reads that answer at the gate, they have to take it on trust, since it contains nothing that they can look up.

## Give the answer a rule number and the rule's own words

With `highlight` set, the engine marks the matched words in a field and returns `positions` along with the snippet. Each entry in `positions` holds the start and the end of one match, counted in characters from the start of the original field, which means that slicing the stored text at those two offsets returns the matched word. `sentenceAt` extends the first entry to the full stop on each side and returns the sentence that contains the first match.

The snippet from the same call is better suited to a list of results. By default the engine takes 200 characters around the densest run of matches and adds an ellipsis wherever it cuts the field, and that cut can fall in the middle of a word. That is why `searchRules` builds its quote from the offsets and takes the reference from each hit's `id`.

<CodeGroup defaultTab="baggage.ts">

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

const narsil = await createNarsil()

await narsil.createIndex('baggage_rules', {
  schema: { title: 'string', body: 'string' },
  language: 'english',
})

await narsil.insertBatch('baggage_rules', [
  {
    id: 'CB-04',
    title: 'Pushchairs and child seats',
    body: 'One folding pushchair and one child seat travel free of charge for each child under two, carried in the hold and collected at the baggage belt. A pushchair that does not fold is carried as hold baggage against the adult allowance.',
  },
  {
    id: 'CB-01',
    title: 'Cabin baggage allowance',
    body: 'Each passenger carries one cabin bag of up to 10 kilogrammes measuring 55 by 40 by 20 centimetres, along with one small personal item that fits under the seat in front.',
  },
  {
    id: 'CB-09',
    title: 'Musical instruments',
    body: 'A violin or a guitar in a soft case counts as the cabin bag. A larger instrument needs a seat of its own, booked before departure.',
  },
  {
    id: 'CB-12',
    title: 'Mobility equipment',
    body: 'A wheelchair, a walking frame, or a pair of crutches travels free of charge and counts against no allowance.',
  },
])

const sentenceAt = (body: string, position: { start: number; end: number } | undefined): string => { // [!code ++:6]
  if (position === undefined) return ''
  const opens = body.lastIndexOf('. ', position.start)
  const closes = body.indexOf('.', position.end)
  return body.slice(opens === -1 ? 0 : opens + 2, closes === -1 ? body.length : closes + 1)
}

const assistantTools = {
  searchRules: tool({
    description: 'Search the baggage rules by what their text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<RuleText[]> => { // [!code --]
    execute: async ({ text }: { text: string }): Promise<RuleCitation[]> => { // [!code ++]
      const found = await narsil.query<{ body: string }>('baggage_rules', { // [!code --]
      const found = await narsil.query<{ title: string; body: string }>('baggage_rules', { // [!code ++]
        term: text,
        document: { include: ['body'] }, // [!code --]
        highlight: { fields: ['body'] }, // [!code ++]
        limit: 2,
      })
      return found.hits.map((hit) => ({ body: hit.document.body })) // [!code --]
      return found.hits.map((hit) => ({ // [!code ++:5]
        reference: hit.id,
        title: hit.document.title,
        quote: sentenceAt(hit.document.body, hit.highlights?.body?.positions?.[0]),
      }))
    },
  }),
}

const askAssistant = async (): Promise<string> => {
  const assistant = new ToolLoopAgent({
    model: assistantModel(),
    instructions: 'Answer the passenger from the baggage rules.', // [!code --]
    instructions: 'Answer the passenger by quoting the rule that covers the question.', // [!code ++]
    tools: assistantTools,
  })
  const reply = await assistant.generate({ prompt: QUESTION })
  return reply.text
}

console.log(JSON.stringify({ question: QUESTION, assistantAnswered: await askAssistant() }, null, 2))

await narsil.shutdown()
```

```json result open
{
  "question": "Does a folding pushchair count against my cabin baggage allowance?",
  "assistantAnswered": "Rule CB-04, Pushchairs and child seats, says: 'One folding pushchair and one child seat travel free of charge for each child under two, carried in the hold and collected at the baggage belt.'"
}
```

```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 = 'Does a folding pushchair count against my cabin baggage allowance?'

export interface RuleText { // [!code --]
  body: string // [!code --]
} // [!code --]
export interface RuleCitation { // [!code ++:4]
  reference: string
  title: string
  quote: 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 assistantModel = () =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const searches = everythingReturnedBy(prompt, 'searchRules') as RuleText[][] // [!code --]
      const searches = everythingReturnedBy(prompt, 'searchRules') as RuleCitation[][] // [!code ++]
      if (searches.length === 0) return callTool('searchRules', { text: 'folding pushchair cabin baggage' })
      if (searches[0].length === 0) return say('I hold no rule on that.')
      return say('A folding pushchair travels free and does not count against your cabin baggage allowance.') // [!code --]
      const first = searches[0][0] // [!code ++:2]
      return say(`Rule ${first.reference}, ${first.title}, says: '${first.quote}'`)
    },
  })
```

</CodeGroup>

The answer now contains the rule's reference and the sentence with the condition that is missing from the first answer. A gate supervisor can open CB-04 and read the same words there.

<CitedSpanDiagram
  field="baggage_rules / CB-04 / body"
  text="One folding pushchair and one child seat travel free of charge for each child under two, carried in the hold and collected at the baggage belt. A pushchair that does not fold is carried as hold baggage against the adult allowance."
  citation={{ start: 0, end: 143 }}
  matches={[
    { start: 4, end: 11 },
    { start: 12, end: 21 },
    { start: 130, end: 137 },
    { start: 146, end: 155 },
    { start: 170, end: 174 },
  ]}
  caption="The bar covers the sentence that the AI agent quotes, from character 0 to character 143, and each tick marks one of the five words that the query 'folding pushchair cabin baggage' matches."
/>

## Check every quote against the stored text

The model writes the quote itself, which means it can put a sentence in the answer that no rule contains. `publishAnswer` therefore takes the reference, the quote, and the answer, and fetches the stored rule with `narsil.get`. It publishes the answer only when the rule's body contains the quote. `indexOf` returns the offset at which the quote starts, and `publishAnswer` records that offset and the end offset with the answer, so that the airline's website can underline the sentence inside the rule.

On its first try the mock model quotes a sentence that it invents, which is the failure that the authors above report. The reference is a real rule on the right subject; however, that rule contains none of the quoted wording.

<CodeGroup defaultTab="baggage.ts">

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

const narsil = await createNarsil()

await narsil.createIndex('baggage_rules', {
  schema: { title: 'string', body: 'string' },
  language: 'english',
})

await narsil.insertBatch('baggage_rules', [
  {
    id: 'CB-04',
    title: 'Pushchairs and child seats',
    body: 'One folding pushchair and one child seat travel free of charge for each child under two, carried in the hold and collected at the baggage belt. A pushchair that does not fold is carried as hold baggage against the adult allowance.',
  },
  {
    id: 'CB-01',
    title: 'Cabin baggage allowance',
    body: 'Each passenger carries one cabin bag of up to 10 kilogrammes measuring 55 by 40 by 20 centimetres, along with one small personal item that fits under the seat in front.',
  },
  {
    id: 'CB-09',
    title: 'Musical instruments',
    body: 'A violin or a guitar in a soft case counts as the cabin bag. A larger instrument needs a seat of its own, booked before departure.',
  },
  {
    id: 'CB-12',
    title: 'Mobility equipment',
    body: 'A wheelchair, a walking frame, or a pair of crutches travels free of charge and counts against no allowance.',
  },
])

const sentenceAt = (body: string, position: { start: number; end: number } | undefined): string => {
  if (position === undefined) return ''
  const opens = body.lastIndexOf('. ', position.start)
  const closes = body.indexOf('.', position.end)
  return body.slice(opens === -1 ? 0 : opens + 2, closes === -1 ? body.length : closes + 1)
}

interface PublishedAnswer { // [!code ++:9]
  answer: string
  reference: string
  start: number
  end: number
}

const published: PublishedAnswer[] = []
const refused: string[] = []

const assistantTools = {
  searchRules: tool({
    description: 'Search the baggage rules by what their text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<RuleCitation[]> => {
      const found = await narsil.query<{ title: string; body: string }>('baggage_rules', {
        term: text,
        highlight: { fields: ['body'] },
        limit: 2,
      })
      return found.hits.map((hit) => ({
        reference: hit.id,
        title: hit.document.title,
        quote: sentenceAt(hit.document.body, hit.highlights?.body?.positions?.[0]),
      }))
    },
  }),
  publishAnswer: tool({ // [!code ++:24]
    description: 'Send the answer to the passenger, quoting one sentence of the rule that covers it.',
    inputSchema: z.object({ reference: z.string(), quote: z.string(), answer: z.string() }),
    execute: async ({
      reference,
      quote,
      answer,
    }: {
      reference: string
      quote: string
      answer: string
    }): Promise<PublishOutcome> => {
      const rule = await narsil.get('baggage_rules', reference)
      const body = typeof rule?.body === 'string' ? rule.body : ''
      const start = body.indexOf(quote)
      if (start === -1) {
        const reason = `Rule ${reference} holds no sentence reading '${quote}'`
        refused.push(reason)
        return { published: false, reason }
      }
      published.push({ answer, reference, start, end: start + quote.length })
      return { published: true }
    },
  }),
}

const askAssistant = async (): Promise<string> => {
  const assistant = new ToolLoopAgent({
    model: assistantModel(),
    instructions: 'Answer the passenger by quoting the rule that covers the question.',
    tools: assistantTools,
  })
  const reply = await assistant.generate({ prompt: QUESTION })
  return reply.text
}

console.log(JSON.stringify({ question: QUESTION, assistantAnswered: await askAssistant() }, null, 2)) // [!code --]
const closing = await askAssistant() // [!code ++:3]

console.log(JSON.stringify({ question: QUESTION, assistantClosed: closing, refused, published }, null, 2))

await narsil.shutdown()
```

```json result open
{
  "question": "Does a folding pushchair count against my cabin baggage allowance?",
  "assistantClosed": "Answered from rule CB-04.",
  "refused": [
    "Rule CB-04 holds no sentence reading 'A folding pushchair travels free of charge on every fare.'"
  ],
  "published": [
    {
      "answer": "A folding pushchair for a child under two travels free of charge, in the hold.",
      "reference": "CB-04",
      "start": 0,
      "end": 143
    }
  ]
}
```

```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 = 'Does a folding pushchair count against my cabin baggage allowance?'

export const INVENTED_QUOTE = 'A folding pushchair travels free of charge on every fare.' // [!code ++]

export interface RuleCitation {
  reference: string
  title: string
  quote: string
}

export interface PublishOutcome { // [!code ++:4]
  published: boolean
  reason?: 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 assistantModel = () =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const searches = everythingReturnedBy(prompt, 'searchRules') as RuleCitation[][]
      if (searches.length === 0) return callTool('searchRules', { text: 'folding pushchair cabin baggage' })
      if (searches[0].length === 0) return say('I hold no rule on that.')
      const first = searches[0][0]
      return say(`Rule ${first.reference}, ${first.title}, says: '${first.quote}'`) // [!code --]
      const attempts = everythingReturnedBy(prompt, 'publishAnswer') as PublishOutcome[] // [!code ++:16]
      if (attempts.length === 0) {
        return callTool('publishAnswer', {
          reference: first.reference,
          quote: INVENTED_QUOTE,
          answer: 'A folding pushchair travels free and does not count against your cabin baggage allowance.',
        })
      }
      if (attempts[attempts.length - 1].published === false) {
        return callTool('publishAnswer', {
          reference: first.reference,
          quote: first.quote,
          answer: 'A folding pushchair for a child under two travels free of charge, in the hold.',
        })
      }
      return say(`Answered from rule ${first.reference}.`)
    },
  })
```

</CodeGroup>

`publishAnswer` returns `published: false` for the invented sentence before the passenger sees anything. On its second try the model quotes the sentence that the search returns, which `publishAnswer` publishes with characters 0 to 143 of CB-04 as its offsets. A web page can use that pair to mark the passage inside the rule.

## Where to go next

- Read [Full-text search](/docs/full-text-search) for the ranking behind these hits, and for the tags and the snippet length that `highlight` accepts.
- See [When the index has no good match](/docs/agent-weak-matches) for what an AI agent should say when the search returns nothing worth citing.
- Keep a journal of the query and the hits behind each answer with the plugin from [Record every search](/docs/agent-search-audit).
