dhaga.docs
Self-hosting

Providers

Extend Dhaga with your own LLM, web search, embedding model, or vector store — all through provider-agnostic gateways in @dhaga/core.

Dhaga's provider contracts are exported from @dhaga/core. A provider can live in this monorepo or in an independent npm package; application features only consume the contracts and never import a vendor SDK directly.

Register external packages in apps/web/src/dhaga.providers.ts. Next.js runs this bootstrap once before the Node.js server accepts requests. Provider SDKs therefore remain optional dependencies and are only bundled when imported.

Writing a provider from scratch?

This page is the operator-facing overview. For step-by-step guides grounded in the actual interface signatures, see Extending Dhaga — Add an LLM provider, Add a search provider, and Add a vector store.

LLM providers

Implement LLMClient, then export an LLMProvider registration:

import type { LLMClient, LLMProvider } from "@dhaga/core";

const client: LLMClient = {
  async extract(options) {
    // Call the model, then validate before returning.
    const value = options.schema.parse(await callModel(options));
    return { data: value, model: "my-model", usage: { inputTokens: 0, outputTokens: 0 } };
  },
  async complete(options) {
    const text = await callModel(options);
    return { data: text, model: "my-model", usage: { inputTokens: 0, outputTokens: 0 } };
  },
};

export const myLLM: LLMProvider = {
  id: "my-llm",
  capabilities: {
    structuredOutput: true,
    vision: false,
    webSearch: false,
    batch: false,
  },
  isConfigured: () => Boolean(process.env.MY_LLM_API_KEY),
  createClient: () => client,
};

Register and select it in the bootstrap:

import { registerLLMProvider, selectLLMProvider } from "@dhaga/core";
import { myLLM } from "@your-scope/dhaga-llm";

registerLLMProvider(myLLM);
selectLLMProvider(myLLM.id);

Selection can instead use LLM_PROVIDER=my-llm. A provider declaring batch: true must also expose createBatchClient; invalid registrations fail at startup.

The built-in openai provider accepts OPENAI_BASE_URL, so many local or OpenAI-compatible servers need no plugin. Compatible servers must support the structured-output behavior used by extract, not only basic chat completions.

Search providers

Search has a deliberately small contract:

import type { SearchProvider } from "@dhaga/core";

export const searxng: SearchProvider = {
  id: "searxng",
  isConfigured: () => Boolean(process.env.SEARXNG_URL),
  createClient: () => ({
    async search(query, options) {
      // Return normalized { title, url, snippet } records.
      return searchSearxng(query, options?.limit ?? 5);
    },
  }),
};

Use registerSearchProvider, then either selectSearchProvider("searxng") or SEARCH_PROVIDER=searxng.

Two providers ship built in:

idImplementationConfigured when
anthropicAnthropicSearchClient — Anthropic's own server-side web_search tool, run on the extract-tier modelANTHROPIC_API_KEY is set
firecrawlFirecrawlSearchClient — Firecrawl's search APIFIRECRAWL_API_KEY is set

Which one runs, most specific first: an explicit selectSearchProvider(id) → SEARCH_PROVIDER → FIRECRAWL_API_KEY set ? firecrawl : anthropic.

So Firecrawl stays the provider on any instance that actually configured it — an existing self-host keeps its behaviour with no config change and no surprise switch to a differently-metered one. Everywhere else the default is Anthropic, which means hasSearch() is true on any instance that already set ANTHROPIC_API_KEY for the other AI features. With neither key set the default is still anthropic, so the error a caller eventually sees names ANTHROPIC_API_KEY — the key that would actually fix it.

Wired, not yet proven

The anthropic provider has never been exercised against a live key: it typechecks and is unit-tested against recorded response shapes, but no end-to-end search has been observed. It is also billed twice over — $10 per 1,000 searches and every retrieved page charged as input tokens to the searching model — so the published cost figures for the watchlist sweep are estimates, not measurements.

Reporting what a search cost (optional)

A provider may additionally implement MeteredSearchClient, which adds one method returning the results and their cost:

import type { MeteredSearchClient } from "@dhaga/core";

const client: MeteredSearchClient = {
  async search(query, options) { /* … */ },
  async searchMetered(query, options) {
    return {
      results: await this.search(query, options),
      // `model` + `tokens` only when the search ran THROUGH a model.
      usage: { searches: 1 },
    };
  },
};

This is a capability, not a requirement — 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 still needs zero changes anywhere else.

It exists because the built-in providers cost money in different currencies: Firecrawl bills a flat subscription outside Dhaga's metering entirely, while Anthropic's web search charges retrieved pages as model input tokens — a real inference bill the instance dollar ceiling has to see.

Judgment (Jev)

