Skip to content

Writing conduits

Shipped

A conduit is the cross-source counterpart to a hook. A hook transforms a record on one source’s write path; a conduit routes and transforms a canonical record from one source into another, with the canonical row as the interchange format: “a paid Shopify order should become an Oracle NetSuite sales order,” “a NetSuite item fulfillment should become a Shopify fulfillment.” It’s authored with the same SDK, validated by the same Zod machinery, run in the same isolated-vm sandbox, and stored in the same DB-native store — a conduit is “a hook that bridges sources,” not a separate subsystem.

Both halves ship today: the authoring surface (the defineConduit SDK, the Zod schema, the registry entry, the CLI/MCP commands) and the engine that runs conduits — dispatch, binding, fan-out and conflict handling.

The SDK is the same @turbobase/extensions npm package hooks use — see Install the SDK for local typechecking (@turbobase/extensions/conduit is the conduit-only subpath export).

import { defineConduit } from '@turbobase/extensions';
export default defineConduit({
id: 'shop2ns:order', // '<from>2<to>:<record-or-scope>'
on: 'after_canonicalize_inbound', // the cross-source lifecycle event
from: {
source: 'shopify', // origin source
recordType: 'order', // canonical record type to match
when: (ctx) => ctx.payload.financial_status === 'paid', // optional gate
},
to: {
source: 'netsuite', // target source the master fans out to
recordType: 'salesOrder', // canonical record type to create
},
mode: 'dual-bind', // default; or 'one-shot'
idempotencyKey: (ctx) => `shop2ns:order:${ctx.externalId}`,
cycleBudget: 4, // default 4
conflictStrategy: 'merge', // default 'merge'
transform: (ctx) => ({
patch: {
// canonical-in → canonical-patch-out (pure)
memo: `from Shopify order ${ctx.externalId}`,
},
createdFrom: {
source: ctx.fromSource,
recordType: ctx.recordType,
externalId: ctx.externalId,
},
}),
});
Field Required Meaning
id yes Stable id; collisions are loader errors. Convention '<from>2<to>:<record-or-scope>' (e.g. shop2ns:order).
on yes The lifecycle event — after_canonicalize_inbound, after_sync_success_outbound, after_save, or after_delete.
from yes { source, recordType, when? } — which inbound record triggers it, with an optional payload gate.
to one form { source, recordType } — the target the master materializes into. One transform emits one target record type.
transform one form Pure ctx → { patch, createdFrom? }. Returns a canonical patch + the lineage pointer.
sequence the other form An ordered list of { to, transform, awaitSource? } entries, materialized one after another on the same inbound record — see several targets, in order. Declare either to + transform or sequence, never both.
mode no 'dual-bind' (default — keep both sides synced) or 'one-shot' (materialize once, no ongoing binding).
idempotencyKey no Dedups a replayed source webhook; anchors the conduit_event audit row. Convention '<id>:<externalId>'.
cycleBudget no Max-hop safety fuse on top of the loop guards. Default 4.
conflictStrategy no What happens when a fanned-out write is rejected — see conflict handling. Default 'merge'.
timeoutMs no Execution-budget override; default 200ms.
description no Operator-friendly text in the UI.
systemReserved no Managed-service guardrail instance admins can’t disable (mirrors hooks).

One record arriving in one system often has to become several records in another, and the order matters: the sales order before the deposit that applies to it, the invoice before the payment. Declare that as a sequence instead of a single to + transform:

import { defineConduit } from '@turbobase/extensions';
export default defineConduit({
id: 'shop2ns:order-and-deposit',
on: 'after_canonicalize_inbound',
from: { source: 'shopify', recordType: 'order', when: (ctx) => ctx.payload.financial_status === 'paid' },
sequence: [
{
to: { source: 'netsuite', recordType: 'salesOrder' },
awaitSource: true, // hold the next entry until NetSuite has numbered this one
transform: (ctx) => ({
patch: { memo: `from Shopify order ${ctx.externalId}` },
createdFrom: { source: ctx.fromSource, recordType: ctx.recordType, externalId: ctx.externalId },
}),
},
{
to: { source: 'netsuite', recordType: 'customerDeposit' },
transform: (ctx) => ({
patch: {
amount: ctx.payload.total_price,
sales_order_id: ctx.materialized[0].externalId, // the id NetSuite minted for the entry above
},
createdFrom: { source: ctx.fromSource, recordType: ctx.recordType, externalId: ctx.externalId },
}),
},
],
});

