Skip to content
8 min read · 1,542 words

SearXNG Search

This is a featured battery

SearXNG breaks the mold every other tool battery follows, and web search is something nearly every agent eventually wants. That earns it its own page. If you read nothing else, read the two code blocks below: the first shows the whole entry point, the second shows the pipelines.

We built this battery because a search tool is the one tool that refuses to be a constant. Every other bundled tool — calculate, color_contrast, text_analyze — is a pure function of its arguments, so we ship it as a ready-made Tool you import and register. A web search isn't pure: it has to know which SearXNG instance to talk to, and that instance is almost always behind some flavor of authentication — a reverse proxy, an API gateway, basic auth, a bearer token that rotates every fifteen minutes. None of that can be baked in at module load. So this battery ships a factory instead of a constant, and the configuration you'd otherwise be threading through three layers of your own code lives in one options object.

The one call that matters

createSearxngSearchTool is the whole entry point. It takes a config and returns a Tool you register like any other — there's just one wrinkle: it's async, so you await it.

typescript
import { createSearxngSearchTool } from '@nhtio/adk/batteries/tools/searxng'
import { ToolRegistry } from '@nhtio/adk/common'

const searxng = await createSearxngSearchTool({
  instanceUrl: 'https://searx.example.org',
  // Custom auth — a static object, or a resolver for tokens that expire.
  headers: () => ({ Authorization: `Bearer ${getFreshToken()}` }),
})

const registry = new ToolRegistry([searxng])

That's the minimum. The model now has a searxng_search tool whose only required argument is query; everything else (categories, engines, language, pageno, time_range, safesearch) is optional and passed straight through to SearXNG.

If you don't need the async escape hatch (more on that below), there's a synchronous twin — same config, no await:

typescript
import { createSearxngSearchToolSync } from '@nhtio/adk/batteries/tools/searxng'

const searxng = createSearxngSearchToolSync({ instanceUrl: 'https://searx.example.org' })

Why a factory, when every other tool battery is a constant?

Because configuration that can't be known at module load has to come from somewhere, and the alternatives are worse. A module-level singleton means one instance per process — useless when you're fanning out across tenants. Reading process.env inside the handler buries the config contract where nobody can see it. A factory keeps the contract honest: the options object is the documentation, the tool it returns is immutable, and you can mint as many differently-configured tools as you have instances to search.

Why is the factory async?

One reason, and it's the artifact option. A Tool wraps its output in a spooled-artifact class, and the wrap-site calls that constructor synchronously — so the tool can only ever hold a sync () => Ctor. But we also want you to be able to hand the factory a dynamic import() => import('@nhtio/adk/spooled_artifact').then(m => m.SpooledMarkdownArtifact) — so the artifact class never enters your static bundle. Resolving that import is async, so the factory that does it is async too. When you don't reach for a dynamic import, createSearxngSearchToolSync skips the ceremony.

Custom headers: static or resolved

Authentication is the reason this tool needs configuration at all, so headers get first-class treatment. Pass a static object when the credential is fixed for the life of the process:

typescript
await createSearxngSearchTool({
  instanceUrl: 'https://searx.example.org',
  headers: { 'X-API-Key': process.env.SEARXNG_KEY! },
})

Pass a resolver — sync or async — when the credential refreshes. The resolver runs on every search, so a token minted ten minutes ago is never the token you send:

typescript
await createSearxngSearchTool({
  instanceUrl: 'https://searx.example.org',
  headers: async () => ({ Authorization: `Bearer ${await mintToken()}` }),
})

Your headers are merged over the tool's defaults (Accept: application/json, a User-Agent), and yours win — so overriding either is a one-liner, not a fork.

Two levels of output-format control

A search result can be returned two ways: normalized (trimmed to the fields a model actually reads — title, url, snippet, engine, score) or raw (the full SearXNG JSON, untouched). The question is who decides, and the answer is "it depends on your deployment," so the battery lets you set it at either level.

Pin it at the factory and the model cannot change it — the format argument is removed from the tool's schema entirely, so it can't even try:

typescript
await createSearxngSearchTool({ instanceUrl, resultFormat: 'normalized' }) // model has no say

Leave it neutral (the default, 'either') and the model chooses per call via a format argument on the tool:

typescript
await createSearxngSearchTool({ instanceUrl }) // resultFormat defaults to 'either'

The rule is simple: an opinion at the factory always wins; no opinion at the factory hands the choice to the model.

Pipelines: shape the request, shape the result

The search itself is the boring part — fetch, parse, return. The interesting part is everything you want to do around it: force a language, inject a tenant header, drop low-score hits, redact a field, render markdown, answer from a cache without hitting the network at all. Rather than make you wrap the tool, the battery gives you two middleware pipelines built on the same @nhtio/middleware onion the core runners use — (ctx, next), mutate the context in place, call next() to continue.

