@tonydua/dsh-web-search-exa
Manifest validZero-config Exa web search provider for DeepSeek Harness (dsh): keyless anonymous MCP fallback (mcp.exa.ai/mcp) plus keyed REST search — a drop-in WebSearchProvider for the ctx.web seam, no API key required.
@tonydua/dsh-web-search-exa
English | 简体中文
Zero-config Exa web search for DeepSeek Harness (dsh): no API key required — a
WebSearchProviderfor thectx.webseam with an anonymous MCP fallback plus a keyed REST path.
Built with deepseek-v4-flash inside DeepSeek Harness (dsh).
Supported versions
Every published dsh version from 0.1.2-alpha.2 to 0.1.7-alpha.1 is
verified, not merely declared: each one is installed in isolation, the plugin
is typechecked against that version's own declarations, and the test suite runs
against it. Reproduce with bash scripts/compat-matrix.sh.
| dsh line | Verified | Notes |
|---|---|---|
0.1.2-alpha.2 … 0.1.2-alpha.5 | ✅ | oldest supported |
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 also verified end to end: a real dsh --profile headless task searched through the anonymous MCP path with no API key present |
0.1.6-alpha.1, 0.1.6-alpha.2 | ✅ | |
0.1.7-alpha.1 | ✅ | settings service changed shape — see below |
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"
That enumeration is not decoration — it is the only form that installs on every published version under both pnpm and npm. The rule that forces it:
A pre-release version satisfies a range only if some comparator in that range carries a pre-release on the same
major.minor.patchtuple.
So >=0.1.2-rc.1 does not match 0.1.5-rc.2 — the tuples differ. A single
open-ended lower bound therefore cannot cover a project published as a series of
prereleases, and * would accept even a breaking 1.0. Each 0.1.x line that
ever shipped a prerelease needs its own comparator; >=0.1.8 then carries every
future stable release, so the list only needs a new entry when dsh opens a new
0.1.x prerelease line.
Measured, on the real published tarball:
| range | npm installs | pnpm |
|---|---|---|
>=0.1.2-rc.1 (the earlier attempt) | 1 / 14 versions | 14 / 14 |
| enumerated (current) | 14 / 14 versions | 14 / 14 |
This was found by testing rather than reasoning: the open-ended range is fine on
pnpm, which is what dsh plugin add uses, and fails on npm for 13 of the 14
versions with ERESOLVE. If you install with npm and hit that on an older
release of this package, either upgrade, or pass --legacy-peer-deps.
What differs across versions
Auditing the real export surfaces of all 14 versions found the ctx.web seam
completely stable — WebError is exported from dsh-web and still extends
HarnessError, launchEnvironmentOf is present, and the settings service is
mounted at ctx.settings in every version. Two things do differ:
0.1.7-alpha.1replaced the settings API.SettingsProvider.installSectionis gone; the service is nowSettingsForms, which derives a configuration page from the Config schema the Loader already holds for the entry (SettingsDescriptor.schema,autoGenerate). Calling the old method unconditionally threw aTypeErroron that host, so the plugin loaded but failed. It now probes for the method, calls it only when present, and otherwise does nothing — on0.1.7+the Loader's schema is what feeds the form, so there is nothing to register.0.1.7-alpha.1peers@deepseek-ai/cordis^4.0.3while the cordislatestdist-tag still points at4.0.2.4.0.3is published; the tag is simply behind. Install@deepseek-ai/cordis@4.0.3alongside a0.1.7host. The matrix script pins this per version.
Also supported with: @deepseek-ai/dsh-web, dsh-settings (optional),
dsh-launch-environment across that whole range, and Node.js >=22.19.0 (the
harness's own floor).
Profile-install note
dsh profiles set autoInstallPeers: false, and the harness's own services are
supplied at runtime by the dsh host instead of being resolved by pnpm. Add this
to the profile's pnpm-workspace.yaml so dsh plugin add stays warning-free:
peerDependencyRules:
ignoreMissing:
- '@deepseek-ai/cordis'
- '@deepseek-ai/dsh-*'
Degradation and failover
The keyless channel is a shared, best-effort endpoint. The provider reports its own health rather than pretending to always work:
- 3 consecutive transient failures (5xx, 429, network, unparseable body) open a
circuit breaker for 5 minutes, during which
available()returnsfalse. One successful search closes it again. - A 4xx other than 429 does not trip it — that failure would repeat forever, so hiding it would only delay the same error.
- Anonymous 429s raise
WEB_RATE_LIMITED(not a genericWEB_PROVIDER_ERROR) with a message namingEXA_API_KEY. - The keyed REST path ignores the breaker: a paid endpoint's failures are yours to see.
Whether that turns into automatic failover is a harness-side decision. The
seam picks exactly one usable provider and has no priority chain — with two
usable providers it raises WEB_PROVIDER_AMBIGUOUS. So:
- Pinning
searchProvider: exagives deterministic selection but no fallback: when the breaker opens you getWEB_PROVIDER_CONFIGURED_UNAVAILABLE. - Leaving
searchProviderunset gives up determinism: a degraded Exa stops being a candidate, but if another provider (saydeepseek-officialwith a validDEEPSEEK_API_KEY) is also usable, the seam reports ambiguity instead of choosing it.
Pick whichever failure mode you prefer; the plugin cannot choose for you.
Building from source
pnpm install
pnpm run build # tsdown -> lib/index.js + lib/index.d.ts
pnpm run typecheck # tsc --noEmit
pnpm test # builds, then runs the node:test suite against lib/
src/ is the source of truth; lib/ is committed because both the published
tarball and git-based installs consume it.
Features
- 🆓 Zero-config, keyless by default — searches route through Exa's hosted MCP
server (
mcp.exa.ai/mcp) with no credentials at all (Exa's documented unauthenticated public MCP, rate-limited). - 🔑 Keyed REST upgrade — set
EXA_API_KEYand it automatically switches to Exa'sPOST /searchREST API (higher limits, no behavior change). - 🔌 Drop-in provider — registers into the dsh
ctx.webseam; the existing model-facingweb_search/web_fetchtools, prompt sections, and result cards work unchanged. - 🎛️
providerIdswitch — can coexist with the official@deepseek-ai/dsh-web-search-exapackage in one profile (no duplicate-id collisions, no silent overrides). - 📦 npm-publishable — MIT, ESM, bundled types,
fileslimited tolib/.
Why this package exists (vs. the official one)
The DeepSeek Harness ships an official Exa provider,
@deepseek-ai/dsh-web-search-exa.
This package is its zero-config variant: it adds the anonymous MCP fallback
the official one does not have, and keeps the same keyed REST behavior.
Official @deepseek-ai/dsh-web-search-exa | This package @tonydua/dsh-web-search-exa | |
|---|---|---|
REST path (POST /search) | ✅ only path | ✅ used when a key is configured |
| Requires an API key | ✅ yes — empty key makes it unavailable | ❌ no — keyless anonymous MCP fallback |
Anonymous MCP (mcp.exa.ai/mcp) | ❌ not implemented | ✅ default when 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, searchType, numResults, highlightsPerResult, providerId |
Which one should I use?
- You have an
EXA_API_KEYand want the officially maintained package → use@deepseek-ai/dsh-web-search-exa. It is the canonical implementation. - You want to try Exa search with zero setup, no key, no cost commitment → use this package. It degrades gracefully: anonymous MCP by default, REST automatically when a key appears.
- You want both → install both and use the
providerIdswitch (see Coexistence).
How it works
| Condition | Path | Endpoint |
|---|---|---|
apiKey / EXA_API_KEY set | REST POST /search with Authorization: Bearer | https://api.exa.ai/search (baseURL configurable) |
| No key configured | Anonymous MCP tools/call web_search_exa (JSON-RPC 2.0, no credentials) | https://mcp.exa.ai/mcp (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. Anonymous usage is rate-limited by Exa:
an HTTP 429 surfaces as a distinct WEB_RATE_LIMITED code — not a generic
provider failure — with a hint to configure an API key (which also switches to
the REST path automatically).
Installation (into a dsh profile)
One artifact, two doors. CI packs this version's tarball, verifies it against every supported dsh version, attaches it to the GitHub Release, and publishes that artifact to npm — so the release asset and the npm tarball are one file, not two builds that happen to match.
From npm (v0.1.4+ ships the dsh.bundle manifest, so the bundle patch
inserts the provider row with no manual patch editing):
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, includes work not yet released):
dsh plugin --profile web add github:TonyDua/dsh-web-search-exa
Restart dsh web. Without an API key the official DeepSeek search
provider is unavailable, so the seam auto-selects this provider — fully
zero-config. With a key configured, select Exa explicitly in your own
$DSH_HOME/profiles/web/cordis.patch.yml (applied after bundle patches):
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: exa
…or at runtime with the environment variable $DSH_WEB_SEARCH_PROVIDER=exa.
Local development checkout:
dsh plugin --profile web add ../plugins/dsh-web-search-exa
Then enable the provider and select it. Either merge into
$DSH_HOME/profiles/web/cordis.patch.yml (persistent):
- id: web-search-exa
name: '@tonydua/dsh-web-search-exa'
config:
apiKeyEnv: EXA_API_KEY
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: exa
Alternatively, select the provider at runtime with the environment variable
$DSH_WEB_SEARCH_PROVIDER=exa (no config edit needed).
Restart dsh web for changes to take effect. The existing model-facing
web_search tool then routes through this provider — no tool config changes.
Runtime singleton compatibility
@deepseek-ai/dsh-tools is a dsh runtime singleton and must resolve to one
physical package instance in a profile. This provider does not depend on it;
the requirement belongs to the host profile. If another third-party plugin
installs @deepseek-ai/dsh-tools as a nested regular dependency instead of a
peer dependency, fix that plugin's dependency declaration or make the profile
package manager resolve the shared instance before debugging search errors.
Otherwise dsh's agent loop can fail before the provider is called with an
error such as Cannot read properties of undefined (reading 'prepare').
Configuration
| Key | Default | Meaning |
|---|---|---|
providerId | exa | Provider id registered into ctx.web. Only change it when both this and the official package are installed (see next section). |
apiKey | unset | Literal Exa API key. Empty/missing enables the anonymous MCP path. |
apiKeyEnv | EXA_API_KEY | Environment variable consulted when no literal apiKey is set. |
baseURL | https://api.exa.ai | Exa API base URL; /search is appended for the keyed REST path. Matches the official dsh provider. |
apiURL | unset | Deprecated full REST endpoint alias. If set, it takes precedence over baseURL. |
mcpURL | https://mcp.exa.ai/mcp | Exa hosted MCP endpoint (anonymous path). |
searchType | auto | REST retrieval mode: auto / keyword / neural. |
numResults | unset | Default result count when the request carries no maxResults. |
highlightsPerResult | 1 | Highlight sentences requested per result on the REST path. |
Coexistence with the official package
Both packages register their provider under the same default provider id
(exa) and the same cordis plugin name (web-search-exa). The seam rejects
duplicate ids with WEB_DUPLICATE_PROVIDER, so installing both into one
profile without changes breaks at startup.
There is no silent override — coexistence is explicit, via the providerId
switch:
- Keep the official package on
exa(its id is fixed). - Give this package a distinct id — set
providerId: exa-anon(any unique string) in this plugin'sconfig. - Select the anonymous variant explicitly with
searchProvider: exa-anonon thewebseam (or$DSH_WEB_SEARCH_PROVIDER=exa-anon), and keepsearchProvider: exa→ the official one if you want it selectable 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
Simplest alternative: install only one of the two packages per profile — the defaults then work as-is.
In the Web panel
Status: configuration is done in the profile patch layer, not the Web UI —
this version ships no editable UI entry. The Settings UI only renders cards
that are hand-registered by client plugins for fixed namespaces (shell,
agent-loop, web-search-deepseek); it has no generic form for arbitrary
plugin namespaces. What is true today:
- Plugin inventory (Settings → Plugins): the entry appears automatically
as
web-search-exa(@tonydua/dsh-web-search-exa) once enabled — the inventory reads the live Cordis loader, no extra code needed. - Settings namespace (server-side): the plugin registers the
web-search-exasection via the currentctx.settings.installSectionAPI, so the data layer is writable — but no client card binds to it, so nothing shows in the UI. The built-in "Web search" card edits the officialweb-search-deepseeknamespace, not this plugin. - Changing configuration today: edit the plugin's
configin$DSH_HOME/profiles/web/cordis.patch.yml(fields and defaults in the table above) and restartdsh web; or setEXA_API_KEY/$DSH_WEB_SEARCH_PROVIDERas environment variables. TheapiKeyfield isrole('secret'): it never appears indescribe()responses. - Search result cards:
web_searchcalls render the usualwebcards (sources, snippets, dates) throughdsh-tool-web, independent of the provider — anonymous Exa results display exactly like DeepSeek ones.
Roadmap (next version): a client-side card registered into the
settings.plugin.item slot bound to the web-search-exa namespace, so all
fields above become editable live in Settings → Plugins (mirroring how the
official cards work).
FAQ
Q: Do I need an Exa API key? No. Without a key the provider uses Exa's free anonymous hosted MCP. With a key it uses the REST API for higher limits.
Q: I got HTTP 429 / rate limited.
That's Exa's anonymous-MCP rate limit. Configure EXA_API_KEY (or the
apiKey field) and the provider switches to the REST path automatically.
Q: Can I run this alongside the official Exa provider?
Yes — give this package a distinct providerId and select it explicitly
(see Coexistence).
Q: Why don't I see a settings entry in the Web UI?
This version registers the web-search-exa settings namespace server-side
only; a UI card is planned for the next version. Configure through
cordis.patch.yml or environment variables for now (see
In the Web panel).
Q: Which dsh versions are supported?
Every published dsh version from 0.1.2-alpha.2 to 0.1.7-alpha.1, plus future
0.1.8+ stable releases. Each version is installed in isolation, typechecked
against its own declarations, and run through this package's test suite in CI —
see Why the peer range looks like that.
Acknowledgements
The anonymous MCP integration follows the web_search implementation in
can1357/oh-my-pi (packages/coding-agent/src/web/search/providers/exa.ts
and src/exa/mcp-client.ts) and the
@oh-my-pi/exa plugin: same
"REST when a key exists, credential-free mcp.exa.ai/mcp otherwise" strategy,
same x-exa-source attribution header, same Title:-section response parsing.
Thanks to the oh-my-pi (omp) project for pioneering the zero-config Exa
integration.
Thanks also to Exa for providing and operating the
free, unauthenticated hosted MCP server (mcp.exa.ai/mcp) that makes this
package's zero-config default possible. Exa's hosted MCP is an official Exa
product; anonymous usage is rate-limited (see FAQ).
Changelog
See CHANGELOG.md for all notable changes.
License
MIT — see LICENSE.
Comments
Loading…
From the same category
DeepSeek Harness plugin for Reactive Resume: bridges your resumes and job applications into a Harness session over MCP.
★ 41.7k
↓ 156/wk
MIT
Aug 24, 2026
dsh plugin --profile web add dsh-plugin-reactive-resumeby Tencent
Let AI agents use your real, logged-in browser without interrupting your work. CLI + extension for browser automation across any shell-capable AI agent.
★ 8.6k
↓ 6.1k/wk
MIT
TypeScript
Oct 10, 2026
dsh plugin --profile terminal add @wxg-prc-cpg/browser-skill-dsh-pluginby yjh051108
dsh-routing-suite — injector + router-standard kit: install the runtime injector first, then the task-aware reasoning-mode router preset (measured P1-P23).
★ 7k
MIT
JavaScript
Sep 18, 2026
dsh plugin --profile web add @dsh-external/dsh-super-injectorby Q00
Agent OS: the agent gets smarter on its own. We just hold the line: Interview-gated, staged evaluation, budgeted evolution loop. MCP server, 14 runtimes: Claude Code, Codex CLI, Gemini CLI, OpenCode,
★ 6.2k
MIT
Python
Oct 7, 2026
by dsh-market
The plugin market inside DeepSeek Harness — browse, search, one-click install · DSH 可视化插件市场
★ 6.1k
↓ 112.4k/wk
MIT
TypeScript
Oct 10, 2026
dsh plugin --profile web add dshmarketby superdesigndev
OpenRouter for agent tools. Join community here: https://discord.gg/6mQYYfFMAn
★ 5k
NOASSERTION
Python
Oct 11, 2026
dsh plugin --profile web add treg-dsh