Some Claude calls ask a classifier-shaped question — yes/no, pick-one, a score. When JEV_API_KEY is set, TypeSafe's Jev model (pinned jev-1.13.0, typesafe.ai) is asked first, and its answer is used only when it can produce the whole existing output with no generated text at or above a confidence threshold; otherwise the Claude call runs unchanged. Unset, those features simply run their Claude call — nothing breaks, nothing is lost, and nothing is sent to TypeSafe. Set, the classification inputs (contact names, companies, message text) are.

The key is server-only (never NEXT_PUBLIC_). Jev bills $0.042 per million input tokens, output free, and is metered in the same ai_actions row as the action's Claude call — in its own jev_input_tokens / jev_output_tokens columns, so its tokens are priced at Jev's rate rather than Claude's and its cost can be reported apart from Anthropic's and OpenAI's. The gateway lives in packages/core/src/judgment (deep-import @dhaga/core/src/judgment); the app entry point is askJev in apps/web/src/lib/ai/jev.ts, with constants in apps/web/src/utils/constants/jev/.

The API root is pinned in code (JUDGMENT_BASE_URL, packages/core/src/judgment/constants.ts), so a stray TYPESAFE_BASE_URL cannot send request bodies or the key to another host, and the client is built once per key value and cached. On a cold serverless instance the first Jev call also pays connection setup and can run past its share; it then falls back to Claude, which is safe — only that call's saving is lost.

Every response is checked against the questions asked before any caller sees it (packages/core/src/judgment/validate.ts): each key present, the right answer type, a choice among the options offered, values in [0, 1], and a usage.input_tokens that is a non-negative integer (a missing output_tokens is recorded as 0). Anything off-contract throws, and every caller turns a throw into "run the Claude path". When a Jev call and a Claude call fold into one ai_actions row, the row is labelled with its first non-Jev model.

The nightly and "get results now" jobs that judge one subject at a time — person-vs-service classification and the contact and company sector passes — go through apps/web/src/lib/jobs/jev/ instead: bounded concurrency (JEV_JOB_CONCURRENCY), a per-job budget of Jev-only seconds (*_JEV_BUDGET_MS, after which the rest go to Claude), and per-subject thresholds (service needs ≥ 0.99, the other kinds ≥ 0.8). The pre-pass never throws: a failure sends every row to the Claude batch. Jev never decides the sector passes' paid web-lookup offer (unknownCompany / unrecognised stay Claude's) — except for a contact with no employer, where no lookup could be offered, so a confident "cannot be placed" settles as Claude's null would. Their Jev usage is metered with the job's own rows: a sector chunk stays one row (one credit) whoever answered it, and a chunk whose Claude call failed is written from Jev's placements but not billed (lib/jobs/jev/sector-merge.ts). The Jev settle units — both sector passes and person classification — meter, write and stamp in one real transaction on a core-only self-host too (lib/jobs/in-transaction.ts), where a scoped unit alone would autocommit.

A nightly Jev budget counts only time spent in Jev. Each one is a JevBudget pot (lib/jobs/jev/budget.ts) charged the union of in-flight time: overlapping calls are charged wall-clock once, and the searches, reads, backfills and Batch submits between passes cost nothing. Each job's pot — person classification 30s, contact sectors 20s, company sectors 20s, signals 20s, goal matching 20s — is split fairly across the tenants it sweeps: each tenant gets an equal share of what is left (the job's pot or the run's below, whichever is smaller), so a quiet tenant's unused time rolls forward to the rest. Every pass is still clamped by, and charged to, the whole nightly api/jobs/daily run's shared 60s budget (DAILY_JEV_TOTAL_BUDGET_MS, lib/jobs/jev/run-budget.ts). Once a pot is spent, the remaining subjects go to Claude exactly as with no key. The "get results now" passes and the paid goal resolve run on an 8s wall-clock deadline instead, with the user waiting.

The nightly Jev passes need a batch-capable LLM provider, like the jobs themselves. With a provider that has no Batch API, person classification, both sector passes, signal detection and goal matching do not run, Jev included, exactly as before Jev. Without a batch a job cannot persist the rows Jev is unsure about (or a sector chunk it only partly settles), so Jev would re-ask and re-bill the same rows every night.