typescript
const searxng = await createSearxngSearchTool({
  instanceUrl: 'https://searx.example.org',
  inputPipeline: [
    // Runs before the request. Mutate the query, params, or headers.
    async (ctx, next) => {
      ctx.params.language = 'en'
      await next()
    },
  ],
  outputPipeline: [
    // Runs after the response is parsed. Filter, re-rank, redact, or re-render.
    async (ctx, next) => {
      ctx.results = ctx.results.filter((r) => (r.score ?? 0) > 0.1).slice(0, 10)
      await next()
    },
  ],
})

The input context (SearxngRequestContext) carries the mutable query, params, and headers, plus a stash Map for passing data downstream and a shortCircuit(result) escape hatch — call it and the fetch never happens, the string you pass becomes the tool's output. That's your cache-hit path. The output context (SearxngResponseContext) carries the mutable normalized results, the raw body, the same stash, and an output field: set it and it becomes the tool's output verbatim.

Render markdown by pairing an output stage with a markdown artifact

The tool's spool artifact type is the factory's artifact resolver (default () => SpooledJsonArtifact). If you want the model to receive markdown, set it to () => SpooledMarkdownArtifact and have an output stage write the rendered markdown into ctx.output. The battery ships no markdown renderer of its own — the shape of "a good search result in markdown" is your call, and a three-line .map() usually is the whole renderer. (Need the class to stay out of your static bundle? artifact: () => import('@nhtio/adk/spooled_artifact').then((m) => m.SpooledMarkdownArtifact) — that's the async resolver the async factory exists for.)

typescript
import { SpooledMarkdownArtifact } from '@nhtio/adk/spooled_artifact'

await createSearxngSearchTool({
  instanceUrl: 'https://searx.example.org',
  resultFormat: 'normalized',
  artifact: () => SpooledMarkdownArtifact,
  outputPipeline: [
    async (ctx, next) => {
      ctx.output = ctx.results.map((r) => `- [${r.title}](${r.url})\n  ${r.content}`).join('\n')
      await next()
    },
  ],
})

A fresh middleware runner is minted on every invocation, so the pipelines are safe to reuse across calls — a detail you'd otherwise discover the hard way, since middleware runners are single-use.

When SearXNG says no

JSON is off by default on most instances

SearXNG ships with its JSON output format disabled, because bots abuse it. An instance that hasn't enabled search.formats: [json] in its settings.yml answers a JSON request with 403 Forbidden. The tool turns that into a graceful Error: string that names the setting, so the model gets a legible message instead of a mystery — but the fix is on the instance, not in your code. If you run the instance, enable the format; if you don't, you may simply be out of luck on that one.

Other failures degrade the same way: a network error, a timeout (default 10s, configurable via timeout), or a thrown pipeline stage all come back as Error: strings the model can read and react to, rather than exceptions that abort the turn. The one exception is bad arguments — those throw E_INVALID_TOOL_ARGS before the handler runs, because a malformed tool call is a different kind of problem than a search that didn't pan out.

Don't trust number_of_results — count results instead

SearXNG's JSON output reports number_of_results: 0 on a lot of instances even when results is full of hits. This isn't our tool dropping the count — it's a long-standing upstream behavior, documented in searxng/searxng#2987 ("JSON number_of_results is wrong") and searxng/searxng#2457 ("number_of_results always set to 0 in API results"). We pass the field through exactly as the instance reports it, so if you want a count, use results.length. We're flagging it here so you don't lose an afternoon to it like everyone in those issue threads did.

From results to RAG: the web_retrieval glue

A search result is only useful if it gets into the turn. The shared web_retrieval module — the same glue the Scrapper battery uses — turns SearXNG results into Retrievable records the runner already knows how to surface:

typescript
import { searxngResultsToRetrievables, storeRetrievables } from '@nhtio/adk/batteries/tools/web_retrieval'
import { Retrievable } from '@nhtio/adk/common'

// `payload` is a normalized SearXNG result (parse the tool's JSON output, or build it in a pipeline).
const raws = searxngResultsToRetrievables(payload, { kind: 'web-search-result' })
await storeRetrievables(ctx, raws, { retrievable: Retrievable })

searxngResultsToRetrievables is a pure function — it returns plain RawRetrievable data, never touching a core class, so importing it costs you nothing at runtime. storeRetrievables is the one piece that constructs Retrievables, and it takes the constructor through a resolver (Retrievable, () => Retrievable, or a dynamic import) for the same decoupling reason the artifact option does. Search snippets are short, so they ride inline as strings; web content is tagged trustTier: 'third-party-public' by default — a deliberate constant for open-web data, not something we sniff from the URL (see the trust-tier rules in Retrievable Glue). The full story, including reader-backed SpooledArtifact content for long pages, lives on the Scrapper page.

Where this sits in an assembly

This is a tool battery, so it slots in wherever tools do: register the returned Tool in your ToolRegistry and the model can call it. It pairs naturally with the Retrievable and retrieval seams if you want search results to flow into a RAG pipeline (see the glue above), but on its own it's just a clean web-search verb the model can reach for — configured once, authenticated however your instance demands, shaped however your application wants. See Tools batteries for the rest of the bundled tools, and Bring your own tools for the Tool contract these factories produce.