Add a search provider
Implement the SearchClient interface and register a SearchProvider so Dhaga's news/job-change watchlist runs against your web search backend — Brave, SerpAPI, or a self-hosted SearXNG.
Web search powers the opt-in news/job-change watchlist. It sits behind the
SearchClient gateway in packages/core/src/search/types.ts, which mirrors the
LLM gateway exactly. (It does not power enrichment: that asks the LLM
provider to run its own web search and never touches getSearchClient().)
Two providers ship built in — anthropic
(Anthropic's own server-side web_search tool, the default) and firecrawl —
and adding Brave, SerpAPI, or a self-hosted SearXNG is one implementation plus a
registration.
The contract
The search contract is deliberately tiny — normalize whatever your backend
returns into { title, url, snippet }:
// packages/core/src/search/types.ts
export interface SearchResult {
title: string;
url: string;
snippet: string;
}
export interface SearchOptions {
limit?: number;
}
export interface SearchClient {
search(query: string, options?: SearchOptions): Promise<SearchResult[]>;
}
export interface SearchProvider {
id: string;
isConfigured(): boolean;
createClient(): SearchClient;
}Reporting what a search cost (optional)
The same file exports a second, optional interface. Implement it only if your backend's searches carry a cost Dhaga's metering should see:
export interface SearchUsage {
/** Provider-side searches performed. Providers not billed per search report 0. */
searches: number;
/** Set only when the provider ran the search THROUGH a model. */
model?: string;
/** The model tokens that search consumed. Present iff `model` is. */
tokens?: LLMUsage;
}
export interface MeteredSearchClient extends SearchClient {
searchMetered(query: string, options?: SearchOptions): Promise<SearchResponse>;
}It is kept separate from SearchClient for the same Interface-Segregation
reason BatchLLMClient is kept separate from LLMClient: a SearXNG or Brave
client has no per-search inference bill to report and must not be forced to
invent one. Callers feature-detect with isMeteredSearchClient(client) and
meter only when they can, so a plain SearchClient provider stays a
first-class citizen — nothing downstream changes.
The built-in anthropic provider implements it because Anthropic's web search
charges every retrieved page as input tokens to the searching model; the
built-in firecrawl provider does not, because its flat subscription sits
outside Dhaga's metering entirely.
Implement and register
import { registerSearchProvider, selectSearchProvider } from "@dhaga/core";
import type { SearchProvider } from "@dhaga/core";
export const searxng: SearchProvider = {
id: "searxng",
isConfigured: () => Boolean(process.env.SEARXNG_URL),
createClient: () => ({
async search(query, options) {
const res = await fetch(
`${process.env.SEARXNG_URL}/search?format=json&q=${encodeURIComponent(query)}`,
);
const body: { results: { title: string; url: string; content: string }[] } =
await res.json();
return body.results
.slice(0, options?.limit ?? 5)
.map((r) => ({ title: r.title, url: r.url, snippet: r.content }));
},
}),
};
// in apps/web/src/dhaga.providers.ts
registerSearchProvider(searxng);
selectSearchProvider("searxng"); // or leave it to SEARCH_PROVIDER=searxngSelection and graceful degradation
getSearchClient() resolves the active provider from selectSearchProvider(...),
then SEARCH_PROVIDER, then a built-in default: firecrawl where
FIRECRAWL_API_KEY is set, otherwise anthropic
(packages/core/src/search/index.ts). An instance that configured Firecrawl
therefore keeps it with no config change; everywhere else search runs on the
ANTHROPIC_API_KEY the product already needs. Features that use search first
call hasSearch() — which returns the active provider's isConfigured() — and
degrade quietly when no provider is configured, so an unconfigured search
backend never crashes a request; it just turns the feature off.
Return an empty array, not an error, for no results
search() should resolve to [] when a query has no hits. Reserve thrown
errors for genuine failures (auth, network) — callers treat an empty result as
"nothing found" and an exception as "the provider is broken".
Adding Brave or SerpAPI is the same shape — a different createClient() body
and a different isConfigured() env check. See
Providers for packaging and distribution.
Add an LLM provider
Implement the LLMClient interface and register an LLMProvider so Dhaga routes extraction and reasoning to your model — Ollama, a BYO-key gateway, or any OpenAI-compatible server.
Add a vector store / retrieval provider
Swap Dhaga's embedding model and vector store independently through the retrieval gateway — any external engine, as long as declared dimensions match.