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

# Give a model a search tool

> Give a model a search tool that takes a resident's own words, since a lookup by key needs an identifier that the model has no way of knowing.

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

Say that a resident phones the council about an old fridge, and the AI agent on the line replies that it has no page on the subject, although two pages of the guidance are about fridges. The agent's one tool fetches a page by its slug. The model therefore has to make up a slug, and the slug that it makes up matches no page.

The author of a 2026 diagnostic benchmark sorts failed tool use into four kinds, which are a skipped call, an ignored result, a fabricated output, and an unnecessary call. Across nineteen models, the best has a clean tool-use rate of 86.33% ([arXiv 2607.04686](https://arxiv.org/abs/2607.04686)). Which of those four failures a model can make depends on the shape of its tool.

The model on this page is a scripted mock, so the code prints the same text every time it executes. Replace the mock with a model string when you want a real model to choose the calls. Each code block below holds one whole file.

## A tool that takes only an identifier

`readPage` takes a slug and returns the page with that slug. That tool is useful when the conversation already contains a slug. The resident's question contains none, however, which means the model has to guess one, and `narsil.get` returns `undefined` for the guess.

<CodeGroup defaultTab="helpline.ts">

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

const narsil = await createNarsil()

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

await narsil.insertBatch('waste_pages', [
  {
    id: 'bulky-waste',
    title: 'Book a bulky waste collection',
    service: 'collections',
    body: 'A fridge, a freezer, a mattress, or a sofa goes on a bulky waste collection, which costs £22 for up to three items. The crew takes the items from the kerbside on the morning of the booking.',
  },
  {
    id: 'recycling-centre',
    title: 'Using the household recycling centre',
    service: 'sites',
    body: 'The household recycling centre on Marsh Lane takes a fridge, garden waste, and rubble free of charge from any resident holding a site permit.',
  },
  {
    id: 'missed-collection',
    title: 'Report a missed collection',
    service: 'collections',
    body: 'Report a missed bin within two working days and the crew returns to the street on the next working day.',
  },
  {
    id: 'food-waste',
    title: 'Food waste caddies',
    service: 'collections',
    body: 'Every household has a kitchen caddy and a kerbside caddy for food waste, emptied each week.',
  },
  {
    id: 'small-electricals',
    title: 'Small electrical items',
    service: 'sites',
    body: 'A kettle, a toaster, or a hairdryer goes in the small electricals bank at any of the four recycling banks.',
  },
  {
    id: 'garden-waste',
    title: 'Garden waste subscription',
    service: 'collections',
    body: 'A garden waste subscription costs £48 a year and covers a fortnightly collection from March to November.',
  },
])

const helplineTools = {
  readPage: tool({
    description: 'Read one page of the waste and recycling guidance by its slug.',
    inputSchema: z.object({ slug: z.string() }),
    execute: async ({ slug }: { slug: string }): Promise<WastePage | null> => {
      const page = await narsil.get('waste_pages', slug)
      if (page === undefined) return null
      return { title: String(page.title), body: String(page.body) }
    },
  }),
}

const askHelpline = async (): Promise<string> => {
  const helpline = new ToolLoopAgent({
    model: helplineModel(),
    instructions: 'Answer the resident from the waste and recycling guidance.',
    tools: helplineTools,
  })
  const reply = await helpline.generate({ prompt: QUESTION })
  return reply.text
}

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

await narsil.shutdown()
```

```json result open
{
  "question": "How do I get rid of an old fridge?",
  "helplineAnswered": "I hold no page under 'fridge-disposal'."
}
```

```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 = 'How do I get rid of an old fridge?'

export const GUESSED_SLUG = 'fridge-disposal'

export interface WastePage {
  title: 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 helplineModel = () =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const reads = everythingReturnedBy(prompt, 'readPage') as Array<WastePage | null>
      if (reads.length === 0) return callTool('readPage', { slug: GUESSED_SLUG })
      if (reads[0] === null) return say(`I hold no page under '${GUESSED_SLUG}'.`)
      return say(reads[0].body)
    },
  })
