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 is synchronous, it takes a config, and it returns a Tool you register like any other:
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.
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:
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:
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:
createSearxngSearchTool({ instanceUrl, resultFormat: 'normalized' }) // model has no sayLeave it neutral (the default, 'either') and the model chooses per call via a format argument on the tool:
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.
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) 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 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.
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
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.
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, 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 this factory produces.