Add a search provider
Implement SearchClient and register a SearchProvider so Dhaga's news and job-change watchlist runs on your backend — Brave, SerpAPI or 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;
/** The user's calendar day. Only a provider that searches THROUGH a model
* has anywhere to put it; a search endpoint ignores it. */
today?: CalendarDay;
}
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 LLMClient and register an LLMProvider to route Dhaga's extraction and reasoning to your model — Ollama, a BYO-key or 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.