```

</CodeGroup>

The guidance contains the answer twice, on the bulky waste page and on the recycling centre page, yet the resident hears neither. Keep a lookup by key alongside a search all the same, because once a search returns a page's id, the model can use the lookup to open that page.

## Search on the words that a resident uses

`searchPages` takes the question as text and returns the pages that match it. `query` passes that text through the index's language module, which stems each word and drops the stop words, and the terms that remain are what a page can match. Two pages contain the word fridge, which is why the search returns both.

<CodeGroup defaultTab="helpline.ts">

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

const narsil = await createNarsil()

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

await narsil.insertBatch('waste_pages', [
  {
    id: 'bulky-waste',
    title: 'Book a bulky waste collection',
    service: 'collections',
    body: 'A fridge, a freezer, a mattress, or a sofa goes on a bulky waste collection, which costs £22 for up to three items. The crew takes the items from the kerbside on the morning of the booking.',
  },
  {
    id: 'recycling-centre',
    title: 'Using the household recycling centre',
    service: 'sites',
    body: 'The household recycling centre on Marsh Lane takes a fridge, garden waste, and rubble free of charge from any resident holding a site permit.',
  },
  {
    id: 'missed-collection',
    title: 'Report a missed collection',
    service: 'collections',
    body: 'Report a missed bin within two working days and the crew returns to the street on the next working day.',
  },
  {
    id: 'food-waste',
    title: 'Food waste caddies',
    service: 'collections',
    body: 'Every household has a kitchen caddy and a kerbside caddy for food waste, emptied each week.',
  },
  {
    id: 'small-electricals',
    title: 'Small electrical items',
    service: 'sites',
    body: 'A kettle, a toaster, or a hairdryer goes in the small electricals bank at any of the four recycling banks.',
  },
  {
    id: 'garden-waste',
    title: 'Garden waste subscription',
    service: 'collections',
    body: 'A garden waste subscription costs £48 a year and covers a fortnightly collection from March to November.',
  },
])

const helplineTools = {
  readPage: tool({ // [!code --]
    description: 'Read one page of the waste and recycling guidance by its slug.', // [!code --]
    inputSchema: z.object({ slug: z.string() }), // [!code --]
    execute: async ({ slug }: { slug: string }): Promise<WastePage | null> => { // [!code --]
      const page = await narsil.get('waste_pages', slug) // [!code --]
      if (page === undefined) return null // [!code --]
      return { title: String(page.title), body: String(page.body) } // [!code --]
  searchPages: tool({ // [!code ++:6]
    description: 'Search the waste and recycling guidance by what its text says.',
    inputSchema: z.object({ text: z.string() }),
    execute: async ({ text }: { text: string }): Promise<WastePage[]> => {
      const found = await narsil.query<WastePage>('waste_pages', { term: text, limit: 5 })
      return found.hits.map((hit) => hit.document)
    },
  }),
}

const askHelpline = async (): Promise<string> => {
  const helpline = new ToolLoopAgent({
    model: helplineModel(),
    instructions: 'Answer the resident from the waste and recycling guidance.',
    tools: helplineTools,
  })
  const reply = await helpline.generate({ prompt: QUESTION })
  return reply.text
}

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

await narsil.shutdown()
```

```json result open
{
  "question": "How do I get rid of an old fridge?",
  "helplineAnswered": "The household recycling centre on Marsh Lane takes a fridge, garden waste, and rubble free of charge from any resident holding a site permit. A fridge, a freezer, a mattress, or a sofa goes on a bulky waste collection, which costs £22 for up to three items. The crew takes the items from the kerbside on the morning of the booking."
}
```

```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 = 'How do I get rid of an old fridge?'

export const GUESSED_SLUG = 'fridge-disposal' // [!code --]

export interface WastePage {
  title: 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 helplineModel = () =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const reads = everythingReturnedBy(prompt, 'readPage') as Array<WastePage | null> // [!code --]
      if (reads.length === 0) return callTool('readPage', { slug: GUESSED_SLUG }) // [!code --]
      if (reads[0] === null) return say(`I hold no page under '${GUESSED_SLUG}'.`) // [!code --]
      return say(reads[0].body) // [!code --]
      const searches = everythingReturnedBy(prompt, 'searchPages') as WastePage[][] // [!code ++:5]
      if (searches.length === 0) return callTool('searchPages', { text: QUESTION })
      const found = searches[0]
      if (found.length === 0) return say('I hold no page on that.')
      return say(found.map((page) => page.body).join(' '))
    },
  })
```

