---
url: 'https://adk.nht.io/batteries/tools/searxng.md'
description: >-
  A configured web-search tool for any SearXNG instance — custom-header auth,
  two-level output-format control, and input/output middleware pipelines. The
  first tool battery that's a factory, not a constant.
---

# SearXNG Search

## LLM summary — SearXNG Search battery

* A factory that mints a configured web-search `Tool` for a SearXNG instance. Entry point: `createSearxngSearchTool(config)` from `@nhtio/adk/batteries/tools/searxng`. **Synchronous**, returns a `Tool` — register it with `new ToolRegistry([tool])`. Unlike every other tool battery (which exports ready-made stateless `Tool` constants) this exports a factory, because the tool needs per-deployment config (instance URL + auth headers).
* Because it exports a factory, NOT a `Tool`, it MUST NOT be bulk-registered via `Object.values(batteries)`. Call the factory first.
* `config.instanceUrl` (required) is the SearXNG base URL; invalid/missing throws `E_INVALID_SEARXNG_CONFIG` (fatal, at factory-call time). The handler always requests `format=json`; SearXNG disables JSON by default, so a misconfigured instance returns 403 — the tool returns an `Error:` string naming `settings.yml`.
* `config.headers` is a static `Record<string,string>` OR a sync/async resolver `() => headers | Promise<headers>`. The resolver runs on every search → use it for refreshable auth tokens. Caller headers override the default `Accept`/`User-Agent`.
* Output shape is configurable at two levels. `config.resultFormat: 'normalized' | 'raw' | 'either'` (default `'either'`). When pinned to `normalized`/`raw`, the model cannot change it AND the `format` arg is removed from the input schema. When `'either'`, the input schema exposes a `format` arg so the model picks per call. `normalized` trims results to `{title,url,content,engine,score,publishedDate}` + non-empty `answers`/`infoboxes`/`suggestions`/`corrections`; `raw` returns the full SearXNG JSON.
* `config.artifactConstructor` (default `() => SpooledJsonArtifact`) is passed straight through to the `Tool` — set `() => SpooledMarkdownArtifact` or `() => SpooledArtifact`. The handler does not switch it at runtime.
* `config.inputPipeline` / `config.outputPipeline` are arrays of onion middleware `(ctx, next)` built on `@nhtio/middleware`. Input stages mutate `ctx.query`/`ctx.params`/`ctx.headers` before the fetch, or call `ctx.shortCircuit(string)` to skip the fetch (e.g. cache hit). Output stages mutate `ctx.results`/`ctx.raw` or set `ctx.output` (verbatim final string, e.g. rendered markdown). A `ctx.stash` Map carries across both. A fresh runner is minted per invocation (middleware runners are single-use).
* Model-facing input args: `query` (required), `categories`, `engines`, `language`, `pageno` (default 1), `time_range` (`day|month|year`), `safesearch` (`0|1|2`), and `format` (only when `resultFormat: 'either'`). Bad args throw `E_INVALID_TOOL_ARGS`; network/HTTP/pipeline failures return graceful `Error:` strings.
* Caveat: `number_of_results` is frequently `0` in SearXNG JSON even when `results` is non-empty — a known upstream quirk (searxng/searxng#2987, #2457), not a tool bug. Passed through verbatim; use `results.length` as the count.

::: tip 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`](https://adk.nht.io/api/@nhtio/adk/batteries/tools/searxng/functions/createSearxngSearchTool) is the whole entry point. It is synchronous, it takes a config, and it returns a `Tool` you register like any other:

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

const searxng = 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.

::: info 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.
:::

## 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
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
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
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
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 = 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`](https://adk.nht.io/api/@nhtio/adk/batteries/tools/searxng/interfaces/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`](https://adk.nht.io/api/@nhtio/adk/batteries/tools/searxng/interfaces/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.

::: info Render markdown by pairing an output stage with a markdown artifact
The tool's spool artifact type is a static factory argument, `artifactConstructor` (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.
:::

```typescript
import { SpooledMarkdownArtifact } from '@nhtio/adk/common'

createSearxngSearchTool({
  instanceUrl: 'https://searx.example.org',
  resultFormat: 'normalized',
  artifactConstructor: () => 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

::: warning 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.

::: warning 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](https://github.com/searxng/searxng/issues/2987) ("JSON `number_of_results` is wrong") and [searxng/searxng#2457](https://github.com/searxng/searxng/issues/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.
:::

## 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](../vector/retrievable) and retrieval seams if you want search results to flow into a RAG pipeline, 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](../../assembly/batteries-tools) for the rest of the bundled tools, and [Bring your own tools](../../assembly/byo-tools) for the `Tool` contract this factory produces.
