Writing a search adapter
June 9, 2026 · View on GitHub
A search adapter is the only thing standing between deepdive and a new search backend. Each one is a small class that turns a query string into a ranked list of candidate URLs. Adding one is ~30 lines plus a test. This doc is the contract every adapter must satisfy and a copy-paste scaffold to start from.
deepdive ships these: duckduckgo (default, no key), searxng, brave,
tavily, exa, auto, wikipedia, arxiv, github.
The interface
From src/search.ts:
export interface SearchResult {
url: string; // absolute http(s) URL of the page to fetch
title: string; // display title ("" if unknown)
snippet: string; // short context excerpt ("" if unknown)
rank: number; // 1-based position in this query's result list
}
export interface SearchAdapter {
readonly name: string;
search(query: string, limit: number, signal?: AbortSignal): Promise<SearchResult[]>;
}
The contract
- Return absolute
http:/https:URLs. The agent fetches eachurlverbatim through the headless browser (or the PDF path). Relative URLs,javascript:, and tracking-redirect wrappers must be resolved/unwrapped before you return them. - Honor
limit. Return at mostlimitresults. Asking the backend for more and slicing is fine; returning 200 when asked for 5 is not. - Rank from 1.
rankis 1-based and dense. The agent uses it for ordering only; the cross-query dedupe (dedupeByUrlinsrc/search.ts) happens upstream, so you don't need to dedupe across calls — but don't emit the same URL twice within one call. - Throw on failure, don't return
[]to hide an error. A non-2xx response or malformed payload shouldthrow new Error("<name> <status> ..."). Empty results for a genuinely empty backend response are fine; swallowing a 500 as[]is not — it hides outages and breaks theautoadapter's fallback logic, which keys on thrown errors. - Respect the signal + apply a timeout. Wrap the caller's
signalwithsearchTimeoutSignal(signal)(fromsrc/search.ts) so a hung endpoint can't block the whole run. It composes the caller's abort signal with a hard per-request timeout (DEEPDIVE_SEARCH_TIMEOUT_MS, default 15s). - No new runtime dependencies. Use
fetch(global in Node 20+) and hand-rolled parsing. If the backend returns HTML/XML, parse it with a regex and a// fix the parser if it breakscomment — seeduckduckgo.ts/arxiv.ts. Do not addcheerio,xml2js, etc. - Keep the parsing pure and exported. Put the response→
SearchResult[]transform in an exported function (mapXResults/parseXHtml) that takes the already-parsed payload and returns the array. That function is what your test exercises — no network needed. - Never log or persist credentials. API keys arrive via the constructor
(resolved from env in
resolveSearchAdapter). They must not appear in any thrown error message, event, or returned field.
Steps
- Create
src/search/<name>.tsimplementingSearchAdapter(scaffold below). Keep the network call insearch()and the transform in an exported pure function. - Register it in
src/search.ts— add acase "<name>":toresolveSearchAdapter. Read any key fromenvthere and throw a clear"<name> adapter requires DEEPDIVE_<NAME>_KEY"if it's required and missing. Keyless adapters just construct and return. - Add a test
test/<name>-adapter.test.mjsimporting from../dist/<name>.js: cover the pure mapper (mapping, ranking, limit, missing fields), theresolveSearchAdapterwiring (key required/optional), and aglobalThis.fetch-stubbedsearch()call asserting the request shape + that non-2xx throws. - Document it — add a row to the README "Search adapters" table and list
any
DEEPDIVE_*env var in the CLI--helpEnvironment block (src/cli.ts) and--searchadapter list. - Run
npm run build && npm test—tsc --strictmust pass and your new tests must be green.
Scaffold
// src/search/example.ts
// Example search adapter. <one line on the backend + whether it needs a key.>
import { searchTimeoutSignal, type SearchAdapter, type SearchResult } from "../search.js";
interface ExampleItem {
link?: string;
name?: string;
blurb?: string;
}
export class ExampleSearch implements SearchAdapter {
readonly name = "example";
// Drop the constructor arg if the backend needs no key.
constructor(private readonly key?: string) {}
async search(query: string, limit: number, signal?: AbortSignal): Promise<SearchResult[]> {
const url = new URL("https://api.example.com/search");
url.searchParams.set("q", query);
url.searchParams.set("count", String(Math.min(limit, 50)));
const headers: Record<string, string> = {
accept: "application/json",
"user-agent": "deepdive (+https://github.com/askalf/deepdive)",
};
if (this.key) headers.authorization = `Bearer ${this.key}`;
const res = await fetch(url, { headers, signal: searchTimeoutSignal(signal) });
if (!res.ok) throw new Error(`example ${res.status} ${res.statusText}`);
const json = (await res.json()) as { items?: ExampleItem[] };
return mapExampleResults(json.items ?? [], limit);
}
}
// Pure, exported for unit tests — no I/O.
export function mapExampleResults(items: ExampleItem[], limit: number): SearchResult[] {
return items
.filter((r) => typeof r.link === "string" && r.link.length > 0)
.slice(0, limit)
.map((r, i) => ({
url: r.link as string,
title: r.name ?? "",
snippet: (r.blurb ?? "").trim(),
rank: i + 1,
}));
}
// in src/search.ts → resolveSearchAdapter(name, env)
case "example": {
const { ExampleSearch } = await import("./search/example.js");
return new ExampleSearch(env.DEEPDIVE_EXAMPLE_KEY); // omit arg if keyless
}
That's the whole surface. PRs adding adapters are welcome — keep them small, keyless-by-default where the backend allows it, and tested.