What the engine guarantees for a sequence:

  • Declared order. Entries run one after another, on the server, for one inbound record. Each entry’s transform sees ctx.materialized — the targets the run has already landed, in order, as { source, recordType, masterTable, masterRowId, externalId }. It is empty for the first entry.
  • The minted id, when you ask for it. awaitSource: true on an entry holds the next entry until the target source has given its verdict on this one, so ctx.materialized[i].externalId carries the id the source minted. Without it the id is null — the write is still on its way. The wait is the same one a process step uses, with the same five-minute budget, and it reads the same rule: a retrying write is not a refusal. It applies to a different-type target only — the one that mints a new record. An entry of the firing record’s own type merges onto the firing master, so there is no minted id to wait for, and awaitSource on it is refused at deploy.
  • One ledger row per target. The first entry’s conduit_event row carries the conduit’s idempotency key; later entries carry <key>#2, <key>#3, … so turbobase logs --conduit <id> shows every target’s disposition. All rows sit at the same hop: a declared fan-out is siblings on one hop, not a chain, and does not spend the cycle budget as though it were a loop.
  • Each target is checked on its own. Every to has to be writable; a sequence whose second target is read-only is refused at deploy naming that pair. An entry that declares awaitSource against a record type with no write trail is refused too (conduit_target_no_write_trail), as is one that declares it on a target of the firing record’s own type (conduit_target_await_same_type).
  • A failure stops the sequence and undoes nothing. When an entry fails — the source refused it, no verdict arrived inside the budget, or its target could not be resolved — that entry’s row is failed with the reason, every later entry is skipped with earlier target failed, and a sync_failure is raised. What already materialized stays: a conduit has no compensating delete, and a half-materialized sequence is visible on the ledger, one row per target, rather than hidden. If you need the undo, that is a process with onFailure: 'rollback'.
  • Resumable. A re-delivered run skips every entry whose row is already committed and continues from the first one that is not, so a retry never mints a second master for a target that already landed.

The apply rule is unchanged per entry: a target of the same canonical type as the firing record binds it to the same master; a different type mints a new master chained to the origin by createdFrom. turbobase ext replay and turbobase ext lint understand both forms.

to is the half the bridge writes. Turbobase materializes the canonical master and the existing write-back worker fans it out to to.source — so a target whose adapter declares no PG→source write path for that record type has nothing to fan out to, and every run of the conduit would fail after the master had already been flipped.

That pair is therefore refused at deploy time, before the conduit is stored:

Terminal window
turbobase ext deploy conduits/ns2slack.message.ts
# 400 conduit_target_read_only
# conduit target (slack, message) is declared read-only: the slack adapter has
# no PG→source write path for message, so the bridge would flip the master row
# and then lose every write to it

The refusal body carries error: "conduit_target_read_only", toSource, toRecordType and that reason, so a script can branch on it. The same check runs when the assistant drafts a conduit — an unshippable target is refused before a module is written — and again at apply time, which catches a conduit deployed before this rule existed.

Two targets are not refused, and the distinction matters if you are writing a bridge into custom data:

  • a pair the adapter declares bidirectional — the ordinary case;
  • a pair with no static declaration at all, which is what the per-install cr_ / cl_ custom-record mirrors are. Those are writable by construction and have nowhere to say so, so a conduit targeting one deploys normally.

