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).
The defineConduit contract
Section titled “The defineConduit contract”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). |
Several targets, in order
Section titled “Several targets, in order”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
transformseesctx.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: trueon an entry holds the next entry until the target source has given its verdict on this one, soctx.materialized[i].externalIdcarries the id the source minted. Without it the id isnull— 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, andawaitSourceon it is refused at deploy. - One ledger row per target. The first entry’s
conduit_eventrow carries the conduit’s idempotency key; later entries carry<key>#2,<key>#3, … soturbobase 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
tohas to be writable; a sequence whose second target is read-only is refused at deploy naming that pair. An entry that declaresawaitSourceagainst 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
failedwith the reason, every later entry isskippedwithearlier target failed, and async_failureis 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 withonFailure: 'rollback'. - Resumable. A re-delivered run skips every entry whose row is already
committedand 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.
The target must be writable
Section titled “The target must be writable”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:
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 itThe 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.
The four events
Section titled “The four events”// exported from @turbobase/extensionsexport 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 (NSitemFulfillmentcommitted → 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 keepsafter_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 a200, because a delete here is idempotent) does not fire a second time, and a delete your guardrails or abefore_deletehook 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.
What an after_delete run is told
Section titled “What an after_delete run is told”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.
What an after_save run is told
Section titled “What an after_save run is told”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: [...] }.fieldsnames the canonical columns the writer supplied, soctx.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.
Conduits are pure and credentials-free
Section titled “Conduits are pure and credentials-free”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(...).
ctx.query — one bounded, declared read
Section titled “ctx.query — one bounded, declared read”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_messagelives in the sharedactivitytable, so a read withoutactivity_typewould 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.
Idempotency and the cycle budget
Section titled “Idempotency and the cycle budget”Two distinct loop defenses:
-
idempotencyKey— dedups a replayed source webhook so the same inbound event doesn’t bridge twice; it anchors theconduit_eventaudit row. Make it derive deterministically from the trigger ('<id>:<externalId>'), not from wall-clock or random data — a weak key is exactly whatturbobase ext lintflags.On
after_saveandafter_delete, omit it. A record saved here has no external id, soctx.externalIdis 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. LeaveidempotencyKeyoff 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 fromctx.change, say — and notctx.externalIdalone.turbobase ext lintwarnslifetime-idempotency-keyfor exactly this, and says nothing about a key on the two inbound events, wherectx.externalIdis 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 thancycleBudgettimes (A→B→A→…), the conduit trips. The hop a record sits at is read off theconduit_eventledger 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.
Conflict handling
Section titled “Conflict handling”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, lint, and replay
Section titled “Scaffold, lint, and replay”Scaffold a conduit with the CLI:
turbobase ext new conduit --source shopify --record order --to netsuite \ --event after_canonicalize_inboundThen iterate offline before deploying:
turbobase ext lint conduits/shop2ns.order.ts # semantic foot-guns, offlineturbobase ext replay conduits/shop2ns.order.ts --fixture samples/order.json --tracereplay 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.bindingsis 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 withawaitSource. 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.
Inspecting and repairing bindings
Section titled “Inspecting and repairing bindings”A conduit’s observable output at runtime is its run ledger and its bindings. Stream the ledger:
turbobase logs --conduit shop2ns:order --followList 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):
turbobase ext binding --status divergentturbobase ext binding --retry bnd_123A non-retryable binding returns 409; only divergent/blocked bindings can be retried.
Watching your conduits
Section titled “Watching your conduits”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.
Turning one off, and removing it
Section titled “Turning one off, and removing it”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:
turbobase ext disable shop2ns:order # stop it, keep itturbobase ext enable shop2ns:order # start it againturbobase ext remove shop2ns:order # delete the definitionDisable 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.
Asking the assistant to draft one
Section titled “Asking the assistant to draft one”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.
Grounding over the authoring MCP
Section titled “Grounding over the authoring MCP”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
initializename your connected sources (the candidatefrom/topairs), what’s already deployed, the loop — scaffold →mock_testuntil green →deploywithapprove: true— andauthor_conduitas 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 atransformknows which canonical fields carry the intent across.catalog:///andtypes://allgive the canonical schema; thesamples:///fixtures are whatmock_testandreplayrun against.
Only connector skills surface here; admin-authored skills for Pilot, your AI assistant in the app, never appear on the authoring server.
Next steps
Section titled “Next steps”- Writing hooks — the single-source counterpart.
- Sandboxes and testing —
replay+linta conduit. - Deploy, plan, promote — ship a conduit.