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:
id | Implementation | Configured when |
|---|---|---|
anthropic | AnthropicSearchClient — Anthropic's own server-side web_search tool, run on the extract-tier model | ANTHROPIC_API_KEY is set |
firecrawl | FirecrawlSearchClient — Firecrawl's search API | FIRECRAWL_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-vectorsVectorWriteOptions.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/coreand the provider registry tests.
Deploying
The three ways Dhaga deploys — Vercel, a single persistent server, and Docker — plus the Postgres role and pooling rules that matter in hosted mode.
Cost & scaling on AWS
A service-by-service migration map off Supabase and Vercel, a cost model at 1k, 10k and 100k users, and scaling built around the real cost: inference.