Your agent can call APIs, read files, and refactor a module without breaking a sweat. Ask it about a release from last month and it answers from memory, with the wrong function signature and total confidence.
A search tool fixes that. Most people reach for a hosted search API: sign up, copy a key, pay per query. That works until a multi-agent loop fires dozens of searches per task, or the key hits its rate limit in the middle of a demo.
SearXNG is the self-hosted option. It is a metasearch front end: your query goes out to Google, Bing, DuckDuckGo, Brave, and a long tail of smaller engines, then comes back merged, deduplicated, and ranked as JSON. No key, no per-query billing, and the query never leaves infrastructure you control.
It is not a search API with extras. SearXNG does not crawl pages, does not write summaries, and does not clean snippets the way Tavily does. You get titles, URLs, snippets, and engine metadata. Turning that into an answer is the model's job.
What you need
- Docker Engine with the compose plugin (check with
docker compose version) - Python 3.10+ for the client code
httpxandopenaiinstalled- Ten minutes
Step 1: bring up the official compose stack
SearXNG ships a compose template with two services: core for the app and valkey, which you only need once the limiter is on.
mkdir -p ./searxng/core-config && cd ./searxng
curl -fsSL -O https://raw.githubusercontent.com/searxng/searxng/master/container/docker-compose.yml
curl -fsSL -O https://raw.githubusercontent.com/searxng/searxng/master/container/.env.example
cp .env.example .env
Edit .env so the port binds to loopback only:
SEARXNG_HOST=127.0.0.1
SEARXNG_PORT=8080
Start it once. On first boot the container copies its settings template into core-config/ and generates a random secret_key for you:
docker compose up -d
docker compose logs --tail 10 core
Open core-config/settings.yml. It starts with use_default_settings: true, which means the file only holds overrides and everything else comes from the image. Append the JSON format:
search:
formats:
- html
- json
Restart the app so it reads the new file:
docker compose restart core
JSON output is disabled by default in SearXNG. Skip this step and every API request returns 403 instead of an empty result, which sends people hunting for a bug in their client.
Step 2: check the JSON endpoint
curl -s "http://127.0.0.1:8080/search?q=docker+multi-stage+build&format=json" | jq '.results[0] | {title, url, engine}'
On a fresh instance that returns something like this:
{
"title": "Multi-stage builds | Docker Docs",
"url": "https://docs.docker.com/build/building/multi-stage/",
"engine": "brave"
}
The response carries results, answers, infoboxes, suggestions, corrections, and unresponsive_engines. There is no total result count field on the current image, so len(data["results"]) is your count. Each result has url, title, content (the snippet), engine, engines (every engine that returned that URL), score, category, and sometimes publishedDate.
A 403 here means one thing: the format is not enabled. Check search.formats before anything else.
Step 3: the parameters that change result quality
q is the only required parameter. Four others do most of the work for an agent:
categories: comma separated, for examplegeneral,news,it,science. Theitbucket is the useful one for developer questions: docs, Q&A, package pages.language:en,id, and other locale codes. Set it explicitly, otherwise the instance default decides for you.time_range:day,month, oryear. Engines that do not support it ignore it silently.pageno: page number when you want more than the first screen of results.
format=json has to be on every request. Any format you have not enabled returns 403, which is the documented behavior, not a bug.
SearXNG merges duplicate URLs across engines and ranks the merged set. The engines array is a cheap relevance signal: a docs page returned by three engines usually beats a forum post returned by one.
Step 4: a client the agent can actually call
The important bits: a short timeout, errors returned as data instead of raised exceptions, deduplication by URL, and a snippet cap so one long page cannot eat the context window.
import os
import httpx
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://127.0.0.1:8080")
def web_search(query: str, categories: str = "general", language: str = "en",
time_range: str | None = None, limit: int = 8) -> list[dict]:
params = {
"q": query,
"format": "json",
"categories": categories,
"language": language,
"safesearch": 0,
}
if time_range:
params["time_range"] = time_range
try:
resp = httpx.get(f"{SEARXNG_URL}/search", params=params, timeout=10.0)
except httpx.RequestError as exc:
return [{"error": f"search backend unreachable: {exc}"}]
if resp.status_code == 403:
return [{"error": "JSON format disabled: add json to search.formats"}]
if resp.status_code == 429:
return [{"error": "rate limited by the SearXNG limiter"}]
resp.raise_for_status()
seen, out = set(), []
for item in resp.json().get("results", []):
url = item.get("url")
if not url or url in seen:
continue
seen.add(url)
out.append({
"title": item.get("title", "").strip(),
"url": url,
"snippet": (item.get("content") or "").strip()[:400],
"engine": ",".join(item.get("engines") or [item.get("engine", "")]),
"published": item.get("publishedDate"),
})
if len(out) >= limit:
break
return out
Run it directly to confirm the whole path works before an LLM is involved:
SEARXNG_URL=http://127.0.0.1:8080 python -c "from search_client import web_search; print(web_search('kubernetes pod disruption budget', limit=3))"
Returning errors as dicts looks sloppy, but it keeps the agent in the loop. A model that receives {"error": "JSON format disabled"} can retry with different arguments or tell the user; a stack trace just ends the run.
Step 5: expose it as a tool
Any OpenAI-compatible client works here, including a local model behind Ollama or vLLM. The tool schema stays small on purpose, because every extra field is another way for the model to get the call wrong.
import json
import os
from openai import OpenAI
from search_client import web_search
client = OpenAI()
tools = [{
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web. Returns titles, URLs and snippets.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"categories": {"type": "string", "description": "general, news, it, science"},
"time_range": {"type": "string", "enum": ["day", "month", "year"]},
},
"required": ["query"],
},
},
}]
model = os.environ.get("MODEL", "gpt-4.1-mini")
messages = [{"role": "user", "content": "What changed in Kubernetes 1.34 about sidecar containers?"}]
resp = client.chat.completions.create(model=model, messages=messages, tools=tools)
msg = resp.choices[0].message
if msg.tool_calls:
messages.append(msg)
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(web_search(**args)),
})
resp = client.chat.completions.create(model=model, messages=messages, tools=tools)
print(resp.choices[0].message.content)
Once search works, the next natural tool is fetch: take the top URL from the results, pull the page, strip the HTML, and let the model quote from the source instead of the snippet.
Step 6: keep it running
SearXNG has no authentication. Keep it on loopback or a private network, and if it has to be reachable from outside, put a reverse proxy with auth in front of it.
If you expose it publicly, enable the limiter. It needs Valkey, which the compose file already starts:
server:
limiter: true
valkey:
url: valkey://searxng-valkey:6379/0
Behind a reverse proxy, the limiter reads the client IP from X-Forwarded-For and X-Real-IP. If the proxy does not set them, every request looks like it comes from one address and your own agent gets throttled.
Engines break. Google serves CAPTCHAs, Bing rate limits, DuckDuckGo goes quiet. SearXNG suspends an engine after a failure for a while, by default a day for access denied and CAPTCHA errors and an hour for too many requests. You can see it per request in unresponsive_engines, and tune it under search.suspended_times.
If one engine keeps failing, disable it. Fewer engines means fewer bans and faster responses:
engines:
- name: google
disabled: true
Update with the standard two commands, after checking the compose template for changes:
docker compose pull
docker compose up -d
When to use this, when to pay for search
A hosted API such as Tavily, Brave, Exa, or Serper gives you managed uptime, cleaner snippets, and extra endpoints like page extraction or answer generation. For a prototype, or a workload measured in a few thousand queries a month, the subscription is cheaper than the afternoon you spend on engine tuning.
Scraping DuckDuckGo's HTML endpoint yourself is the zero-infrastructure option, and it breaks silently the moment the markup changes. One engine, no deduplication, and no fallback when it decides you are a bot.
SearXNG wins when search volume is high or unpredictable, when queries should not leave your network, and when you want several engines behind one endpoint. The trade is explicit: you own uptime, and engines will break on you.
Errors you will probably hit
403onformat=json:jsonis missing fromsearch.formats. Restartcoreafter editing.- HTTP 200 with zero results: the query is too narrow, or
time_rangeis too tight, or the language filter excludes everything. Widen one of the three. unresponsive_engineskeeps growing: engines are banning the instance IP. Checkdocker compose logs core | grep -i captcha, cut the number of categories, and wait out the suspension.- Agent inside another container cannot reach
127.0.0.1:8080: put both on one compose network and usehttp://core:8080. - Settings edits do nothing: the file must be in the mounted
core-config/directory, and the service needs a restart. A YAML error shows up in the logs and stops the container. - The limiter blocks your own agent: it works per client IP. Keep it off on a private network, or configure
trusted_proxiesinlimiter.toml.
Wrap up
Two commands cover most of the setup: docker compose up -d and a curl with format=json. Everything else is tuning around those two.
The upgrade that pays off next is a fetch tool, because snippets are enough to pick a URL and not enough to quote it accurately.
References
- SearXNG container installation: https://docs.searxng.org/admin/installation-docker.html
- Search API parameters and formats: https://docs.searxng.org/dev/search_api.html
search:settings reference: https://docs.searxng.org/admin/settings/settings_search.html- Limiter and bot detection: https://docs.searxng.org/admin/searx.limiter.html