localflame: The Web Search That Never Calls Home
The DeepSeek Harness ships with exactly one web-search provider, and it’s the cloud one. dsh-web-search-deepseek, id deepseek-official, pointed at DeepSeek’s API and gated on DEEPSEEK_API_KEY. There is no fetch backend in the box at all — tool-web ships with fetch: false, and the web-fetch-http package isn’t even installed. Which means the honest description of the stock harness’s web capability is: a search button that works if you pay, and a fetch button that doesn’t exist. localflame is the answer to that: a tiny, self-hosted Firecrawl web search + fetch provider for the DeepSeek Harness (dsh, the dsh-web GUI served by @deepseek-ai/dsh-web-app) that routes the harness’s native web_search / web_fetch tools at your own unauthenticated Firecrawl instance. Zero auth. And it started, very specifically, with a 401.
The 401 That Started This
The before-state was a hand-added, cloud-only Firecrawl provider pointing at api.firecrawl.dev with a bad key. web_search came back with:
Error: Firecrawl search failed (HTTP 401): Unauthorized: Invalid token
Meanwhile, a healthy zero-auth local Firecrawl instance was already sitting on port 3002, answering real queries with no header at all:
curl -s http://127.0.0.1:3002/ # {"message":"Firecrawl API",...}
curl -s -X POST http://127.0.0.1:3002/v2/search -d '{"query":"github","limit":1}' # real results, no auth
curl -s -X POST http://127.0.0.1:3002/v2/scrape -d '{"url":"https://example.com"}' # real markdown
The fix was never going to be a better key. The fix was a provider that stopped sending one.
What Stock DSH Actually Ships
Before writing anything, the project read the installed code — the @deepseek-ai/* packages under the global bun install — and the picture it produced is worth keeping:
- The only search provider in the box is
dsh-web-search-deepseek(cloud-only). No Firecrawl provider. No fetch provider. dsh-base/cordis.patch.ymlsets the defaults:web→searchProvider: deepseek-official,web-search-deepseek→apiKeyEnv: DEEPSEEK_API_KEY, andtool-web→fetch: false. Fetch is off by default.web-fetch-httpisn’t installed, so even a fetch flip would have had nothing behind it.
So the actual work decomposed into: add a Firecrawl search and fetch provider, select it in the seam, and turn tool-web.fetch on. Each of those three turns out to have a trap.
The ctx.web Seam
DSH exposes web access as a service seam (ctx.web), not a hardcoded function. Backends register as search and fetch providers; a provider is an object with an id, an available() method that must be a cheap local check and must not hit the network, and the search/fetch methods themselves. The model-facing tools web_search and web_fetch live in dsh-tool-web, which merely formats the seam’s result — it never owns provider selection or network access. Provider selection comes from config (web.searchProvider / web.fetchProvider) or the DSH_WEB_SEARCH_PROVIDER / DSH_WEB_FETCH_PROVIDER environment variables, and with nothing set the seam auto-selects the single registered usable provider. Failures map to typed WebError codes — WEB_PROVIDER_CONFIGURED_MISSING, WEB_PROVIDER_AMBIGUOUS, and friends — rather than generic exceptions.
The README documents this model because it was reverse-engineered, and it cross-checked the approach against how two other agents on the same machine already wire Firecrawl: Hermes runs a FirecrawlWebSearchProvider with a _KeylessFirecrawlClient that POSTs to /v2/search and /v2/scrape with no Authorization header at all, and OMP’s pi-coding-agent resolves its endpoint from FIRECRAWL_BASE_URL / FIRECRAWL_API_URL and omits the header entirely in keyless mode. localflame is the same pattern, done natively as a DSH provider instead of bolted onto someone else’s harness.
The Two Traps
Trap one: the patch shape. The first attempt registered the plugin as a plain top-level row in the profile’s cordis.patch.yml:
- id: web-firecrawl-local # WRONG for a brand-new entry
name: '@local/dsh-web-firecrawl'
After a restart: Error: configured web provider "firecrawl-local" is not registered. That error is the smoking gun — the web override had applied (the seam now wanted firecrawl-local), but the plugin row never mounted. Reading applyEntryPatches in @deepseek-ai/dsh-app-boot explains why:
const target = entryMap.get(id);
if (!target) { warn("patch: entry % not found", id); continue; } // silently skipped
Top-level patch rows only override existing entries; a brand-new id is dropped with a warning nobody reads. New entries must use - insert:. The working shape is a top-level web row (an override of an existing id) plus an insert for the provider entry itself.
Trap two: resolving is not mounting. The second failure mode was subtler — the plugin resolved as a bare module from the profile directory, imported cleanly, exported exactly { apply, inject, name }, and still didn’t mount. The code was right; the wiring was the problem. The lesson generalizes: in a layered loader, “my module imports” and “my module is loaded” are different claims, and only the second one produces behavior.
And there’s a third, quieter trap hiding in the fetch flip. You’d naturally try tool-web → fetch: true in the profile patch. That merges into the host tool-web row — but the web-app ships that row disabled: true, and the tools a session actually sees are mounted per-session by the agent preset, where every shipped preset (standard, code, cordis) forces fetch: false. A profile-patch flip is a silent no-op. The real lever is the preset: localflame flips fetch: true inside the three presets you actually use (Standard, PTC, Creator), each backed up to *.localflame.bak, and keeps the profile patch minimal — select the provider, disable the cloud search provider, done.
The Provider Itself
Ported from firecrawl/dsh-firecrawl (MIT, with full attribution in ATTRIBUTION.md, relicensed AGPLv3 at the repo root), the provider exposes the Firecrawl v2 surface:
- Search (
POST /v2/search): source selection, result limits,scrapeContent, domain include/exclude lists, Google-style freshness qualifiers (qdr:h/d/w/m/y), geo-targeting, and a 60-second timeout. - Fetch (
POST /v2/scrape): markdown or HTML output, main-content stripping, ad and cookie-banner blocking, wait-before-capture for slow client-rendered pages, mobile emulation, proxy pass-through, a 100k-character decoded-body cap that sets a realtruncated: trueflag, and a 2048-character URL limit.
On top sits a safety and typing layer that’s always on, independent of configuration: assertFetchableUrl rejects file://, non-HTTP(S) schemes, embedded credentials, and over-long URLs locally, before any request leaves the process; fetch returns the page’s real metadata.statusCode, so a 404 is a result (statusCode: 404) rather than a thrown error; failures carry typed WebError codes (WEB_ABORTED, WEB_INVALID_URL, WEB_PROVIDER_ERROR) with the seam’s structured metadata preserved; and the transport is redirect: 'error' with no Authorization header — available() is always true, because there is no API key to gate on.
Eleven Commits, One Night
The history is compressed in the way the problem demanded — no time for architecture debates when a 401 is sitting in the terminal:
- August 27, 07:51 UTC — first commit announces the whole project in its subject line: “localflame: zero-auth self-hosted Firecrawl web search+fetch provider for DeepSeek Harness (AGPLv3)”. Seven minutes later, the initial commit.
- Evening of August 27, 17:22–17:35 UTC — fourteen minutes of focused hardening: the Firecrawl feature flags ported into one robust plugin, MIT attribution and modification notes, install-time config flags for both tools, the feature matrix and upstream citation, and a restored executable bit on
install.sh(the classic Windows-born-script bug). - 19:11 UTC —
web_fetchexposed through astandard-fetchagent preset. - 22:22 UTC — the clone-and-preset-creating installer removed; simplified to zero-config.
- August 28, 00:50 UTC — push update. 01:46 UTC — PR #1 merged.
Born in the morning, hardened by night, merged just after midnight. Eleven commits across eighteen hours, AGPLv3, Node ^22.19.0 || >=24.0.0 to match what the harness itself demands. If your web_search ever says “Invalid token” again, the reason is now a choice, not a limitation: the flame stays local, and it doesn’t call home.
github.com/CommanderTurtle/localflame · persephone
xkcd of the day, 8/27 - Trade #3290
