Skip to main content
Two substrates run integrations: hosted (Cloudflare) and local (Node + Postgres). The choice is per-deployment, set by the operator via MARFA_INTEGRATION_RUNTIME — it’s not per-Connection and it’s not per-Integration. Integration code (the handlers.ts module) is portable: the same code runs on either substrate without modification. Where they differ is the operational shape underneath the SDK. The manifest’s runtime_compatibility enum also accepts self-hosted as a third tier. No wired substrate currently dispatches against it; see Integrations for the manifest-level vocabulary.

The two substrates

Hosted — Cloudflare

The default for production Marfa. Each integration deploys as a Cloudflare Worker. Per-Connection state lives in a Durable Object. Scheduled polls fire from Cron Triggers; reactive runs and inbound webhooks flow through Cloudflare Queues. Inbound webhook receipts hit a single control-plane Worker (@withmarfa/runtime-control) which verifies the signature and routes onto the right per-Integration queue. Set with MARFA_INTEGRATION_RUNTIME=hosted. Requires CLOUDFLARE_QUEUES_REACTIVE_RUN_URL + CLOUDFLARE_QUEUES_API_TOKEN for the reactive-run bridge to actually fire (otherwise the bridge boots in disabled mode and only schedule + inbound-webhook traffic reaches the Workers).

Local — Node + Postgres

The substrate for self-hosters who don’t want a Cloudflare account in their stack. The same handler code runs in a worker_thread pool inside the Marfa server process; per-Connection state lives in the connection’s connection.runtime extension; pg-boss drives the cron and queue layers off the Postgres instance the rest of the server already uses. Inbound webhooks hit POST /runtime/webhook/:connection_id on the Marfa server directly — same verification adapters (@withmarfa/webhooks) the hosted substrate uses, just no remote hop. Set with MARFA_INTEGRATION_RUNTIME=local. Requires Postgres — SQLite self-hosts must keep =hosted until they migrate. Operator footprint is two containers: server + Postgres. No Redis, no extra binary, no Postgres extension dependency.

Where they differ — semantic parity sheet

The substrates are designed for parity at the handler-author surface. The differences below live underneath the SDK and rarely surface to integration code; they exist as documented operational realities rather than gotchas.

Picking a substrate

You don’t pick per-Integration — the manifest’s runtime_compatibility field declares which substrates an integration supports, and the operator’s MARFA_INTEGRATION_RUNTIME setting picks which substrate the deployment runs.
  • Production-managed Marfa runs hosted against the platform’s Cloudflare account. Integrations declaring runtime_compatibility: ["hosted"] or ["hosted", "local"] are eligible.
  • Self-hosted Marfa defaults to local so a fresh docker compose up works without a Cloudflare account. Operators who want Cloudflare can set MARFA_INTEGRATION_RUNTIME=hosted and wire the Cloudflare bridge env vars; integrations declaring ["hosted"] then become eligible.
  • The sync integration declares runtime_compatibility: ["local"] only — it has no hosted path.

Local substrate operator notes

  • MARFA_INTEGRATION_RUNTIME=local (or the env var unset — local is the default) initialises pg-boss against DATABASE_URL and registers handlers from every integration’s dist/local.js. Set the var to hosted to delegate to the Cloudflare bridge instead.
  • pg-boss creates its own schema (pgboss by default) inside the same Postgres instance. No extra database, no extra credential.
  • The reactive-run bridge subscribes to the in-process pubsub — the same firehose the Cloudflare bridge consumes when running hosted. One substrate runs per server; the MARFA_INTEGRATION_RUNTIME value selects which.
  • POST /runtime/webhook/:connection_id is mounted publicly (no bearer token); verification happens inside the route via the subscription’s HMAC secret. The route lives only when MARFA_INTEGRATION_RUNTIME=local; with hosted, inbound webhooks continue routing through the Cloudflare control plane.
  • Per-Connection state lives under the connection.runtime reserved extension on the connection’s system.connection item. The hosted substrate uses the same namespace, so handler-visible state has the same shape on either substrate.
  • Workers crash → pool replaces. A handler that throws repeatedly takes its worker_thread with it; the next dispatch lands on a fresh thread. Other integrations’ pools are untouched.

Authoring against either substrate

The handler module is the same on both substrates. Per-substrate divergence lives in the entry files:
  • handlers.ts — registers schedule, webhook, and item-event handlers. Substrate-agnostic.
  • manifest.ts — exports the IntegrationManifest, including runtime_compatibility.
  • worker.ts — Cloudflare Workers entry. Re-exports PerConnectionState plus the handlers.
  • local.ts — local-runtime entry. Registers handlers and exports the manifest.
Both entry files call registerHandlers() from the integration’s own handlers.ts, which seeds @withmarfa/runtime-sdk’s in-process handler registry. The substrate then dispatches against that registry. Substrate-specific concerns — Durable Object storage proxies, worker_thread boot, queue wiring — sit outside the handler module entirely. Importing @withmarfa/runtime-sdk/cloudflare from inside handlers.ts defeats portability. Substrate-specific imports belong in worker.ts.