dhaga.docs
Extending Dhaga

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=searxng

Selection 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.

On this page