dsh-tavily
Verified@0x427567/dsh-tavily Β· v0.3.0 Β· MIT Β· Web UI
Tavily-backed search provider for the DeepSeek Harness web seam (ctx.web): keyless-first credentials, a cooldown after the keyless tier refuses, and a user-visible notice when it falls back to the key.
Install
dsh plugin add @0x427567/dsh-tavily Confirm the layer applied with dsh --profile default --dump-config β see the install guide.
Source
Tags
Creators
Readme
@0x427567/dsh-tavily
A Tavily-backed search provider for the DeepSeek Harness web
capability seam (ctx.web), shipped as a DSH bundle so dsh plugin add wires it
up without hand-editing a profile.
Tavily answers /search with the result list itself β title, URL, content and an
optional published date β so one search is one HTTP request: no auxiliary model turn,
and no scraping results out of provider prose.
What makes this one different
Several Tavily providers for DSH exist. This one is built around a single idea: which credential tier served your search should never be a silent decision.
- Keyless first. Tavily serves
/searchwith no account at all, so this provider works with no key stored. Your key is reached for only once the keyless tier refuses. - A refusal is visible. When the keyless tier refuses, that tier is cooled down and the switch to your key is reported in the search result itself β and logged β rather than happening behind your back. Both follow your harness language.
- Explicit modes.
keyless-first(default),key-first,keyless-only,key-only. - No build step. Plain ESM, published as written. Installing it runs no install
script, so it needs no
allowBuildspermission.
Install
Requires Node ^22.19 || >=24, and a harness that provides @deepseek-ai/dsh-web,
@deepseek-ai/dsh-credentials, @deepseek-ai/dsh-web-search-deepseek and
@deepseek-ai/dsh-timeout at ^0.1.5-rc.2 β all four are declared as peer dependencies. A peer
mismatch is a warning, not a failure: pnpm 12 reports unmet peers and installs anyway, and what an
older harness then gets is an ESM link error at load, because this plugin imports symbols those
packages must already export. Keep the harness and the plugin on the same release line.
From npm (recommended)
dsh plugin --profile web add @0x427567/dsh-tavily
Prebuilt, so nothing is compiled on your machine. The package is scoped because
dsh-tavily and dsh-web-tavily are already taken on npm by other Tavily providers.
From GitHub
dsh plugin --profile web add github:ShawnOY/dsh-tavily
Either form installs the same package. It declares dsh.bundle.patch, so dsh plugin add
registers it in the profile's dsh.profile.bundles and its shipped patch mounts the
provider, pins the web seam's search to it, and switches off the DeepSeek search provider the
base bundle ships. web_search is therefore Tavily-backed, and
Settings β Plugins β Plugin configuration shows this plugin's card alone. Its Search
provider select switches to the shipped DeepSeek backend without a restart. Nothing else to
configure.
Restart the harness afterwards: the base hmr row is disabled, so a newly added module
is not hot-reloaded.
From a local checkout
Pack it, then install the tarball:
npm pack
dsh plugin --profile web add ./0x427567-dsh-tavily-<version>.tgz
Do not point dsh plugin add at the checkout directory. pnpm installs a directory
dependency as a symlink, and Node resolves a symlinked module to its real path β so a checkout
outside the harness home resolves this package's @deepseek-ai/* imports against the checkout's
own node_modules, not the harness's, and boot fails with
Cannot find package '@deepseek-ai/schemastery'. A tarball extracts the package inside the
profile, where the peers resolve correctly. npm install in a checkout is only for running the
test suite; it pulls its own copies of the host packages, which is fine for the stubbed tests
and wrong for loading into a harness.
Re-packing to the same version is not enough to upgrade. dsh plugin add <tarball> records the
tarball's integrity in the profile lockfile, so replacing the file at the same path and version leaves
pnpm reusing the cached contents β the install reports success and nothing changes. Remove and re-add
(dsh plugin --profile web remove <name> then add <tarball>), or bump the version so the spec
differs. --force does not get past this.
Manually, by relative path
If you would rather not install a package, copy this directory to
<profile>/plugins/tavily-search/ and add to <profile>/cordis.patch.yml:
- insert:
- id: web-search-tavily
name: ./plugins/tavily-search/index.js
config:
apiKeyEnv: TAVILY_API_KEY
# The shipped DeepSeek provider starts off, so the default selection is this
# one; the card switches between them inside this plugin. Pinning the seam is
# what keeps a third search provider from making it ambiguous.
- id: web-search-deepseek
disabled: true
- id: web
config:
searchProvider: tavily
fetchProvider: http
Credentials
Keyless needs none. To use the keyed tier, provide a key in any of these ways β first match wins:
A literal
apiKeyin this plugin's config (avoid; it lands in a config file).TAVILY_API_KEY(or yourapiKeyEnv) in the harness's credential store,~/.dsh/.credentials.yamlβ written either by the card's API key field or by hand:version: 1 refs: TAVILY_API_KEY: tvly-...The same variable in the environment that launched the harness.
Keyless is free but rate-limited, and Tavily does not publish the keyless limit. Keys have documented limits (100 RPM development, 1,000 RPM production). When the keyless tier refuses, this provider falls back to your key and tells you.
Configuration
| Key | Default | Meaning |
|---|---|---|
apiKey |
β | Literal key. Prefer the credential store. |
apiKeyEnv |
TAVILY_API_KEY |
Credential ref resolved per search. Must be a shell-style name ([A-Za-z_][A-Za-z0-9_]*); anything else is rejected when the config loads. |
baseURL |
https://api.tavily.com |
API base; /search is appended. |
searchDepth |
basic |
Tavily search_depth. advanced costs more. |
maxResults |
5 |
Result bound when the caller sets none. |
snippetChars |
1200 |
Bound on one source's snippet; a longer one is cut and marked with β¦. 0 keeps Tavily's text as-is. |
allowCustomBaseURL |
false |
Whether baseURL may point somewhere other than the official endpoint. See The official endpoint is locked. |
searchTimeoutMs |
12000 |
How long one search may take before this plugin gives up and throws WEB_SEARCH_TIMEOUT. The seam has no deadline of its own, so without this a slow upstream can hold a turn open. Armed through @deepseek-ai/dsh-timeout, the same deadline primitive the shipped fetch provider uses, so a timeout is classified from its abort reason rather than inferred. The probe spends the same budget. |
includeAnswer |
false |
Ask Tavily for an LLM-written answer as well. |
provider |
tavily |
Which backend answers web_search: tavily or deepseek-official β see Switching providers. |
mode |
keyless-first |
Credential strategy β see below. |
keylessCooldownMinutes |
10 |
How long a refused keyless tier is skipped. 0 disables the cooldown. |
language |
auto |
Copy language for this plugin alone. auto follows the harness preference β see Language. |
provider, mode, keylessCooldownMinutes, language, searchDepth and maxResults are also
editable at runtime from
Settings β Plugins β Plugin configuration, which also carries a Test connection button β see
Testing the connection. This package ships a browser half that contributes the card for its
dsh-tavily namespace β registering a namespace on the Host is not enough on its
own, because that page renders only the namespaces a card claims. A change there takes effect on
the next search, and the profile patch remains the base value a reset returns to. The other keys
are file-only.
Modes
mode |
Behaviour |
|---|---|
keyless-first (default) |
Try keyless; on refusal, cool that tier down and retry with the key. |
key-first |
Use the key whenever one resolves; keyless only when none does. |
keyless-only |
Never send the key. |
key-only |
Require a key; never use the keyless tier. |
A refusal means HTTP 401, 403 or 429, or a 200 whose body carries prose
instead of a results array β Tavily answers a capped keyless call with instructions
rather than a machine-readable error. A 400 (a bad query) is not a refusal: retrying
with a key would not help, so the error surfaces.
What you see when it falls back
The switch is announced in the search result, which the harness renders as the tool output:
β οΈ Tavily ε
ε―ι₯ι’εΊ¦ε·²η¨ε°½οΌθΏζ¬‘ζΉη¨ε―ι₯οΌζ₯δΈζ₯ηΊ¦ 10 ειε
η΄ζ₯δ½Ώη¨ε―ι₯γ
and, while the cooldown is active:
β οΈ Tavily ε
ε―ι₯ζε‘ε·ε΄δΈοΌηΊ¦ε© 7 ειοΌοΌθΏζ¬‘δ½Ώη¨ε―ι₯γ
Both also emit a warn to the harness log. A happy path is silent. The wording above is
the default (Simplified Chinese); see Language for the other two.
Language
The notice, the log warning, and this provider's own error messages follow the harness language preference β Settings β General β Language β so they match the language the settings card and the rest of the UI are already in. A language change applies to the next search.
The card's own language field overrides that for this plugin alone. Leave it on
auto to follow the harness; pick a language to keep the rest of the UI where it is and
have only this card and this plugin's notices speak it. Both halves read the same field,
so the card and the transcript cannot disagree about which language they are in.
Three languages are served:
| Preference | Language | Where it comes from |
|---|---|---|
zh (default) |
Simplified Chinese | The harness's own zh, so it matches the shell |
en |
English | The harness's own en |
zh-Hant |
Traditional Chinese | A language pack this package adds |
zh and en are built into the harness; zh-Hant arrives with this plugin's browser
half, which calls ctx.locale.addLanguage for it. It falls back to zh, so a key missing
from the pack resolves instead of rendering as a raw identifier.
The pack is registered under the script tag and deliberately not a region tag such as
zh-TW. That keeps the default where it belongs: a browser reporting zh-TW or zh-HK
matches the zh primary subtag and opens in Simplified, and a Traditional reader opts in
by picking ηΉι«δΈζ once. Registering zh-TW instead would exact-match those browsers and
pull them off the default.
Both halves resolve a given preference the same way β an exact id wins, then the primary
subtag β so an unshipped zh-TW or zh-HK lands on Simplified zh in the card and in
the notices rather than the two disagreeing.
The one case where they can part ways is a browser that has never picked a language and
reports zh-Hant itself. The client matches that exactly and opens in Traditional; the
Host sees no stored preference at all, so its notices use the default, Simplified.
Choosing a language in Settings β General β Language persists it and closes the gap.
Nothing else can: the browser's language list never leaves the client, and the client
deliberately does not write a browser-derived guess into the durable settings document.
Error classification
A failed search throws a WebError whose code tells you what to do, instead
of one generic WEB_PROVIDER_ERROR:
| Code | When | What to do |
|---|---|---|
WEB_SEARCH_QUOTA_EXCEEDED |
HTTP 432/433 on the key tier | Top up the key or check its plan |
WEB_SEARCH_RATE_LIMITED |
HTTP 429 on either tier | Wait a bit and retry |
WEB_SEARCH_INVALID_KEY |
HTTP 401/403 on the key tier | Check that the key is correct and valid |
WEB_SEARCH_KEYLESS_REFUSED |
the keyless tier refuses (401/403/432/433) | Set an API key to keep searching |
WEB_SEARCH_UPSTREAM_ERROR |
HTTP 5xx | Tavily is down for a moment; retry later |
WEB_SEARCH_UNREACHABLE |
the request never reached Tavily | Check the network or endpoint settings |
WEB_SEARCH_TIMEOUT |
no answer within searchTimeoutMs |
Lower maxResults, or raise the budget |
WEB_SEARCH_CREDENTIAL_MISSING |
the mode needs a key and none resolved | Provide the credential (see Credentials) |
WEB_PROVIDER_ERROR |
anything else (e.g. a 400, or an unparseable body) | Read the message; it carries Tavily's detail |
The message itself ends with the matching action, in the interface language, so the operator does not have to map codes by hand.
Switching providers
Settings β Plugins β Plugin configuration β Tavily web search carries a Search provider select:
| Value | Backend | Cards on the page |
|---|---|---|
tavily (default) |
This plugin, keyless-first | This one |
deepseek-official |
The DeepSeek search provider dsh-base ships, run inside this plugin |
This one |
The choice applies to the next web_search β no restart β and it is stored in the settings
document, so it survives one. The profile patch remains the value a reset returns to.
Both selections answer through this plugin, so the page keeps one search card either way. The seam is pinned to this provider and never re-resolves, which also means a third search provider installed alongside cannot make it ambiguous.
The one thing the built-in selection does not carry over is its settings card. The shipped
provider's endpoint and model stay on their defaults, with DEEPSEEK_SEARCH_BASE_URL as the
override, and its key is read from DEEPSEEK_API_KEY in the credential store or the
environment.
Testing the connection
Settings β Plugins β Plugin configuration β Tavily web search β Test connection calls the credential tiers and reports each one separately, so "the keyless tier still works" and "my key is valid" stop being the same unanswered question:
| Row | What a passing row means |
|---|---|
| Keyless tier | Tavily answered without any key |
| Your API key | Tavily answered with the key the credential ref resolved |
The test uses the values currently on screen, saved or not, so a mode can be tried before it is saved. Editing Search provider or Credential mode clears a result, because it no longer describes what is selected; changing the language or the cooldown, or collapsing the card, does not.
- A tier the mode never uses reads Not tested and is not called:
keyless-onlydoes not spend a keyed request, andkey-onlydoes not touch the keyless tier. - The probe is a real call, not a dry run. Each row asks Tavily for a single result. When the keyless tier refuses, that refusal starts the cooldown exactly as a real search would, and the result says so β you can see the fallback working before you rely on it. A tier already cooling down is reported as such rather than hit again.
- Selecting the built-in backend tests that backend instead: one row, and it costs a whole model
turn, because DeepSeek exposes no dedicated search endpoint. A missing
DEEPSEEK_API_KEYis reported without making that call at all. - The button exists only where the deployment mounts the web
/apibridge. A profile without one β a headless composition β shows "This deployment has no test channel." and still searches normally.
The key never travels from the browser: the card posts provider, mode and the cooldown, and the
Host reads the credential itself. The route sits behind the harness's own Host/Origin fence and
browser authentication, because it is registered on the /api bridge rather than beside it.
Tier status at a glance
The card's header carries one line about the credential tiers, readable without expanding it:
| What it says | When |
|---|---|
| Keyless is cooling down (~N min left) | A search or a test was refused by the keyless tier, so that tier is being skipped |
| Keyless is ready; your key is configured / β¦no key configured | The ordinary state, with or without a stored key |
| This mode never uses the keyless tier | mode: key-only, where a cooldown would not affect the next search |
| Built-in backend: key configured / β¦no key configured | The built-in backend is selected |
Reading it costs nothing: it is a local read of this plugin's own state with no upstream request, so opening the settings page spends no keyless call and no credit. It is re-read when the card first renders, when you expand it, after a save, and after a test.
It is not a usage readout. Tavily publishes no keyless limit and offers no endpoint for one, so
there is no "remaining quota" number to show β only whether the tier is cooling down. Per-key usage
and balance belong to the key-pool plugins, which need a key and a call to Tavily's /usage to show
them. The cooldown itself lives in memory: restart the harness and the line reads ready, which is
what the next search will do too.
How the switch is wired
web_search is not a search engine of its own: it is a model-facing tool that calls
ctx.web.search(), and the seam resolves one registered provider. Three rows decide which:
- the
webrow pinssearchProvider: tavily, so the seam always resolves this package; - this package's provider is registered and available whenever its base URL parses β the
keyless tier needs no credential, so a stored key is not a precondition β and its
search()routes on theprovidersetting; - the
web-search-deepseekrow stays disabled, which is what keeps the Plugins page to one search card.
Picking deepseek-official therefore does not move the pin or touch the loader. This package
imports DeepSeekSearchProvider from @deepseek-ai/dsh-web-search-deepseek β a declared peer
dependency β and delegates to it, so that backend's behaviour, errors and endpoint handling
are the shipped ones. What it costs is that the shipped provider's own configuration card is
gone, and with it the ability to edit that endpoint and model from the UI.
The patch also restates fetchProvider: http, because a patch replaces the targeted row's
whole config and dropping it would leave the web fetch provider unset.
Your own cordis.patch.yml is applied after every bundle layer, so you can still override any
of these rows β but you must restate the whole config to do so.
The official endpoint is locked by default
baseURL decides where your key is sent, so a stray value there is not a routing preference β it is
a credential leaving the machine. Unless allowCustomBaseURL: true is set, any baseURL other than
the official endpoint (compared ignoring case and a trailing slash) is refused by search, by the
test button and by the status read, before any request is built. The error names the switch to
set, and the card shows it when the test fails.
The lock is a guard against misconfiguration, not a sandbox: it does not resolve DNS, follow
redirects or vet certificates, and allowCustomBaseURL: true is a deliberate decision that the
endpoint is yours.
Caveats
- The shipped DeepSeek search provider starts switched off, and its settings card stays
off. The default selection is Tavily, so Settings β Plugins shows this plugin's card
alone; picking
deepseek-officialruns that backend without bringing its card back. Its endpoint and model are not editable from the UI. - Keyless has no account and no contract. You accept no terms and hold the least
leverage over what happens to your data. It is the right default for trying a tool and
the wrong channel for anything sensitive β see
Tavily on keyless.
Set
mode: key-firstif you would rather always use your own account. - The card covers
provider,language,modeand the cooldown. The remaining keys βapiKey,apiKeyEnv,baseURL,searchDepth,maxResults,searchTimeoutMsandincludeAnswerβ are set in the profile patch or the settings document. The card's API key field writes the credential store through the harness's own credentials facade β the same one its Models settings page uses β and the store never hands the value back, so the field is write-only by construction. - A failure now says what went wrong, not only which status came back. A
429reads asrate limited, a keyed401/403asthe key was rejected, and a keyless one asthe keyless tier refused itβ the same words the card's test button uses, in the harness's language. The error code staysWEB_PROVIDER_ERROR; only a timeout takes its own,WEB_SEARCH_TIMEOUT, so a caller can tell a slow upstream from a turn it cancelled itself. - Traditional Chinese is a language pack, not a build of
zh. The harness reads barezhas Simplified, so ηΉι« lives atzh-Hant. It shows up in the language list as an extra entry rather than as a variant of δΈζ, and a Traditional reader has to pick it once. Nozh-TW/zh-HKentry is offered, so those browsers stay on the Simplified default unless someone chooses otherwise.
Development
npm test # both offline suites
node test/provider.test.mjs # the Host half only
node test/client.test.mjs # the browser half only
TAVILY_LIVE_TEST=1 node test/provider.test.mjs # + a live keyless call
TAVILY_LIVE_TEST=1 TAVILY_API_KEY=tvly-... node test/provider.test.mjs
npm run test:bridge # register on a harness's real /api bridge
TAVILY_LIVE_TEST=1 node test/bridge.test.mjs # + a live call dispatched through it
test/bridge.test.mjs is the one suite that does not stub the Host. It loads the real
@deepseek-ai/dsh-client-connection and cordis, lets apply() register the probe route the way a
running profile does, and dispatches through the bridge's own handler table β so the route grammar,
the duplicate-path contract, the inherited Host/Origin fence and the fall-through to 404 are checked
against the implementation that will actually run them. Those packages are not dependencies here, so
it resolves them from an install (DSH_BRIDGE_ROOT, a profile's node_modules, or the global dsh)
and exits 0 with a notice when it cannot β a skip is not a pass. It is deliberately outside
npm test for that reason.
The offline assertions stub fetch; the live ones are opt-in. The plugin imports its
@deepseek-ai/* peers as bare specifiers, so run the tests where those resolve β for
example inside the profile that has the plugin installed.
test/client.test.mjs stands up the __ModuleLoader__ contract with a stub require and
a stub context, so the browser half's wiring β the dictionaries it registers and the
language pack it adds β is checked without a browser. It does not render the card.
client.js is the browser half. It is a prebuilt bundle in the shape the client module system
serves β a window.__ModuleLoader__.load factory receiving the host's require β so it is served
as written and needs no build step. Its dsh.client.platform is web, and it is registered in the
browser module graph as @0x427567/dsh-tavily/client.js.
License
MIT β see LICENSE.