Goal matching (both passes: the paid "Request now" resolve and the nightly top-up), signal detection and the sector web lookup also call Jev through judgeEach (apps/web/src/lib/jobs/jev/judge-each.ts), which holds no DB connection and hands usage back for the caller to meter in one scoped statement. There Jev can only say no: reject a goal candidate (p(match) ≤ 0.02), report the company "not found" (skip at p(found) ≤ 0.02), or find no update about the contact (skip at p(update) ≤ 0.05, SIGNAL_JEV_MAX_UPDATE_PROBABILITY, within the job's 20s of Jev-only time, SIGNAL_JEV_BUDGET_MS). Each bar is compared to the probability directly. Every match, found company and signal is still Claude's. A Jev goal rejection writes no goal_members row, so the candidate is re-screened next run. A "Request now" run in which Jev rejected every candidate reports no_candidates and is not charged; its Jev calls are recorded as 0-credit goal_matching rows. A run whose Claude calls all failed returns failed, uncharged, even if Jev rejected some candidates. Signal detection skips the Claude classifier for a search that returned no results with or without JEV_API_KEY — code answers that one; the contact is still stamped scanned and its search metered.

Web enrichment (person and company) asks Jev whether the saved findings state anything at all before the Claude extraction (lib/ai/note-extraction/research-gate.ts). At p(found) ≤ 0.02 the extraction is skipped and an empty extraction runs through the same apply phase: the same rows, the same outcome, and still one 20-credit enrichment action. The web search always runs. Connector enrichment is never gated — its findings note is built by code from provider fields. Eval: 30 synthetic findings, 0 false skips, 10 of 13 found-nothing notes skipped.

An empty-note gate for the Claude note extraction is built but ships OFF (EMPTY_NOTE_JEV_GATE_ENABLED in utils/constants/jev/empty-note.ts), so every note reaches Claude exactly as before and Jev is never asked. Switched on, it would ask Jev whether a user's short note (up to 280 characters) holds anything extractable and, at p(extractable) ≤ 0.2, run an empty extraction through the same apply phase instead (same rows, outcome and credits), with Jev running before Claude on a 1s share. It stays off because it failed the owner's ship rule of zero skipped notes that the real Claude extraction finds anything in: in a live eval of 63 synthetic notes through the production extraction call (false skips in 2 of 4 runs, always this note), Jev scored "🎂🎉" at p 0.05, as low as the truly empty notes, while Claude extracted a birthday fact and tags from it. No threshold separates that note, so it would be a false skip at any bar. Re-run note-extraction/__tests__/empty-note-gate.live.test.ts (JEV_LIVE_EVAL=1) twice with zero false skips before flipping it.

The messaging router (lib/ai/message-intent/) and the list-referent call (lib/ai/list-referent/) ask Jev inside the chat reply's deadline. Jev gets a 1s share (MESSAGE_INTENT_JEV_SHARE_MS, LIST_REFERENT_JEV_SHARE_MS), and Claude then gets exactly its own timeout (6s router since 2026-09-24, 10s referent): the worst case is 1s slower than Claude alone, and with no key the timing is unchanged. Besides capture / unclear, the router lets Jev answer list_add when the message names exactly one existing, non-archived list whose name does not end in "list" (code fills listName), or a new list whose name Jev picks from spans code found; snooze when the message states one duration code can read ("in 10", "10 mins", "an hour" — code does the arithmetic, still one Jev call) or asks for next morning; reminder when code reads one duration and cuts the subject; and a pure one-clause question (the message's own words). Every field is copied or code-read. A live eval against real Claude (two runs, 88 messages) found 0 misroutes on these. The correction gate is built but OFF: Jev and Claude disagreed on the correction kind. A message that is exactly one link skips the router entirely (lib/messaging/inbound/bare-link.ts): it goes to capture with no model call, with or without a key.

A free-text reply to "who was that note about?" is a third webhook Jev share (lib/messaging/pending-question/agent-answer/jev-gate.ts). Before the agent loop runs, Jev asks whether the reply answers the question; at p(yes) ≤ 0.1 the loop is skipped and the reply is handled as the loop's own non-answer, storing the same turn. It runs inside the batch_plan action on a 1s share. Eval: 210 real answers with 0 false skips; 53 of 64 non-answers skipped.

The turn after a filing is a fourth (lib/ai/agent/after-filing.ts). After the agent loop files a note, Jev asks whether the user's message asks for any OTHER change; at p(yes) ≤ 0.1 the loop ends with the same state the discarded extra turn would have produced by answering. Capture path only, not the pending-question answer; 1s share. Eval: 0 of 70 false skips, and 16-18 of 22 plain notes (about 77%) skip the turn. Known gap: a request made in an EARLIER chat message is not shown to Jev.

A forwarded photo's card scan is a fifth (lib/messaging/process-session/derive-image-card.ts). With a key, the transcript read runs first, then Jev on it: isCard ≥ 0.8 and hasPerson ≥ 0.9 → a card; hasPerson ≤ 0.1 → a photo note; anything else runs the fields vision read as before. With no key, both reads run in parallel exactly as before. A settled scan takes about as long as before (transcript + Jev). When Jev is unsure or fails, the reads run one after another (transcript, then Jev for at most 1s, then fields), so the scan takes roughly transcript + fields instead of the slower of the two: about +3s per photo on the measured cards. Billing matches the old scan exactly: one card_scan row (one credit) with Jev's tokens in it when the scan succeeds, nothing when it fails. Eval: 55 texts × 2 runs, 0 confident disagreements with the fields read.