</CodeGroup>

The resident now hears both ways to get rid of the fridge. Two choices in that tool are still yours to make. The tool returns each hit as the whole stored document, which means that the next prompt contains every field of every page. It also returns no score, and without one the model has no way to tell the weaker page from the stronger one.

## Narrow by service, and fix the rest in the tool

You control what the model receives with four query options. `document.include` lists the two fields that the tool sends, and the tool sets `limit` itself, where the model has no way to raise it. `filters` removes candidates before the engine scores anything, while `facets` returns a count of the matching pages in each service area along with the hits.

`SERVICE_AREAS` holds the council's own names for its service areas, and `z.enum` puts that list in the tool's input schema, which leaves the model only those values to choose from. The facet counts hold one match in each area, and on its second call the model narrows the search to `collections`. On a single thread the engine returns each facet count with `errorBound: 0` and `countExact: true`, which means that each count is the exact number of matching pages.

<CodeGroup defaultTab="helpline.ts">

```ts title="helpline.ts"
import { createNarsil } from '@delali/narsil'
import { ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { helplineModel, QUESTION, type WastePage } from './mock-model.ts' // [!code --]
import { helplineModel, type PageSearch, QUESTION, type WastePage } from './mock-model.ts' // [!code ++:3]

const SERVICE_AREAS = ['collections', 'sites'] as const

const narsil = await createNarsil()

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

await narsil.insertBatch('waste_pages', [
  {
    id: 'bulky-waste',
    title: 'Book a bulky waste collection',
    service: 'collections',
    body: 'A fridge, a freezer, a mattress, or a sofa goes on a bulky waste collection, which costs £22 for up to three items. The crew takes the items from the kerbside on the morning of the booking.',
  },
  {
    id: 'recycling-centre',
    title: 'Using the household recycling centre',
    service: 'sites',
    body: 'The household recycling centre on Marsh Lane takes a fridge, garden waste, and rubble free of charge from any resident holding a site permit.',
  },
  {
    id: 'missed-collection',
    title: 'Report a missed collection',
    service: 'collections',
    body: 'Report a missed bin within two working days and the crew returns to the street on the next working day.',
  },
  {
    id: 'food-waste',
    title: 'Food waste caddies',
    service: 'collections',
    body: 'Every household has a kitchen caddy and a kerbside caddy for food waste, emptied each week.',
  },
  {
    id: 'small-electricals',
    title: 'Small electrical items',
    service: 'sites',
    body: 'A kettle, a toaster, or a hairdryer goes in the small electricals bank at any of the four recycling banks.',
  },
  {
    id: 'garden-waste',
    title: 'Garden waste subscription',
    service: 'collections',
    body: 'A garden waste subscription costs £48 a year and covers a fortnightly collection from March to November.',
  },
])

const helplineTools = {
  searchPages: tool({
    description: 'Search the waste and recycling guidance by what its text says.',
    inputSchema: z.object({ text: z.string() }), // [!code --]
    execute: async ({ text }: { text: string }): Promise<WastePage[]> => { // [!code --]
      const found = await narsil.query<WastePage>('waste_pages', { term: text, limit: 5 }) // [!code --]
      return found.hits.map((hit) => hit.document) // [!code --]
    inputSchema: z.object({ text: z.string(), service: z.enum(SERVICE_AREAS).optional() }), // [!code ++:24]
    execute: async ({
      text,
      service,
    }: {
      text: string
      service?: (typeof SERVICE_AREAS)[number]
    }): Promise<PageSearch> => {
      const found = await narsil.query<WastePage>('waste_pages', {
        term: text,
        filters: service === undefined ? undefined : { fields: { service: { eq: service } } },
        facets: { service: {} },
        document: { include: ['title', 'body'] },
        limit: 3,
      })
      return {
        hits: found.hits.map((hit) => ({
          page: hit.id,
          title: hit.document.title,
          body: hit.document.body,
          score: Number((hit.score ?? 0).toFixed(2)),
        })),
        services: found.facets?.service.values ?? {},
      }
    },
  }),
}

const askHelpline = async (): Promise<string> => {
  const helpline = new ToolLoopAgent({
    model: helplineModel(),
    instructions: 'Answer the resident from the waste and recycling guidance.', // [!code --]
    instructions: 'Answer the resident from the waste and recycling guidance, and say which page you used.', // [!code ++]
    tools: helplineTools,
  })
  const reply = await helpline.generate({ prompt: QUESTION })
  return reply.text
}

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

await narsil.shutdown()
```

