# searxng-search
An OpenCode V2 plugin that adds a `searxng_search` tool backed by a
self-hosted SearXNG instance.
It does **not** replace OpenCode's built-in `websearch` provider. It registers
one extra, opt-in tool, so nothing existing changes behaviour. Replacing a
built-in provider means taking over a key the user chose; adding a tool does
not.
- OpenCode V2 plugin docs:
- OpenCode plugin config:
- SearXNG search API:
- How the paired instance was deployed: [`SEARXNG-SETUP.md`](SEARXNG-SETUP.md)
## Requirements
1. A reachable SearXNG instance with `json` in `search.formats`
(disabled upstream by default). See `SEARXNG-SETUP.md`.
2. `@opencode/plugin` installed at **your exact OpenCode version**:
```sh
opencode --version # e.g. v2.0.24
cd ~/.config/opencode
npm i @opencode/plugin@2.0.24 # must match
```
3. A service restart. The running service caches module resolution, so a
freshly installed package is invisible until then:
```sh
opencode service restart
```
Check the log if the tool does not appear:
```sh
grep -i "loading plugin" ~/.local/share/opencode/log/opencode.log | tail
```
A line like this is good:
```
loading plugin id=/home/you/.config/opencode/plugins/searxng-search
```
A `Cannot find package '@opencode/plugin'` error means the package is
**missing**, not that the docs are wrong. See the stale-package trap in
`../../skills/opencode-plugins/SKILL.md`.
## How it is loaded
By directory discovery, not by config. OpenCode loads every immediate
subdirectory of `~/.config/opencode/plugins/` as a plugin package and resolves
an entrypoint inside it. This directory has no `package.json`, so the entrypoint
falls back to `index.ts`:
```
loading plugin id=/home/you/.config/opencode/plugins/searxng-search
entrypoint=file:///.../plugins/searxng-search/index.ts
```
Sibling files such as this `README.md` do not affect that resolution — verified
by restarting the service with the docs in place. There is deliberately nothing
to add to `opencode.jsonc`.
That has a consequence worth knowing before you try to configure it:
> **`ctx.options` is always `{}` for a discovered plugin.**
Options reach a plugin only through an object entry in the `plugins` array in
`opencode.json(c)`, which is keyed by package or path. A directory that was
found by scanning has no such entry, so it receives no options at all. This was
verified by writing `JSON.stringify(ctx.options)` to a file from inside
`setup()` — the file contained `{}`.
The `baseUrl` / `engines` / `timeoutMs` / `maxSnippetChars` options in
`PluginConfig` are therefore **inert** as shipped. Two working alternatives:
| Approach | How |
| --- | --- |
| Environment variable (easiest) | `export SEARXNG_URL=http://host:39217` before starting OpenCode |
| Register explicitly | Move this directory out of `plugins/`, then add a `{"package": "", "options": {...}}` entry to `opencode.jsonc` |
To keep the `engines` default editable without moving anything, change
`DEFAULT_ENGINES` in `index.ts` and push — see "Push every change" in
`../../skills/opencode-plugins/SKILL.md`.
If you do register it explicitly, remove the directory from `plugins/`
first. Otherwise the plugin loads twice: once by discovery, once by config,
and the tool is registered against the same name twice.
## The tool
Effective name: **`searxng_search`** (tool name + `_` + namespace).
| Argument | Type | Notes |
| --- | --- | --- |
| `query` | string, required | The search query. |
| `engines` | string | Comma-separated. Omit for the instance default. |
| `category` | string | Only sent when you pass it — see the additive trap below. |
| `language` | string | e.g. `zh-CN`, `en-US`. Omit for the instance default. |
| `safesearch` | 0 \| 1 \| 2 | Omit for the instance setting. |
| `pageno` | number | From 1. Engines without paging are dropped above page 1. |
| `time_range` | string | `day`, `week`, `month`, `year`. Engines without it are dropped entirely. |
At `setup()` the plugin reads the instance's `/config` and bakes what the
instance actually supports into each argument's `description`, so the model is
told which engines lose paging or time filtering instead of discovering it by
returning fewer results.
### Two SearXNG behaviours that silently mislead
Both are handled, both are worth understanding because they fail silently.
**1. `categories` is additive, not a filter.** If you pass both `engines` and
`category`, SearXNG takes the union: the named engines *plus* every engine
enabled by default in that category. A careful `engines=bing,yandex` becomes
`bing,yandex` + whatever the category contributes. The plugin therefore
sends `categories` only when the caller explicitly asked for a category, never
alongside an engine list it chose itself.
**2. An unknown engine name falls back instead of erroring.** If `engines=`
names something the active engine list does not contain, SearXNG ignores the
name and searches with the **default engine set**. No error, no
`unresponsive_engines` entry, and results that look like a success. Verified on
this instance:
```
engines=bing -> bing x10
engines=360search -> 360search x7
engines=baidu -> 0 results, unresponsive_engines empty
engines=mojeek -> bing x10 <-- fell back, mojeek is not active
engines=duckduckgo -> bing x10 <-- fell back
engines=nosuchengine -> bing x10 <-- typo: also fell back, no warning
```
Three signatures, and only the third means the engine was really used:
| Observation | Meaning |
| --- | --- |
| 0 results **and** empty `unresponsive_engines` | Engine is disabled or not in the active list |
| Results, but none of them from the requested name | Fell back to the default engine set |
| Results including the requested name | Genuinely used |
`/config` is **not** a reliable way to check this. On this instance it reports
`disabled: false` for every engine while `baidu` and `sogou` are in fact being
dropped, because the listing reflects upstream defaults rather than the
`disabled: true` overrides in `settings.yml`. Diagnose with the table above.
## Measured state of the paired instance
SearXNG 2026.10.7-6671d89be, `docker.io/searxng/searxng:latest` (built
2026-10-07), `http://127.0.0.1:39217`.
| Engine | Status |
| --- | --- |
| `bing` | Active and reliable, 5/5 non-empty runs on both query types. Needs `base_url: https://cn.bing.com`; `www.bing.com` blocks scraping from this network. Default. |
| `yandex` | Active and reliable, 5/5, 15 results per run. Best measured relevance here; first result for a V1→V2 migration query is the migration doc itself. Default. |
| `naver` | Reliable, 5/5. Strongest on English (migration doc ranked #1) but 0/9 on a Chinese query, so it is left out of the defaults and worth passing explicitly for English lookups. |
| `360search` | Reliable but returns 4–5 results at 0% relevance. It splits the query on "V1"/"V2" and answers with Android and CANN version-difference articles. Removed from the defaults. |
| `quark` | Unusable. Returned 11/11 relevant results twice, then 0 results on 5 consecutive runs of each query. Rate-limited after a couple of requests. |
| `baidu` | Disabled. Answers with a CAPTCHA and suspends the client IP for an hour. |
| `sogou` | Disabled. The upstream engine raises `AttributeError` at `searx/engines/sogou.py:56` (`resp.next_request`) in this release. |
`bing` supports neither paging nor `time_range`, so on this instance `pageno > 1`
and `time_range` both narrow the result to nothing useful. The tool says so in
the argument descriptions rather than returning a silently short list.
### There is no working domestic-only engine set
Stated plainly because it was measured, not assumed. Every general-web index
SearXNG ships for China was added to `keep_only`, the container was restarted,
and each was measured over 5 runs per query:
| Engine | en hits | zh hits | Outcome |
| --- | --- | --- | --- |
| `360search` | 5/5 | 5/5 | Reliable, 0% relevant |
| `quark` | 0/5 | 0/5 | Rate-limited dead after two good responses |
| `baidu` | — | 0 | CAPTCHA, 1-hour IP suspension |
| `sogou` | — | 0 | Upstream `AttributeError` |
| `baidu kaifa` | — | 0/10 | A **code** index: matches "opencode v2" as OpenCV2 and returns CSDN blog posts |
| `bilibili` | — | — | Video titles, not a web index |
| `chinaso news` | — | — | Falls through to the default engine set |
| `brave`, `duckduckgo`, `google`, `qwant`, `wikipedia` | 0 | 0 | Unreachable from this network |
| `mojeek`, `startpage` | — | — | Not present in this SearXNG release |
`quark` is the trap. On its first two requests it returned the exact article
being asked about as result #1 on both queries — better than anything else
measured here — and then went permanently dark. Do not reinstate it on the
strength of a first impression.
`cn.bing.com` is the only dependable endpoint, and it is a China-hosted Bing,
so `bing` serves as the domestic option. Its one real limitation is that it
ignores the `site:` operator completely: `site:opencode.ai v2 migration` returns
0 results from opencode.ai, while the same query without `site:` returns 3.
Site-restricted search is not available through this instance — fetch the docs
index and follow links instead.
## Troubleshooting
| Symptom | Check |
| --- | --- |
| Tool absent from the catalog | `grep -i "loading plugin" ~/.local/share/opencode/log/opencode.log \| tail` |
| `Cannot find package '@opencode/plugin'` | Package missing or wrong version. Match `opencode --version` exactly. |
| Installed it but still missing | `opencode service restart` — the service caches module resolution. |
| `fetch failed` / no results at all | `curl -s "http://127.0.0.1:39217/config" \| head -c 200` — is it up? |
| JSON format rejected | `json` must be in `search.formats` in `settings.yml`. |
| Results from an unexpected engine | The fallback in the table above. Check which engines actually appear. |
| Everything works, then stops after an edit | The running service loaded the old file. Restart it. |