To check a pair before you author against it, read GET /v1/catalog/records (served to an MCP client as catalog:///) and look at writeCapability. A non-null value means the write path exists and this tenant may use it, so the pair is a valid to. A null is the one to read carefully: it covers both “the adapter declares this record read-only”, which is what the deploy refuses, and “write-back is not switched on for this tenant yet”, which is an entitlement and not a property of the conduit.

Starting a process instead of writing a record

Section titled “Starting a process instead of writing a record”

A conduit’s to can name a process rather than a record type:

to: { source: 'turbobase', recordType: 'process', process: 'acme:reorder-suggestions' },

The transform’s patch becomes the process input — so the transform is where you shape the firing record into whatever the process declares it takes — and the run is recorded on the conduit ledger like any other target. The process runs as a process always does: its steps go through the write boundary, and its run is watched at GET /v1/processes/runs/:runId. A process your instance has not got is refused at turbobase ext deploy, naming it.

turbobase is a reserved source for the platform’s own records, not a connector. Use it only in the forms these docs describe.

// exported from @turbobase/extensions
export const CONDUIT_EVENTS = [
'after_canonicalize_inbound',
'after_sync_success_outbound',
'after_save',
'after_delete',
] as const;

The first two fire after a source record’s sync commits — the natural triggers for “source A landed, now drive source B”:

  • after_canonicalize_inbound — after an inbound source webhook is canonicalized and upserted into the PG mirror. The trigger for “a record arrived from source A; materialize it into source B.”
  • after_sync_success_outbound — after an outbound write-back commits to the source. The trigger for the next leg of a flow (NS itemFulfillment committed → push the fulfillment to Shopify).

The other two fire on a record here:

  • after_save — after a record is saved on your instance and the save has committed, whoever saved it: your app through the write boundary, a process, the AI assistant, Studio, or another conduit. The trigger for “someone changed something here; drive source B.” A record arriving from a connected source is not a save in this sense — it keeps after_canonicalize_inbound, so a synced record never fires twice.
  • after_delete — after a record is deleted on your instance and the tombstone has committed. The trigger for “unpublish the counterpart”, “close the ticket”, “retract the announcement” — every flow that has to undo something the instance previously drove. It fires on the TRANSITION only, so a repeat delete of a record that is already gone (still a 200, because a delete here is idempotent) does not fire a second time, and a delete your guardrails or a before_delete hook refused fires nothing at all.

Conduits run asynchronously on all four — never inline on the ingest/write-back path, never inline on the save, and never inline on the delete.

ctx.payload is the record’s last state — the row as it stood before the tombstone, which is the one thing a deletion reaction cannot read back for itself: by the time your conduit runs the row is a tombstone. ctx.externalId addresses it the same way a save does, its external id where it has one and its surrogate key otherwise, and ctx.savedBy names the writer that made the delete on the same vocabulary as a save.

ctx.change is absent on this event, as it is on the two inbound ones: a delete is neither an insert nor an edit and wrote no fields, so there is nothing for fields to name.

Two extra context fields come with this event, and both are read by from.when as well as by transform:

  • ctx.change — { kind: 'insert' | 'update', fields: [...] }. fields names the canonical columns the writer supplied, so ctx.change.fields.includes('status') is the gate for “run when the status changes” rather than something your body works out.

  • ctx.savedBy — { kind: 'app' | 'process' | 'agent' | 'studio' | 'conduit' | 'user', id? }, carrying the conduit’s id when a conduit made the save. A conduit whose own write would fire it again refuses it here:

    from: {
    source: 'standalone',
    recordType: 'work_item',
    when: (ctx) => ctx.savedBy?.id !== 'tidy:work_item',
    },

    That refuses the save before the run starts. The cycle budget also stops a loop, but it stops it by tripping — after the conduit has already run.

Both are absent on the two inbound events rather than guessed, so a conduit written before they existed reads exactly what it read before.

A conduit body is a pure canonical→canonical transform. ConduitContext has no secrets: it reads a pre-resolved context and returns a canonical patch plus a target — it never calls a source API, never holds source credentials, never touches an env var or the secrets backend. The engine writes the canonical master row (originating_system='hybrid') and the existing write-back worker fans it out to the bound source(s). The read context gives you recordType, event, fromSource, externalId, payload, lines, tenantConfig, the current bindings (on a master a conduit MINTED, that is the target source and nothing else — the system the record came from is not bound to it, and created_from_id is what names it), an atlas entity resolver (read-only, pre-resolved cross-source id mapping), a canonicalStatus(...) status-normalization helper, log(...), and — when you declare it — query(...).

Some bridges cannot be written from the trigger’s payload alone. A reply on a Slack thread carries the thread’s root timestamp and nothing that names the GitHub issue it belongs to; resolving it is one keyed lookup. A conduit that cannot make it has to be a scheduled digest instead of a conversation.

So a conduit may declare what it reads, and read exactly that:

export default defineConduit({
id: 'slack2gh:comment',
on: 'after_canonicalize_inbound',
from: { source: 'slack', recordType: 'channel_message' },
to: { source: 'github', recordType: 'comment' },
// Declared up front, so it is reviewable in this file rather than
// discovered at runtime.
reads: [
{ recordType: 'channel_message', columns: ['external_id', 'metadata'], by: ['external_id'] },
{ recordType: 'channel', columns: ['external_id', 'name'], maxRows: 50 },
],
maxQueriesPerRun: 4,
transform: async (ctx) => {
const rootId = `${ctx.payload.scope_ref}:${ctx.payload.metadata.threadTs}`;
const [root] = await ctx.query(
`channel_message?select=external_id,metadata&external_id=eq.${rootId}&limit=1`,
);
// …build the canonical patch from the root the thread describes.
},
});

q is one PostgREST path-and-query, the same shape defineProcess’s ctx.query takes. The supported subset is select, limit, offset, order and simple column filters — eq, neq, gt, gte, lt, lte, like, ilike, is, in.

What is refused, and it is always refused BY NAME:

Refused Because
A resource not in reads The declaration is the boundary. The message lists what you did declare.
A column not in reads[].columns Same, for the columns you select.
A filter on a column not in reads[].by Filtering is a separate power from reading — by is a second gate, not a restatement of columns.
select=* and embeds A declaration that promises nothing is not a declaration.
An operator outside the list above A silently dropped filter returns MORE rows — the failure an author never sees in a passing test.
More than maxQueriesPerRun reads The budget belongs to the RUN, so a sequence shares one across its entries.

Two things the engine adds to every read, because forgetting either returns wrong rows rather than an error:

  • the type discriminator on a polymorphic table — channel_message lives in the shared activity table, so a read without activity_type would also return calls and events;
  • the connection scope on a connection-scoped table, so a tenant holding two NetSuite connections cannot read the other one’s rows.

You cannot opt out of either, and you do not have to remember them.

Why it is not scoped by RLS. A process’s ctx.query runs as the invoking user. A conduit has no invoking user — it fires off a sync event — so there is no principal for RLS to scope, and running the read under a claimless role would deny every row. The declaration is the boundary instead. That is not a widening of trust: the engine already mints canonical master rows on a conduit’s behalf, so a read is strictly less power than the write it already has.

The transform is still pure: read-only, no source API, no credential, no write. ctx.query cannot mutate anything.

Exercise it offline, before you deploy. turbobase ext replay --fixture runs a declared read against rows the fixture supplies — add a queryRows map keyed by the query string your transform passes — so you can watch what the conduit asked for and what came back on your own machine. Run it once with no rows and --trace prints the strings your conduit actually composed, which is how you discover the key without deploying anything — including when the empty answer makes the conduit stop, where the error is the trace’s last line and the strings sit above it. See Replaying a conduit that reads. Replay serves the rows; it does not re-check the declaration, so the refusals above are still the deployed conduit’s first word on a read you got wrong.

Two distinct loop defenses:

  • idempotencyKey — dedups a replayed source webhook so the same inbound event doesn’t bridge twice; it anchors the conduit_event audit row. Make it derive deterministically from the trigger ('<id>:<externalId>'), not from wall-clock or random data — a weak key is exactly what turbobase ext lint flags.

    On after_save and after_delete, omit it. A record saved here has no external id, so ctx.externalId is the record’s own surrogate key — the same string for the record’s create and for every later edit of it. A key built from it therefore dedups a record’s second save into its first, and the conduit fires once in that record’s lifetime and never again. Leave idempotencyKey off and each save gets its own key, which is what makes two saves two runs. If you do need your own key on one of these events, put something that moves in it — a field from ctx.change, say — and not ctx.externalId alone. turbobase ext lint warns lifetime-idempotency-key for exactly this, and says nothing about a key on the two inbound events, where ctx.externalId is the right one.

  • cycleBudget (default 4) — a hard per-conduit max-hop fuse on top of the opposite-direction + idempotency guards. When a record bounces more than cycleBudget times (A→B→A→…), the conduit trips. The hop a record sits at is read off the conduit_event ledger rather than carried on the job: neither sync seam knows a record’s chain position, so a budget that trusted them counted every leg as a fresh chain and could never trip. The one exception is a conduit’s OWN write, which carries its position with it, because at the moment that save is recorded the run that made it is not yet on the ledger to be read. Authoring two conduits that bridge the same pair in opposite directions is the classic way to create a cycle; the budget bounds the blast radius. Extreme values (0, or a very large number) are lint warnings.

  • The opposite-direction guard recognises the return leg two ways: by the id the echo arrives under, and — for the window between the conduit writing the master and the write-back confirming the target’s id — by the master itself. Both correlations rest on the write-back stamping the binding with the id the target source minted, which is what makes a two-way pair safe to author at all.

conflictStrategy decides what happens when a fanned-out write to a bound source is rejected:

Strategy Behaviour
merge (default) Keep the master value, mark the rejecting binding divergent, raise a sync failure (→ admin rollup + AI healing). The master is never reverted; divergence is a visible, healable per-binding state.
skip Drop the write, leave the binding untouched.
overwrite Force the master value onto every binding (last-writer-wins), no divergence tracking.
revert-on-reject Opt-in, audited: the master reverts when any bound source rejects. Never the default.

Scaffold a conduit with the CLI:

Terminal window
turbobase ext new conduit --source shopify --record order --to netsuite \
--event after_canonicalize_inbound

Then iterate offline before deploying:

Terminal window
turbobase ext lint conduits/shop2ns.order.ts # semantic foot-guns, offline
turbobase ext replay conduits/shop2ns.order.ts --fixture samples/order.json --trace

replay drives the conduit through its full lifecycle — dispatch guards → from.when → transform → apply-branch → bindings — in dry-run against a JSON fixture (no DB, no network), and with --trace prints the per-stage trace. See Sandboxes and testing.

A conduit is one producer of bindings, not the only one

Section titled “A conduit is one producer of bindings, not the only one”

The fan-out a conduit relies on — one master row, one binding per source, txn_source_link — is not the conduit engine’s private machinery. The write boundary produces bindings too: an app that names several systems on parent.source_system when it creates a record gets one master and one binding per named system, with no conduit anywhere. The realtime listener fans a later edit out to every one of them exactly as it does for a conduit’s.

Two consequences worth knowing while you author one:

  • A master you fire on may already be bound, by a create rather than by a bridge. ctx.bindings is where you see them — each entry is { source, externalId, role }, so it tells you which systems hold a copy, which of those copies has landed upstream yet, and which one the record belongs to (role: 'origin'). It does not tell you what produced a binding, and a conduit should not branch on that: write your transform against the bindings that are there.
  • Write a conduit when the two sides need a transform, a condition or an ordering — a patch shaped per target, from.when, a sequence of targets with awaitSource. If all a customer wants is “this record exists in both of these systems and should stay in step”, the app can ask for that on the create and no conduit is needed.

A conduit’s observable output at runtime is its run ledger and its bindings. Stream the ledger:

Terminal window
turbobase logs --conduit shop2ns:order --follow

List txn_source_link bindings — filter to the ones that need attention, or re-enqueue a divergent write (an admin personal access token (PAT) is required):

Terminal window
turbobase ext binding --status divergent
turbobase ext binding --retry bnd_123

A non-retryable binding returns 409; only divergent/blocked bindings can be retried.

Every deployed conduit is listed on the Conduits tab of /data/extensions, whether or not it has run — a bridge you shipped an hour ago appears immediately, with a zero run count rather than not at all. Each row carries its run and failure counts and p95 commit latency over the last 14 days, and View run trace opens that conduit’s run ledger.

Above the list, a runs-vs-failures chart plots the same window a day at a time, which is what answers “has this bridge been getting worse”. Picking a day on it narrows both the list and the trace drawer to that day’s runs.

The Mechanic does not propose conduits unprompted the way it proposes hooks. Conduits are authored deliberately — by you with the SDK and CLI above, or by an assistant you ask, over the authoring MCP server below.

A deployed bridge is not permanent. Every armed row on the Conduits tab carries Disable and Remove beside View trace, and the same two acts are one command from a terminal:

Terminal window
turbobase ext disable shop2ns:order # stop it, keep it
turbobase ext enable shop2ns:order # start it again
turbobase ext remove shop2ns:order # delete the definition

Disable keeps the definition; Remove does not. A disabled bridge stops being compiled on the next registry reload and carries no traffic, but its code, its route and its history are all still there — Enable puts it straight back, and its row reads disabled rather than draft, so it is never mistaken for a proposal waiting on a decision. Removing deletes the row instead; the bridge comes back only by deploying its definition again, under the same id.

Neither touches what the bridge already did. Its run ledger, the bindings it created and every record it wrote into the target system stay exactly as they are. Turning a bridge off stops it writing — it does not undo what it wrote.

A job already queued when you disable a bridge is not lost work in flight: it lands on the ledger as skipped, with conduit not loaded as the reason, and writes nothing.

Both acts need an administrator, on either door — a browser session on the tab, or a PAT carrying extensions:deploy whose user is an admin at the moment of the call — and each writes one row to the audit log naming who did it. A conduit your instance is operated with as a managed-service guardrail refuses both, and renders neither control.

ext remove is not ext proposals reject. Rejecting a HOOK proposal is terminal: its id becomes the denylist entry that stops the Mechanic proposing the same rule again. A removed conduit id is simply gone, and redeploying it works.

scaffold_conduit hands back the right shape with the transform left empty. When you would rather describe the bridge than write it, author_conduit takes the sentence instead:

when a shopify order is paid, raise the matching NetSuite sales order

Your tenant writes the module against the target record’s real columns, then checks it the same way a deploy would — it loads the result, validates it, and refuses an unwritable target before it drafts anything, with the same sentence the deploy refusal gives.

What comes back is a proposal, not a deployment. It lands on the Conduits tab at /data/extensions with the checked source, and it carries no traffic until an administrator applies it — the same review step an authored hook passes through. Read the transform before you apply it: the bridge starts writing into the target system the moment you do.

Drafting needs an AI provider configured for this instance and an administrator token; without one the tool answers that it is unavailable rather than producing an unchecked guess.

Under the tool is POST /v1/extensions/author-conduit, which takes {intent, from: {source, recordType}, to: {source, recordType}} and answers {status, intentId, id?, reason?} — written staged a proposal, rejected_by_validation says why it could not. Like the rest of the /v1/extensions family it is the extension-authoring surface, reached with an admin PAT carrying extensions:deploy; it is not a route your apps call.

Authoring through an MCP client — Claude Code via turbobase mcp, or the remote /mcp/authoring endpoint with a PAT carrying mcp:authoring — gets the same grounding hooks do, and conduits lean on it harder because a conduit must understand both ends of the bridge:

  • Instructions at initialize name your connected sources (the candidate from/to pairs), what’s already deployed, the loop — scaffold → mock_test until green → deploy with approve: true — and author_conduit as the alternative to starting from a blank template.
  • Prompts — connector skills for the connected sources via prompts/list / prompts/get: the record anatomy on each side of the transform.
  • Resources — recipes:/// is the cross-source mapping aid: intent recipes (columns, stats, actions per record type + intent) filtered to your installed sources, so a transform knows which canonical fields carry the intent across. catalog:/// and types://all give the canonical schema; the samples:/// fixtures are what mock_test and replay run against.

Only connector skills surface here; admin-authored skills for Pilot, your AI assistant in the app, never appear on the authoring server.