Guide for AI coding agents working in this repo. Read this first, then the
relevant openspec/changes/*/design.md before touching code.
The open connector standard for Monid. Connectors are
authored in TypeScript (defineProvider / defineEndpoint: zod schemas + a few
small, closed-term functions), compiled into inert JSON EndpointDocs + a
content-addressed fnTable, and executed by a small generic engine. Two
released artifacts:
engine/— the generic connector engine (@monid/connector-engine)..output/catalog.json— the compiled connector bundle (built by CI, never committed).
The legacy imperative provider adaptors live in the sibling repo
monid-services (services/shared/providers/adaptors/*); connectors are being
migrated here change-by-change.
connectors/<name>/ # provider.ts + endpoints/<e>/{endpoint.ts, schema/, endpoint.test.ts, fixtures/}
engine/ # load -> link -> execute; transports; host ABI (ctx.utils)
shared/core # THE contract: def/doc/hook/bundle zod schemas, presets
shared/compiler # pure Def -> Doc mapping, fn normalization + interning
shared/testing # testSealedUnit / runEndpoint, fixture record+replay
shared/{logging,app-config} # logger + layered config
scripts/ # CLI entrypoints (compile, run, catalog, record, version-check)
openspec/ # spec-driven changes; decision record in changes/*/design.md
config.yml # schema.*/compiler.* = CONTRACT (no env overrides); engine/scripts = tooling
deno task check && deno task test # types + replay tests (zero network)
deno task test:live # live tests; auto-skip without <PROVIDER>_CREDENTIALS_<FIELD>
deno task engine:run 'exa#search' --body '{...}' # JIT-compile + execute one endpoint
deno task catalog providers|endpoints|inspect <id>
deno task record <id> ... # record real fixtures (headers dropped)
deno task compiler:compile # full-repo deterministic compile
deno task version:check # semver gate for ABI/format changes
deno task apify:scaffold <actorId> # authoring-time actor input-schema scaffold- Determinism: the compiled bundle is a pure function of repo content.
schema.*/compiler.*config loads override-free; compile iterates sorted; hashing is RFC 8785. Double-compile must be byte-identical. - Closed-term fns: hook functions have no imports/captures (TS-AST linted;
whitelisted pure globals only). All IO flows through the engine's ONE
transport port — the pure hooks do no IO at all; the three lifecycle hooks are
effectful-by-capability via
utils.http/utils.request(auth injected at egress ONLY for same-origin targets — D16; fns never see credentials). Cosmetic edits must not change a fn hash (normalization guarantees this). - Hooks (nine): six PURE —
auth.inject,input.toRequest,usage.consolidate,usage.estimate(pre-run cost, no IO),output.fromResponse,output.fromError— plus the EFFECTFUL lifecycle family —lifecycle.start/poll/stop(async run protocol; monid-servicesrunLifecycle-shaped). One fallback rule: endpoint ?? provider ?? config default, leaf-wise, closest wins. - Async protocol:
requeststays REQUIRED and is DATA into the lifecycle (ctx.data.request);lifecycle.start(when present) replaces the engine's declarative execution and returnsRUNNING{state?}|COMPLETED{httpStatus, output, state?}(RunKind, UPPERCASE) — WHOLE-STATE semantics: a presentstateIS the complete next fn-state (replaces wholesale), an absent one carries the previous forward (no field merge — D21). State is STRUCTURED (zRunState): fn-ownedexternalRunId/stage/data(ids + billing signals; typed per doc vialifecycle.state→stateSchema) + ENGINE-ownedtiming(the ClickHouse provider slices;RunCompleted.timingreports at settle, sync runs included) — engine-capped (schema.state_max_bytes).timeouts.pollMsis the cadence default, per-tickpollAfterMsoverrides. Every doc floors atschema.fn_abi_since. - The def IS the rate card (D26 — reverses D18):
usage.modeldeclares the billing ALGEBRA per endpoint — LEAF (FREEnever-bills /PER_CALLflat /PER_UNITmetered, optional block-rateevery, default 1) and AND (COMPOSITEwith components keyed by OUR snake_case ids; the vendor's native spelling, when it differs, is the line'svendorFIELD — the join isvendor ?? id) — and every billable line pinsconsumes: {credit, amount}against a credit system declared inusage.credits(resolved KEY-WISE, endpoint over provider — the pool SET is a provider-wide fact: a provider declares every pool its account meters, single-pool providersdefault, a dollar-priced vendor's pool IS dollars, pdl one perx-call-credits-type; each declared pool must be DRAINED at its declaration site — a provider's by ≥1 endpoint, an endpoint's by that endpoint — and the compiled doc narrows to the pools its own lines drain, D6). Conditions/offsets/selection are COUNTING rules owned by consolidate/estimate, never model shapes (D19 — no VARIANT/TIERED kinds). The settle splits into TWO one-job fns (D27, subclassing: the PROVIDER states the default, the ENDPOINT overrides only what diverges):usage.evidence(envelope →{counts}— per-line quantities, estimate's settle-side twin, endpoint-divergent) andusage.consolidate(envelope →{credits, output?}— the VENDOR'S OWN meter lifted out of the payload in one motion viautils.json.pluck, provider-uniform, OPTIONAL: not every vendor reports one). Compiled doc: model/credits/estimate/evidence REQUIRED — for meterless models (FREE/flat) the compiler SYNTHESIZES the one lawful() => ({counts: {}})(core#usage.synthesizedEmpty, one shared fnTable entry); metered models must resolve both quantities fns. Estimates read the PRE-toRequest validated input (typed body AND queryParams — D25). - Input fidelity (D25):
schema/inputs.tsis the faithful vendor mirror — optionality only, no.default(), unquoted identifier keys. ALL tightening lives at the BINDING, derived:zBody.required({limit: true})(primary limiting knobs — the caller states the cap) /.extend({f: shape.f.unwrap().default(n)})(behavior knobs, verified actor defaults) /.unwrap().min(1)floors ONLY where the vendor documents 0 = unbounded. Multiplier arrays are never tightened — optional arrays readarr?.length ?? 0(honest optionality); empty input ⇒ estimate 0. Never invent structure the mirror doesn't have. Quantities fns return{counts}— per metered line only (no rate math, no receipt plumbing). The ENGINE owns the fold (D26, at estimate AND success settle): it appends the model's flat 1s (composite flats under their line id; leaf PER_CALL under the reservedCALLkey — not a Unit; fns never write flat keys, type + runtime rejected) and foldsceil(quantity / every) × consumes.amountper line into the publicusage = {credits, evidence}. Then the VENDOR'S CLAIM settles (D27): the consolidate fn's non-emptycreditsWINS (source of truth — zero entries prune, unreported entries are OMITTED, never?? 0; an empty claim falls back to the derived fold), pool ids must be declared credit systems (FN_CONTRACT), and a disagreement beyond 1e-9 rides out asusage.mismatch.derived— our fold, said and logged, never failing the run. Anyone holding the doc re-derives the fold from evidence × rates. Error settles are{credits: {}, evidence: {}}— no meter read. Scalars carry optionallabels ("base fee", "reviews") — display metadata; the id is the join. ≥2 metered components require doc-level fns (compile-checked); estimates are DEDUCED, never defaulted: no fallback constants — limiting knobs are either actor-verified schema.default(n)s (materialized into body AND queryParams before any hook) or REQUIRED at the binding site (zBody.required({...})in endpoint.ts; schema files stay actor-faithful). The engine validates fn counts against the model at settle AND estimate (FN_CONTRACT) before the fold.deno task engine:estimateprints just the answer —{credits, evidence}. Drift vs tests (D28/D29):deno task drift [--provider] [--fix]is THE vendor-world guard — per-provider suites (scripts/drift/) poll published surfaces; the apify suite checks pricing (regime/shape/derived join/pinned Business-tier rates — against the EFFECTIVE pricingInfo, latest startedAt <= now, with scheduled-pin reconciliation: a pin matching an upcoming price passes with an UPCOMING notice), COVERAGE (every published billable event is modeled or in the documented EXCLUDED map — the D29 completeness rule: an input-gated line the model omits makes estimates silently wrong the moment that input is used), input schemas (live.required ⊆ compiled.required), and output schemas (report-only) in one pass, scheduled weekly (.github/workflows/drift.yml). Fix policy:--fixre-scaffolds drifted schemas (generated; git diff reviews) and writes rate drift + scheduled changes to .output/drift-repin.json — pinned amounts are never auto-rewritten. Where an actor publishes storages.dataset.fields, the doc carries a scaffoldedoutput.schema— passthrough documentation (all-optional, item.or(record): validation cannot fail a paid run). Providers without a machine-readable surface are guarded by test:live + the D27 per-run mismatch signal (the runner says so); the mismatch signal also cross-checks apify's pinned rates on EVERY run. - Typed authoring (D19a/D23):
defineEndpointis generic over the model, the input body schema, and the lifecycle state schema — counts keys narrow to the model's LITERAL metered keys,data.input.bodyisz.outputof the doc's OWN schema, and the fn-ownedstate.databag is typed at read AND write sites whenlifecycle.stateis declared. The rule: TYPED where a doc-declared, engine-validated schema exists (body, lifecycle.state); Json where raw (vendor output, error envelopes) — worked withutils.json. There are NO estimate presets — a typed inline fn on the doc IS the typed preset (preset field args were unchecked strings); presets survive only at provider seams (presets.auth.*,presets.usage.perCall). Ctx facts live at provenance-named paths:data.usage.model,data.lifecycle.state. - Billing before presentation:
usage.consolidateis OPTIONAL (D27 — not every vendor reports a meter; clay, pdl and tinyfish ship without one) and, when present, runs on the RAW response envelope BEFOREfromResponse— presentation changes can never change a bill. Vendor non-2xx is DATA (zero usage), not an exception; lifecycle fns synthesize error statuses for in-body failures and the engine zero-bills every non-2xx envelope (a fn cannot bill an error). - Versioning: every doc carries compiler-derived
minEngineVersion. Connector-only changes never bump the engine. Any hook-ABI or doc-format change requires anENGINE_VERSIONminor bump +doc_format_since/fn_abi_sincefacts inconfig.yml, guarded bydeno task version:check. - Tests run the artifact:
testSealedUnit(id)compiles the whole repo and tests the sealed unit (doc + its fn entries), replayingfixtures/*.json. Live tests gate on the credential env convention (below); synthetic fixtures carry asynthetic-filename prefix until real recordings exist. Fixtures are MINIMAL SHARED CHAINS (fixture strategy v2): provider-levelconnectors/<provider>/fixtures/<shape>.jsonwith a requireddescriptionand{{request.url}}/{{request.origin}}bindings — one chain serves every endpoint.recordtrims (arrays/strings capped) and ALWAYS scrubs PII; the fixture-size lint bounds files (warn 32 KiB / fail 128 KiB).
openspec/ is spec-driven: every non-trivial change gets
openspec/changes/<name>/{proposal.md, design.md, specs/<capability>/spec.md, tasks.md}.
The decision record (D-numbered) lives in design.md — read
openspec/changes/define-endpoint-doc-and-engine/design.md (D1–D29) before
extending the schema or engine. openspec/specs/ is populated on archive.
The async run protocol (D10/D29's reserved surface) is IMPLEMENTED — see
openspec/changes/add-async-run-protocol/design.md. Still reserved: resources
(removed in D19), metered/accruing endpoints, declarative poll/stop phase arms,
stop result reporting — "return with a concrete need, as their own change".
A connector port that needs no schema/engine change carries no design.md: the
proposal plus specs/<capability>/spec.md and tasks.md are the record (see
openspec/changes/add-connector-firecrawl/). Reach for a design.md only when
the contract itself moves.
- Deno 2 workspace; fmt
indentWidth: 4; import aliases@shared/<name>. - Endpoint ids are
<provider>#<path minus its leading slash>, where the path is the def'sendpoint??request.path(design D22). Folder names are ORGANIZATIONAL only — they must be unique per provider, but they are never identity. Declareendpointwhen the native path is transport plumbing (apify's actor slug), empty (tinyfish), or SHARED by two defs (contactout's work/personal twins, where omitting it collides). - Credentials, one convention: each field of a doc's
auth.credentialsreads from<PROVIDER>_CREDENTIALS_<FIELD>— dashes and camelCase humps become underscores (contactout+workApiKey⇒CONTACTOUT_CREDENTIALS_WORK_API_KEY). One alias, for the near-universalapiKeyfield alone: the bare<PROVIDER>_API_KEYstill answers, and the canonical name wins when both are set. A variable set but EMPTY is a config error surfaced asMISSING_CREDENTIALnaming it, never a silent fallback. Same spelling as monid-services'AppConfigpath→env derivation, so local env and hosted config agree. - New hook = contract file in
shared/core/schema/hooks/+ section carrier + doczFnRefslot +fnKeysOfentry +linkFnsbranch + engine phase + version bump. Follow the existing pattern end-to-end. - Commit style:
<type>(<scope>): <subject>(e.g.feat(engine): ...,feat(connectors): ...,docs(openspec): ...).
Hard-won, each one costs an hour if you meet it cold:
- Fn bodies are executable JS, not TS. The engine reinstantiates the source
in an empty scope, so a type annotation is a syntax error at run time. That
collides with
noImplicitAny: a standalone helper lambda (const has = (name) => …) has no contextual type and failsdeno check, while the SAME logic inline in a.map()/.some()/for…ofinfers fine. Write the inference-friendly form —const names = xs.map((x) => typeof x === "string" ? x : x.type)thennames.includes(…)— rather than reaching for a cast. - Closed-term globals are a whitelist (
shared/compiler/lint.ts):JSON,Math,Object,Array,String,Number,Boolean,Error,Promise,encodeURIComponent. NotablyURLis not on it — parse a host with string ops, notnew URL(). utils.json.omitis DEEP,pluckis EXACT. To lift one receipt field out of a payload usepluck(output, "$.field")—omitwalks the whole tree and will also strip an identically-named key nested inside the data.- Identical fn sources intern to ONE fnTable entry. Closed terms cannot
import a shared helper, but duplication is free when the sources are
byte-identical after normalization — so derive per-endpoint differences from
ctx (
data.request.url + "/" + id) instead of hardcoding a path, and the three copies collapse to one entry. Assert it in a test;deno task fmtwill not break it (normalization strips formatting). - Provider-level hooks fall through to EVERY endpoint. A provider
lifecycle.startreplaces declarative execution on the provider's synchronous endpoints too. Mixed sync/async providers must author the lifecycle on the async endpoints. - Fixture
{{request.url}}bindings substitute in recorded REQUEST urls only (replayFetch), never in response bodies. An opaque cursor a fn reads back out of a body (a paginationnext) must be a literal absolute url in the fixture. - A vendor cursor is not a caller-usable url. If following it needs the
credential the engine holds, handing it back hands the caller a URL they
cannot fetch. Two ways out, and it is a real trade. The fn FOLLOWS the cursor
— one complete result set, at the price of stitching an unbounded payload into
a single output and hiding the vendor's paging. Or the connector PASSES it
through and exposes the vendor's own reader as its own endpoint — bounded
outputs and honest paging, at the price of a second call. If you pass it
through, the output must also carry whatever that reader needs as a path param
(usually the job id), or the unusable cursor is the caller's only handle.
Firecrawl takes the second route:
#crawlreturnsnextuntouched beside the jobid, and#crawl/{id}reads the rest for free.