Add a connector
Implement the ConnectorProvider interface and register it, so an enrichment source you already pay for gets its own button beside "Enrich from public web".
A connector is a credential the user owns that Dhaga
spends on their behalf. Two ship built in — apollo (REST, API key) and
apollo_mcp (Streamable HTTP, OAuth) — and adding another is one module plus a
registration.
The one idea worth understanding first
Enrichment is four stages, and only the third is provider-specific:
trigger → enqueue → fetch → deriveA connector replaces the fetch and nothing else. The note it saves, the extraction that turns it into facts, the receipts, the cascade delete, the export — all of that is the existing pipeline, untouched.
This is why the contract returns text, not a schema:
// packages/core/src/connectors/types.ts
export interface ConnectorFindings {
text: string;
citations?: string[];
}A parsed structure would have to be mapped, per provider, onto Dhaga's fact
model. Prose doesn't: it becomes a note, and the extraction pass already knows
how to read one. Write the text for a model to read — one claim per line,
labelled, and omit a field entirely rather than emitting null.
It's also why MCP works generically. MCP tool output is already prose written for a model, so one client serves any MCP server with no per-provider mapping.
The contract
// packages/core/src/connectors/types.ts
export interface ConnectorProvider {
id: string;
label: string;
transport: "rest" | "mcp_http";
credential: "api_key" | "oauth";
endpoint: string;
docsUrl: string;
verify(credential: ConnectorCredential): Promise<VerifyResult>;
enrichPerson(c: ConnectorCredential, subject: EnrichSubject): Promise<ConnectorFindings>;
enrichCompany(c: ConnectorCredential, subject: EnrichSubject): Promise<ConnectorFindings>;
}verify runs before a credential is stored. Return a refusal the user can
act on — and distinguish "this key is wrong" from "this account is out of
credits", because only one of those is fixable by pasting a new key.
Never put the provider's raw response body in that sentence. One reached a user as three wrapped lines of JSON quoting an account id; nobody outside the operator can act on any of it.
Implementing one
Put it in packages/core/src/connectors/<provider>/, and use the shared fetch:
// packages/core/src/connectors/http.ts
import { connectorFetch } from "../http";connectorFetch is https-only per redirect hop, hard-timeouts, caps the body as
it streams, and refuses cross-origin redirects — the request carries the
credential in a header, and following one off-origin would hand it to whoever
answered. Don't reach for bare fetch.
Then register it:
// packages/core/src/connectors/<provider>/index.ts
registerConnectorProvider(myProvider);Registration happens by import side effect, which means an import that never runs is a silent failure — the registry is empty, the settings page renders its empty state, and the build looks like it ships no connectors at all. So add the module to the one file in the app that names them:
// apps/web/src/lib/connectors/providers.ts
import "@dhaga/core/src/connectors/apollo";
import "@dhaga/core/src/connectors/mcp";Nothing else needs changing. The settings section, the buttons, the job kind, the metering and the failure copy are all provider-agnostic.
Scope your provider narrowly
An enrichment credential is usually not a read-only one. An Apollo key can carry sequence and email scopes; Apollo's MCP server exposes 50+ tools including sending mail. A connector that calls whatever it likes can act as the user.
So the built-in providers are deliberately narrow, and yours should be:
- The REST provider calls a three-path allowlist — people and organization enrichment only.
- The MCP provider pins the callable tool names when the user connects, and
a second check refuses any name containing an action fragment (
email,send,create,sequence) even when pinned. That heuristic can false-positive on a legitimately-named tool. It's the right trade: the failure it prevents is sending mail as the user.
If your provider uses OAuth
The MCP module carries reusable helpers — RFC 8414 discovery, RFC 7591 dynamic
client registration, PKCE, code exchange and refresh. Storage, the callback
route and the needs_reauth latch live in apps/web and are already generic;
you shouldn't need to touch them.
Custom endpoints are not supported yet
Every endpoint Dhaga fetches is currently a constant in code, which is why there is no SSRF guard: there is no user-supplied URL to guard.
Before shipping any provider that accepts a URL from a user, that guard is a
prerequisite — DNS re-resolution against private, loopback, link-local and
metadata ranges, not just a string check on what they typed. It's flagged in
packages/core/src/connectors/http.ts, at the place that would need it.