```json result open
{
  "question": "How do I get rid of an old fridge?",
  "helplineAnswered": "A fridge appears on 1 collections page and 1 sites page. See 'Book a bulky waste collection'. A fridge, a freezer, a mattress, or a sofa goes on a bulky waste collection, which costs £22 for up to three items. The crew takes the items from the kerbside on the morning of the booking."
}
```

```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 = 'How do I get rid of an old fridge?'

export interface WastePage {
  title: string
  body: string
}

export interface PageHit extends WastePage { // [!code ++:9]
  page: string
  score: number
}

export interface PageSearch {
  hits: PageHit[]
  services: Record<string, 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: [],
})

const describeSplit = (services: Record<string, number>): string => // [!code ++:5]
  Object.entries(services)
    .map(([service, count]) => `${count} ${service} page${count === 1 ? '' : 's'}`)
    .join(' and ')

export const helplineModel = () =>
  new MockLanguageModelV4({
    doGenerate: async ({ prompt }) => {
      const searches = everythingReturnedBy(prompt, 'searchPages') as WastePage[][] // [!code --]
      if (searches.length === 0) return callTool('searchPages', { text: QUESTION }) // [!code --]
      const found = searches[0] // [!code --]
      if (found.length === 0) return say('I hold no page on that.') // [!code --]
      return say(found.map((page) => page.body).join(' ')) // [!code --]
      const searches = everythingReturnedBy(prompt, 'searchPages') as PageSearch[] // [!code ++:7]
      if (searches.length === 0) return callTool('searchPages', { text: 'fridge' })
      const wide = searches[0]
      if (wide.hits.length === 0) return say('I hold no page on that.')
      if (searches.length === 1) return callTool('searchPages', { text: 'fridge', service: 'collections' })
      const best = searches[1].hits[0]
      return say(`A fridge appears on ${describeSplit(wide.services)}. See '${best.title}'. ${best.body}`)
    },
  })
```

</CodeGroup>

The model chooses the search text itself and cuts the question down to the one word that a page can match. On its second call it narrows the search by service, and its answer contains the page title along with the charge.

<ToolCallLadderDiagram
  turns={[
    { speaker: 'model', label: "readPage({ slug: 'fridge-disposal' })" },
    { speaker: 'tool', label: 'null', outcome: 'miss' },
    { speaker: 'model', label: "searchPages({ text: 'fridge' })" },
    { speaker: 'tool', label: '2 hits, services: { collections: 1, sites: 1 }' },
    { speaker: 'model', label: "searchPages({ text: 'fridge', service: 'collections' })" },
    { speaker: 'tool', label: '1 hit: bulky-waste', outcome: 'answer' },
  ]}
  caption="The model's calls are on the left and the tool's results are on the right, in the order of the tool loop. The guessed slug returns null, whereas each search returns pages."
/>

## Where to go next

- Look up the filter operators, the facet counts, and their `errorBound` in [Filters and facets](/docs/filters-and-facets).
- Read [Fit the passages in a context budget](/docs/agent-context-budget) to measure in characters what a tool sends back to the model.
- Turn a hit into a citation with the reference and the offsets from [Cite the passage behind an answer](/docs/agent-grounded-answers).