Ask Dhaga is a sixth (lib/ai/search/code-answer/), on the chat webhook and the streamed palette alike. One Jev call asks what shape the question is and which of the user's own events and companies (shortlisted by code) it names; Jev and the read of those names share 1s (ASK_CODE_JEV_SHARE_MS). Two shapes are then answered by code from the database, with no Claude call: the people linked to one event and/or company ("who did I meet at X", "who works at Y"), and when the user last logged contact with ONE person that code finds by name (newest note they wrote, shared meeting note, or "I reached out", dated in their zone). Tags are never listed this way: a tag list misses the untagged people Claude's answer finds. Any other question, any answer under 0.8, a Jev failure or timeout runs the Claude plan and answer unchanged; with no key there is no extra read or call. A code answer is one search action (2 credits), the same as a Claude answer. Live eval against real Claude, two runs of 66 questions: 21 answered by code each run, none worse than Claude's answer, and "when did I last…" right 5 of 5 where Claude managed 0. Jev takes ~350ms warm but ~1.2s on a cold connection, and past the 1s share the question simply runs on Claude. Showing Claude the full, dated note text instead of search highlights was tried and is OFF (ASK_EVIDENCE_ENABLED): it scored below the highlights on the same eval. Quick-add's text parse (contact_parse, lib/ai/contact-extraction/) is a seventh, and it asks Jev to pick fields, never to write them. Code finds every candidate span (emails, phones and URLs by regex; lines, comma parts and word runs, lower-case chat included), and one Jev call picks the name, title, company and location from those spans or "none", and says whether the text is a note about a person and whether it is an instruction. Jev's output is used only when every answer is at least 0.8; otherwise Claude runs as before and its whole output is used, never a mix of the two. A capture goes straight to Claude when an email or phone has words beside it (a label such as "Mobile", which Claude copies and Jev is not asked for) or has more than 200 spans, and after Jev when it reads as an instruction or as a note with no name. On the Jev path a note's body is the capture verbatim, the text the note router already falls back to. Either way it is one contact_parse row and one credit; with no key it is the old Claude path. Mobile's on-device OCR text uses the same call; card images stay on Claude vision. Eval (cascade.live.test.ts, run with JEV_LIVE_EVAL=1), 2026-09-24: Jev's accepted answers were whole-record correct on 97% of the dev set (Claude skipped on 34 of 67) and on 100% of the held-out set (10 of 24). The Claude baseline and the cascade-vs-Claude comparison ran in two live runs the same day: whole-record, the cascade scored 90% and 88% on the dev set against Claude's 84% and 81%, and 92% and 88% on the held-out set against 79% and 71%, skipping Claude on about half the captures. It met the ship rule (the cascade at least as accurate as Claude on both sets in both runs), so it runs whenever JEV_API_KEY is set.

Embedding providers and vector stores

Embedding generation and storage are independent plugins. Their declared dimensions must match; Dhaga checks this before indexing or searching and reports both provider ids and dimensions on mismatch.

An EmbeddingProvider implements embedDocuments and embedQuery. A VectorStore implements deterministic upsert, similarity search, existence checks, and deletion. Owner pairs (ownerType, ownerId) are stable keys, so upserts must be idempotent.

Register them with registerEmbeddingProvider and registerVectorStore, then select them in code or with:

DHAGA_EMBEDDING_PROVIDER=my-embeddings
DHAGA_VECTOR_STORE=my-vectors

VectorWriteOptions.transaction is an optional store-specific handle. The built-in pgvector implementation uses it to keep relational tombstones and vector deletion atomic. External services cannot participate in a Postgres transaction and should make deletion idempotent and retry-safe.

The built-ins remain local-huggingface (384 dimensions) and pgvector. Setting DHAGA_VECTOR_STORE to an external store also skips pgvector extension and table setup, so the relational database no longer needs that extension.

Relational database boundary

The application datastore is currently PostgreSQL-compatible, supporting embedded PGlite and hosted PostgreSQL through the same Drizzle repositories. It is intentionally not advertised as a generic database plugin: repositories, full-text indexes, authentication, RLS, and migrations rely on PostgreSQL semantics. Vector data can be moved to an external engine independently.

Provider checklist

  • Keep vendor SDK imports inside the provider package.
  • Validate structured model output with the supplied Zod schema.
  • Return normalized usage even when the vendor omits token counts.
  • Declare only capabilities that the implementation actually supports.
  • Make vector upsert and deletion idempotent.
  • Test disabled/unconfigured behavior and malformed provider responses.
  • Run npm run typecheck --prefix packages/core and the provider registry tests.

On this page