README
@tonydua/dsh-web-search-exa
English | 简体中文
Adds Exa web search to DeepSeek Harness (dsh), the anonymous no-key variant.
dsh plugin --profile web add @tonydua/dsh-web-search-exa
Restart dsh web and it works. No API key, no config edits, no provider to select.
Background, for reference only:
- Exa is a search API. It retrieves pages by keyword or by meaning and returns citable sources with excerpts. It does not generate answers. Exa offers a REST API and also runs an unauthenticated public MCP server.
- The official
dsh-web-search-exais dsh's Exa search provider. It uses Exa's REST API and is only useful once you configure an API key. - This package is a modified copy of the official one. The REST path works the same. What we added is a keyless channel: with no key it uses Exa's public MCP server, and with a key it still uses REST. The anonymous approach follows the oh-my-pi project, see Acknowledgements.
You can ignore all of this by default. Read Selecting a provider only if you also run the official package, or if dsh reports an ambiguous provider.
Built with deepseek-v4-flash inside DeepSeek Harness (dsh). The 0.2.3 live-config work was done with GLM-5.3-flash.
Features
- Works without a key. Searches go through Exa's public MCP server (
mcp.exa.ai/mcp) and carry no credentials. - Returns structured results on that keyless path. It calls
web_search_advanced_exa, whose output is JSON in the REST field vocabulary, so sources carry real highlight snippets without text parsing. - Upgrades itself when you add a key. Setting
EXA_API_KEYswitches to Exa'sPOST /searchREST API for higher limits, with no behavior change. - Drop-in. It registers into the dsh
ctx.webseam; the model-facingweb_searchandweb_fetchtools, their prompt sections, and the result cards all stay as they are. - Works out of the box. With no official package installed there is no provider to select.
- Backs off when it fails. After repeated failures on the anonymous channel the plugin marks itself unavailable so dsh can pick another provider, instead of failing every search. See What happens when a search fails.
Installation
Pick one of three. The choice only decides where the code comes from; all three end up the same.
From npm. The dsh.bundle manifest ships the bundle patch, so the provider row is inserted for you and you do not edit any patch by hand.
dsh plugin --profile web add @tonydua/dsh-web-search-exa
From the GitHub Release. The same tarball, for when npm is unreachable.
dsh plugin --profile web add https://github.com/TonyDua/dsh-web-search-exa/releases/latest/download/dsh-web-search-exa.tgz
From the repository. Tracks main, including work not yet released.
dsh plugin --profile web add github:TonyDua/dsh-web-search-exa
For a local development checkout, use the same command with a path instead of a package name: dsh plugin --profile web add ../plugins/dsh-web-search-exa.
Restart dsh web afterwards. That is the whole procedure in most cases.
Selecting a provider
Skip this section unless you install the official package too.
Before each search, the dsh seam picks an available provider. If exactly one is available it is selected automatically. If more than one is available the seam raises WEB_PROVIDER_AMBIGUOUS and asks you to name one. So there are only two cases where you have to act:
- You installed the official package as well. Both packages register the provider id
exa, sodsh webfails at startup withWEB_DUPLICATE_PROVIDER. You must give this package a different id first, see Coexistence with the official package. - You see
WEB_PROVIDER_AMBIGUOUS. Another provider is available. Name the one you want.
Two ways to name it:
# $DSH_HOME/profiles/web/cordis.patch.yml
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: exa
Or set $DSH_WEB_SEARCH_PROVIDER=exa at runtime.
Restart dsh web after the change. The model-facing web_search tool then uses the selected provider; no tool configuration changes.
Release artifacts and install warnings (usually not needed)
Release artifacts. CI packs this version's tarball, verifies it against every supported dsh version, attaches it to the GitHub Release, and publishes that same artifact to npm. So the release asset and the npm tarball are one file, not two builds that happen to match.
Profile install warnings. dsh profiles default to autoInstallPeers: false, and the harness's own services are provided at runtime by the dsh host rather than resolved by pnpm. If dsh plugin add reports peer warnings, add this to the profile's pnpm-workspace.yaml:
peerDependencyRules:
ignoreMissing:
- '@deepseek-ai/cordis'
- '@deepseek-ai/dsh-*'
Configuration
| Key | Default | Meaning |
|---|---|---|
apiKey |
unset | Literal Exa API key. Empty or missing enables the anonymous MCP path. |
apiKeyEnv |
EXA_API_KEY |
Environment variable read when no literal apiKey is set. |
baseURL |
https://api.exa.ai |
Exa API base URL. The keyed REST path appends /search, matching the official dsh provider. |
apiURL |
unset | Deprecated full REST endpoint alias. Takes precedence over baseURL when set. |
mcpURL |
https://mcp.exa.ai/mcp?tools=web_search_exa,web_search_advanced_exa |
Exa hosted MCP endpoint, used by the anonymous path. The tools query is part of the default because the structured tool is not servable without it. Leave it out of your own URL and the plugin adds it for you. |
mcpTool |
web_search_advanced_exa |
Which MCP tool the anonymous path calls: the structured one, or web_search_exa for the older Title:-section text blob. See How it works. |
searchType |
auto |
REST retrieval mode: auto, keyword, or neural. Read on the REST path only. |
numResults |
unset | Default result count when a request carries no maxResults. |
highlightsPerResult |
1 |
Highlight sentences requested per result on the REST path. |
providerId |
exa |
Provider id registered into ctx.web. Change it only when this package and the official one are installed together, see Coexistence with the official package. |
Where to put the config: on dsh 0.2.0 or later use Settings → Plugins, which writes the profile patch for you. On older versions edit this plugin's config in $DSH_HOME/profiles/web/cordis.patch.yml and restart dsh web. A hand edit to that file needs a restart on every version, because dsh watches no patch file. The environment variables EXA_API_KEY and $DSH_WEB_SEARCH_PROVIDER work too. apiKey is marked role('secret'), so no describe() response exposes its value.
In the Web panel
What the Web UI offers depends on the dsh version:
- dsh 0.2.0 and later. Settings → Plugins generates a configuration page for this plugin from its Config schema, and saving applies to the next search without restarting
dsh web— including switching between the anonymous and keyed paths by adding or removing the key. This needs 0.2.3 or later: dsh generates the page only for a plugin that declares at least one volatile Config field, so 0.2.1 and 0.2.2 install on that line and then show no page at all. - dsh 0.1.2 – 0.1.6. The config lives in the profile patch layer. The plugin registers a
web-search-exasection through thectx.settings.installSectionAPI and the data layer accepts writes, but those versions have no generic settings form, so editcordis.patch.ymlor use environment variables. - Plugin inventory (Settings → Plugins): a
web-search-exaentry appears automatically once the plugin is enabled, on every supported version. The inventory reads live entries from the Cordis loader, so no extra code is involved. - Search result cards:
web_searchcalls render the usualwebresult cards throughdsh-tool-web(sources, excerpts, dates), regardless of provider. Anonymous Exa results look identical to DeepSeek search results.
How it works
| Condition | Path | Endpoint |
|---|---|---|
apiKey / EXA_API_KEY configured |
REST POST /search with Authorization: Bearer |
https://api.exa.ai/search (configurable via baseURL) |
| No key configured | Anonymous MCP tools/call web_search_advanced_exa (JSON-RPC 2.0, no credentials) |
https://mcp.exa.ai/mcp?tools=… (configurable) |
The anonymous MCP path sends no credentials; attribution rides the x-exa-source: dsh-anything header. Results are normalized to the seam's WebSearchSource shape (url, title, snippet, publishedAt), and the seam enforces maxResults on the way back.
The anonymous path calls web_search_advanced_exa by default. Its text content is a sanitized JSON search response whose entries use the same field names as the REST API, so a result maps to a source directly and no Title:-section text parsing is involved. Two consequences worth knowing:
- The structured tool is only served when the URL carries
?tools=…. The bare endpoint answersMCP error -32602: Tool web_search_advanced_exa not found, which is why that query is part of the defaultmcpURLand why the plugin splices it into anymcpURLthat lacks it. - It returns whole-page text for every hit, and we ask for highlights separately. Without
enableHighlightsthe endpoint returns text-only entries, every result would lack a snippet, and the search would come back empty. The full text is discarded: a snippet is always a real highlight sentence, never generated and never lifted from the page body.
searchType is not forwarded here. The plugin's setting uses the REST vocabulary (auto, keyword, neural) while the tool accepts its own (auto, fast, instant), so forwarding it would make a configured keyword or neural fail argument validation and take the whole anonymous path down. The tool's default is what auto asks for anyway.
Pinning mcpTool: web_search_exa restores the older text-blob path, where the same results arrive as Title:-led sections. Use it if Exa changes the structured tool's shape; a body that is not the expected JSON already falls back to section parsing on its own.
Because the structured tool returns a whole page of text per hit, a response can be large. Anonymous responses are capped at 256 KiB: the declared content-length is checked before the body is read, and a body that keeps growing is aborted mid-transfer. Over-limit responses fail as transient errors rather than being truncated or silently parsed.
Rate limits
The anonymous channel is a public endpoint run by Exa, and it is rate-limited. When you hit the limit the search fails with the code WEB_RATE_LIMITED and a message telling you to configure EXA_API_KEY. That code is ours, so that you and the model can tell throttling apart from a broken network.
With a key configured, searches use the REST path and are not subject to this limit.
What happens when a search fails
What you will see:
- The anonymous channel is rate-limited: error code
WEB_RATE_LIMITED, telling you to configure a key. - The anonymous channel fails 3 times in a row: this plugin marks itself unavailable for a 5-minute cooldown. During that window
available()returnsfalse. - You pinned
searchProvider: exaand the cooldown is active: the search reportsWEB_PROVIDER_CONFIGURED_UNAVAILABLE. - You did not pin
searchProviderand the cooldown is active: the seam skips this plugin and looks for another provider. With no other provider available it reportsWEB_PROVIDER_UNAVAILABLE. - You configured a key, so searches use REST: none of the above applies, and failures surface as usual.
Why it works this way. Before each search the seam calls available() to decide which provider to use. If this plugin always answered "available", a dead endpoint would make every search fail hard, and what the user sees is a broken dsh. So the plugin adds a circuit breaker: after 3 consecutive transient failures it admits it is temporarily unavailable, giving the seam a chance to choose someone else. That breaker is this plugin's design; Exa has no such mechanism.
How failures are counted. Only failures that a retry could fix: 5xx, 429, network errors, and unparseable response bodies. Three of them start a 5-minute cooldown, and any successful search clears the count immediately.
A 4xx other than 429 does not count. That is a configuration error and would fail identically on every retry, so hiding it behind a cooldown would only delay the same error by 5 minutes.
This trade-off has a cost. While Exa is down for those 5 minutes, a profile with a pinned searchProvider: exa reports an error instead of trying something else. The plugin cannot choose for you:
- Pinning
searchProvider: exa: predictable behavior normally, but no fallback once the breaker opens. - Leaving
searchProviderunset: it can fall back when the breaker opens, at the cost that the seam raisesWEB_PROVIDER_AMBIGUOUSwhenever several providers are usable, and you have to name one.
Choose the second if you want fallback, and install only one alternative provider.
Compared with the official package
DeepSeek Harness has an official Exa provider, @deepseek-ai/dsh-web-search-exa, which you install separately; dsh does not include it by default. This package is its zero-config variant: it adds the anonymous MCP fallback the official one lacks and keeps the same REST behavior once you configure a key.
Official @deepseek-ai/dsh-web-search-exa |
This package @tonydua/dsh-web-search-exa |
|
|---|---|---|
REST path (POST /search) |
✅ the only path | ✅ used when a key is configured |
| Requires an API key | ✅ yes, an empty key makes it unavailable | ❌ no, with no key it uses the anonymous MCP fallback |
Anonymous MCP (mcp.exa.ai/mcp) |
❌ not implemented | ✅ the default path with no key |
| Zero-config install | ❌ | ✅ |
| Provider id | exa (fixed) |
exa by default, configurable via providerId |
| Cordis plugin name | web-search-exa |
web-search-exa |
| Config keys | apiKey, baseURL, searchType, numResults, highlightsPerResult |
apiKey, apiKeyEnv, baseURL, apiURL (legacy), mcpURL, mcpTool, searchType, numResults, highlightsPerResult, providerId |
Which to use:
- You have an
EXA_API_KEYand want the officially maintained package: use the official one, it is the canonical implementation. - You want to try Exa search with no configuration, no key, and no cost commitment: use this package. It defaults to the anonymous MCP path and switches to REST once a key appears.
- You want both: install both and separate them with
providerId, see the next section.
Coexistence with the official package
Both packages register the same provider id (exa) under ctx.web, and both use the cordis plugin name web-search-exa. The seam rejects duplicate ids with WEB_DUPLICATE_PROVIDER, so installing both into one profile without changing the config fails at startup.
Coexistence requires explicit configuration through the providerId switch:
- The official package keeps
exa; its id is fixed. - Give this package a different id. Set
providerId: exa-anonin this plugin'sconfig; any unique string works. - Name one of them on the
webseam. UsesearchProvider: exa-anonfor the anonymous variant, orsearchProvider: exafor the official package.$DSH_WEB_SEARCH_PROVIDERworks too.
- insert:
- id: web-search-exa
name: '@tonydua/dsh-web-search-exa'
config:
providerId: exa-anon
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: exa-anon
The simplest alternative is to install only one of the two packages per profile, which works with the default config.
Troubleshooting
After upgrading dsh to 0.2.0 or later, startup reports Plugin @tonydua/dsh-web-search-exa@0.1.x is incompatible with dsh 0.2.0-rc.x ... skipping profile bundle. Plugin versions before 0.2.1 declare peer ranges that end at >=0.1.8, which cannot match a dsh 0.2.x prerelease, and dsh refuses to load them rather than risk a crash — web search then runs without this plugin. Upgrade the plugin and restart:
dsh plugin --profile web add @tonydua/dsh-web-search-exa
(web is your profile name.) From 0.2.3 the plugin also gets its configuration page back on 0.2.0 and later, see In the Web panel.
dsh web fails at startup with duplicate loader entry id: web. This is a 0.1.2 bug, fixed in 0.1.4, so upgrading the plugin resolves it. If you already run 0.1.4 or later, please open an issue with dsh --version and your cordis.patch.yml, because a user patch that inserts a web row produces the same error.
Startup fails with Cannot read properties of undefined (reading 'prepare'). @deepseek-ai/dsh-tools is a dsh runtime singleton and must resolve to one physical package instance per profile. This plugin does not depend on it. The usual cause is another third-party plugin in the profile declaring it as an ordinary nested dependency rather than a peer dependency. Fix that plugin's dependency declaration, or have the profile's package manager resolve one shared instance, and only then investigate search errors.
A search reports WEB_PROVIDER_AMBIGUOUS. More than one provider is available. Name one explicitly as described in Selecting a provider.
A search reports WEB_PROVIDER_CONFIGURED_UNAVAILABLE. The provider you pinned is currently unavailable. This happens when the keyless channel's breaker is open, see What happens when a search fails.
The plugin config form is missing in the Web UI. The native configuration editor exists on dsh 0.2.0 and later; on 0.1.2 – 0.1.6 configure through cordis.patch.yml or environment variables, see In the Web panel.
Version compatibility
Every published dsh version from 0.1.2-alpha.2 to 0.2.1-alpha.1 has been tested. Testing means three things: installing that version in isolation, typechecking against its own declarations, and installing this plugin with npm under strict peer resolution. The last step is the one that fails most easily, because npm's peer rules are stricter than pnpm's. To reproduce: bash scripts/compat-matrix.sh.
| dsh line | Tested | Notes |
|---|---|---|
0.1.2-alpha.2 … 0.1.2-alpha.5 |
✅ | the oldest supported baseline |
0.1.2-rc.1 |
✅ | |
0.1.3-alpha.2 |
✅ | |
0.1.5-alpha.1, 0.1.5-alpha.2 |
✅ | |
0.1.5-rc.1, 0.1.5-rc.2, 0.1.5-rc.3 |
✅ | 0.1.5-rc.2 is also verified end to end: a real dsh --profile headless task searched through the anonymous MCP path with no API key |
0.1.6-alpha.1, 0.1.6-alpha.2 |
✅ | |
0.1.7-alpha.1 |
✅ | the settings service changed shape, see below |
0.2.0-rc.1, 0.2.0-rc.2 |
✅ | strict npm install beside the host, then a live keyless search |
0.2.1-alpha.1 |
✅ | same, and the only version that needs @deepseek-ai/cordis@4.0.5-alpha.1 |
Versions from the 0.2.0-rc.1 line onward are additionally declared, one full version at a time, under dsh.compatibility.dshReleases in package.json. That key is catalog metadata: the dsh runtime never reads it, and it does not affect resolution — registries such as DSH STORE require an exact per-version record, and a peer range is not installable evidence. The declarations cover 0.2.0-rc.1, 0.2.0-rc.2, and 0.2.1-alpha.1, all compatible.
What differs across versions
Probing the real export surface of every version, the ctx.web seam turns out to be completely stable: WebError is always exported from dsh-web and extends HarnessError, launchEnvironmentOf is always present, and ctx.settings is mounted in every version. Three things differ.
First, 0.1.7-alpha.1 replaced the settings API. SettingsProvider.installSection is gone, and the service became SettingsForms, which derives a config page from the Config schema the Loader already holds (SettingsDescriptor.schema, autoGenerate). Code that called that method unconditionally throws a TypeError there: the plugin loads but fails. It now probes for the method, calls it only when present, and does nothing otherwise. On 0.1.7+ the Loader's schema drives the form and the plugin has nothing to register.
Second, from 0.2.0-rc.1 the loader can update plugin config live, and dsh-settings generates a plugin's configuration page only when its Config schema declares at least one volatile field. A field declared .volatile() (schemastery >= 3.18.4) reaches apply as a live reference, and the loader commits a saved edit into that reference in place. This plugin declares every field volatile, so on 0.2.0+ the page exists and a save in Settings applies to the next search without restarting. On 0.2.1 and 0.2.2, which declared none, describe() dropped the entry and the page was absent. The read side probes structurally: a plain value passes through, a reference is dereferenced, and older hosts keep working unchanged.
Third, the @deepseek-ai/cordis line moves with dsh, and it moves through pre-releases: 0.1.5/0.1.6 peer it at ^4.0.2 (exactly 4.0.2 for 0.1.5-rc.3), 0.1.7 at ^4.0.3, 0.2.0 at ~4.0.4, and 0.2.1-alpha.1 at ~4.0.5-alpha.1. Install the version the host asks for, and be careful how you pin it: ^4.0.2 resolves to 4.0.4, which the 0.1.5 releases were not published against, and this plugin then fails to install strictly beside them. The matrix script reads that range and pins its floor as a concrete version. This plugin's own peer range also needed a >=4.0.5-alpha.1 comparator, without which 0.2.1-alpha.1 would not install at all.
Also supported across that whole range: @deepseek-ai/dsh-web, dsh-settings (optional), and dsh-launch-environment. Node.js needs >=22.19.0, matching the harness's own floor.
Why the peer range looks like that
"@deepseek-ai/dsh-web": ">=0.1.2-alpha.2 || >=0.1.3-alpha.2 || >=0.1.4-0 || >=0.1.5-alpha.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.1.8 || >=0.2.0-rc.1 || >=0.2.1-alpha.1"
That enumeration is the only form that installs across every published version under both pnpm and npm. The reason is one semver rule:
A prerelease version satisfies a range only if some comparator in that range carries a prerelease on the same
major.minor.patchtriple.
So >=0.1.2-rc.1 does not match 0.1.5-rc.2; the triples differ. A single open-ended lower bound cannot cover a project published as a series of prereleases, and * would also admit a future breaking 1.0.
Two things about the shape are easy to get wrong, and both were:
更多「網頁與瀏覽器」外掛
last30days-skill-cn
作者 jesseovo
給 AI Agent 的中文網際網路研究 Skill:檢索 8 大平臺最近 30 天的討論,追蹤跨平臺熱點,生成附原始來源的報告,並提供每日全網熱榜。支援 Claude Code、Codex、OpenClaw。
browser4
作者 platonai
面向自主智慧體與大規模網頁自動化的 AI 原生瀏覽器引擎,支援智慧內容抽取。
modsearch
作者 liustack
🥇 The strongest free web search plugin for DeepSeek Harness, and the search bridge for every model without native web access. Free, no signup, no API key. Ask the web or X, get structured JSON evidence. | 🥇 全網最強的 DeepSeek Harness 免費聯網搜尋外掛,免費免註冊免 API key。為不能聯網的模型補上搜索,問網頁或 X,拿回結構化 JSON 證據(搜尋、抓取、引用)。
anysearch-dsh
作者 anysearch-team
為 DeepSeek Harness 提供的 AnySearch 網頁搜尋提供方與高階搜尋工具。
