Writing hooks
Shipped
A hook is a TypeScript file that exports a defineHook({...}) declaration.
There are no “plugins” and no “customization rules” — one extension surface, one
runtime that loads it. A hook intercepts a record on a single source’s write
path (or a standalone record where Turbobase is the system of record) and either
validates it, patches it, or logs against it.
This page is the how-to: the defineHook contract, the lifecycle events, the
execution model, and the proactive-learning Mechanic loop that proposes
hooks for you.
Install the SDK
Section titled “Install the SDK”The authoring SDK ships as the npm package @turbobase/extensions. Install
it in your extensions working tree for local typechecking and editor
completion:
npm install --save-dev @turbobase/extensionsThe root export carries the full public API: the defineHook /
defineConduit factories and the generic defineExtensionKind machinery they
build on, the publish-manifest validators (ManifestSchema,
validatePublishManifest), and the generateTypes codegen library behind
turbobase project types. Subpath exports let you import
one surface at a time: @turbobase/extensions/hook, /conduit, /define,
and /types-gen. The package is pure and credentials-free, and installing it
never touches your instance — at runtime the import resolves to the code
sandbox’s inline stub, so your deployed bundle carries no npm dependency.
The defineHook contract
Section titled “The defineHook contract”defineHook is a pure identity factory that adds Zod validation and ergonomic
types. The shape your default export must satisfy:
import { defineHook } from '@turbobase/extensions';
export default defineHook({ id: 'custom:salesorder:require-subsidiary', // stable, unique; survives renames recordType: 'salesorder', // string | string[] | '*' event: 'before_save', // one of the 6 lifecycle events source: 'netsuite', // omit for cross-source / standalone when: (ctx) => ctx.tenantConfig.netsuite?.oneWorldEnabled === true, // optional gate failClosed: true, // optional; default fail-open description: 'subsidiary required on OneWorld accounts', run: (ctx) => { if (!ctx.payload.subsidiary) { ctx.reject({ code: 'MANDATORY_FIELD_MISSING', field: 'subsidiary', message: 'subsidiary required on OneWorld accounts', }); } },});| Field | Required | Meaning |
|---|---|---|
id |
yes | Stable id. Collisions across files are loader errors. Convention: <source>:<record-or-scope>:<descriptor> for code-baseline, custom:<descriptor> or mechanic:<sha>:<descriptor> otherwise. |
recordType |
yes | The record type to bind to — a string, an array, or '*' for every record. |
event |
yes | One of the 6 lifecycle events. |
source |
no | Restrict to one source ('netsuite'); omit for cross-source; use 'standalone' for standalone-only. |
when |
no | Predicate gating execution before run fires; branch on ctx.tenantConfig, ctx.direction, etc. |
run |
yes | The hook body. Receives ctx; calls reject / patch / log. |
failClosed |
no | See fail-open vs fail-closed. Default fail-open. |
timeoutMs |
no | Execution-budget override; default 200ms. |
description |
no | Operator-friendly text surfaced in the UI. |
systemReserved |
no | Managed-service guardrail instance admins can’t disable — see system-reserved hooks. Reserved for platform code. |
Hooks are pure and credentials-free: HookContext has no secrets, and
nothing in a hook reaches a token, env var, or the secrets backend. The runtime
runs untrusted hooks inside an isolated-vm sandbox with no IO — see
Sandboxes and testing.
The eight lifecycle events
Section titled “The eight lifecycle events”// exported from @turbobase/extensionsexport const HOOK_EVENTS = [ 'before_read', 'before_save', 'after_save', 'before_delete', 'after_delete', 'before_sync', 'after_sync', 'on_sync_failure',] as const;Those eight are the events a hook can bind to.
Conduits — “a hook that bridges sources” — bind
to three of their own: after_canonicalize_inbound,
after_sync_success_outbound and after_save. The last name is in both
lists and means two different things. A hook’s after_save runs on the write
path of the record being saved, synchronously with the save, for that one record
type; a conduit’s fires after any save on the instance has COMMITTED, whoever
made it, and its job is to drive another source. Use the hook to shape or
validate the record, the conduit to react to it.
before_read, before_save, after_save, before_delete and after_delete are
universal (they fire for standalone records too); before_sync, after_sync,
and on_sync_failure are connector-only (they fire only when a source
connector is propagating the record).
Delete has its own pair, and that is deliberate. A before_save rule never
sees a delete, so a record whose writes are validated is not thereby protected
from deletion — the rule that blocks deleting an order with a fulfillment has to
bind to before_delete. Note the payload difference: a delete is addressed by
key (pg_row_id / external_id), not by row body, because resolving the row is
fused with the tombstone stamp in one transaction to close a read-then-stamp
race. So before_delete can reject, but there is no row body to patch.
| Event | When it fires | Typical use | Can reject? |
Can patch? |
|---|---|---|---|---|
before_read |
Before a record is read out | Redact / gate reads | yes | yes |
before_save |
Before a write is committed | Validate, fill defaults | yes | yes |
after_save |
After the write committed | Observe, derive side data | no (committed) | no-op |
before_delete |
Before a delete is tombstoned (after the guardrail gate) | Block deletes that break referential or business rules | yes | no (key, not row) |
after_delete |
After the tombstone stamped | Observe, cascade, annotate | no (committed) | no-op |
before_sync |
Before an outbound write leaves for the source | Last-mile shaping for the source | yes | yes |
after_sync |
After the source accepted the write | Observe, stamp lineage | no (committed) | no-op |
on_sync_failure |
When the source rejected the write | Classify / annotate the failure | no | no |
ctx.direction tells you which way the operation is flowing — 'outbound'
(propagating to the source), 'inbound' (a source webhook applying to the
canonical mirror), or 'standalone' (no connected source). ctx.origin tells you what
triggered it — 'user', 'agent', 'webhook', or 'system'.
The hook context — reject, patch, log
Section titled “The hook context — reject, patch, log”run(ctx) receives a HookContext. The read surface includes recordType,
event, direction, origin, source, payload (the working record —
mutable via patch), prior (the previous payload on updates), user,
tenantConfig (the source-feature snapshot), and metadata (source-specific
scoping, e.g. NS customFormId). The three mutators:
// Reject — short-circuits the pipeline. The rejection surfaces as a sync// failure with category='hook_validation' and the rejection code as classification.ctx.reject({ code: 'MANDATORY_FIELD_MISSING', field: 'subsidiary', message: '…' });
// Patch — mutate the working payload; subsequent hooks see the patched shape.// Multiple hooks patching the same field: last-writer-wins.ctx.patch({ memo: ctx.payload.memo ?? 'AUTO' });
// Log — structured logging, observable in hook_telemetry + the parse-error UI.ctx.log('warn', 'falling back to AUTO memo', { recordType: ctx.recordType });The first reject wins — the pipeline short-circuits, so a hook that rejects
stops later hooks for that operation.
Fail-open vs fail-closed
Section titled “Fail-open vs fail-closed”When a hook’s run throws an uncaught error, the default policy is fail-open:
the runtime proceeds as if the hook didn’t run (the error is caught, isolated,
and logged — one bad hook never wedges the queue). Set failClosed: true to stop
the operation instead — appropriate for security-critical or contract-enforcing
hooks where “couldn’t run” must mean “don’t proceed.”
A fail-closed hook that threw is not the same answer as ctx.reject. It
produced no verdict about the record, so the runtime does not report one.
ctx.reject answers 422 { error: 'validation_failed' } with your code, field
and message; a throw — or an overrun of the timeoutMs budget — answers
503 { error: 'hook_unavailable' } with a Retry-After header, and on the
background sync queue it is retried on the job’s remaining attempts instead of
failing the record permanently. Either way nothing is written. So a hook body
that may fail for reasons of its own is honest about it: the caller is told to
send the same record again, not to fix something.
Where hooks live — the two trees
Section titled “Where hooks live — the two trees”The loader scans hook files from two trees (it binds a hook by the recordType
eventit declares, not by its path — the directory layout is purely organisational):
sources/<source>/hooks/**— code-baseline hooks shipped with the product. Reviewed first-party source (provenancecode-baseline); these run in-process with IO.custom/hooks/**— operator- and Mechanic-authored hooks, cross-source or standalone (provenanceoperator/mechanic). These are untrusted customer/AI-authored code: bundled and run in the isolated-vm sandbox.
Per-source operator hooks may also live at sources/<source>/custom/hooks/**
so they sit next to that source’s tree while still being operator-provenance. The
.proposed/ subtree under custom/hooks/ is skipped — Mechanic proposals
are inert until an operator confirms them.
Worked example: code-baseline (Oracle NetSuite customer)
Section titled “Worked example: code-baseline (Oracle NetSuite customer)”sources/netsuite/hooks/customer/before_save.require-name.ts enforces the
NetSuite mandatory-name rule canonical-side, so a missing field surfaces at save
time rather than after a wasted REST round-trip:
export default defineHook<CustomerPayload>({ id: 'ns:customer:require-name', recordType: 'customer', event: 'before_save', source: 'netsuite', description: 'Customer rows require either company_name (company) or first_name + last_name (individual). …', failClosed: true, run: (ctx) => { // `readIsPerson` / `readName` read the CANONICAL snake_case key first, then // the camelCase canonicalizer key, then `ctx.prior` — see below. const isPerson = readIsPerson(ctx); const companyName = readName(ctx, 'company_name', 'companyName'); const firstName = readName(ctx, 'first_name', 'firstName'); const lastName = readName(ctx, 'last_name', 'lastName');
// Individual customer: first + last both required. if (isPerson) { if (!firstName || !lastName) { ctx.reject({ code: 'MANDATORY_FIELD_MISSING', field: !firstName ? 'first_name' : 'last_name', message: 'Individual customers (is_person=true) require both first_name and last_name. …', }); } return; }
// Company customer (default): company_name required. if (!companyName && !(firstName && lastName)) { ctx.reject({ code: 'MANDATORY_FIELD_MISSING', field: 'company_name', message: 'Customer requires company_name (or first_name + last_name …).', }); } },});Note failClosed: true (a missing-name customer must never silently proceed),
the source: 'netsuite' scoping, and that run only ever calls ctx.reject —
it doesn’t reach out to NetSuite.
Read the canonical (snake_case) key first — and fall back to ctx.prior
Section titled “Read the canonical (snake_case) key first — and fall back to ctx.prior”Two rules that a before_save hook gets wrong at the cost of rejecting every
write, both learned the hard way (once on transactions, again on customer):
1. before_save payloads are snake_case. The event runs at the write
boundary (POST /v1/data/:recordType), which hands hooks the canonical column
names — company_name, not companyName. The camelCase spelling is the
canonicalizer’s output shape and reaches a hook only on the inbound path of an
adapter whose trustInboundAdapter is off. Read snake_case first and treat
camelCase as the fallback. A hook that reads only camelCase sees undefined
forever, and with failClosed: true that is not a check that never fires — it is
a check that rejects everything.
2. A partial PATCH does not restate fields it isn’t changing. An edit that
touches only phone carries no name key at all. Absent-because-unchanged and
absent-because-never-set need opposite answers, so consult ctx.prior — the
stored row as it was before this write — before rejecting. And key any
classification flag (is_person) on presence, not truthiness: an explicit
false is a real value, while a missing key must inherit from prior rather
than defaulting.
Neither mistake shows up in a test that drives the DB directly. POSTing
through PostgREST runs no before_save hook at all, so a spec built that way
passes while every real app write 422s. Cover the hook with a unit test that
calls hook.run(ctx) directly on a snake_case payload, asserting both the
rejection and the pass — and, for a field like this one, the prior-inheriting
case where the key is absent rather than falsy.
Worked example — standalone custom table
Section titled “Worked example — standalone custom table”custom/hooks/cr_example/before_save.required-fields.ts is the template for a
hook on a customer-authored standalone table (an instance where Turbobase is
the system of record). The directory name cr_example is organisational; the
binding is the declared recordType:
export default defineHook<ExamplePayload>({ // 'custom:<scope>:<descriptor>' for customer-authored hooks. id: 'custom:example:required-fields', // The standalone table's slug. Change this when you copy the template; // '*' would bind to EVERY record — scope it to one table. recordType: 'example', event: 'before_save', // Standalone tables have no connected source, so don't pin `source`. description: 'Validation for the standalone "example" table — rejects a save when the required `title` field is empty. …', failClosed: true, run: (ctx) => { const title = asStr(ctx.payload.cf_sa_title); if (!title) { ctx.reject({ code: 'MANDATORY_FIELD_MISSING', field: 'cf_sa_title', message: 'The "title" field is required on the example table.', }); } },});Copy this to custom/hooks/cr_<your_slug>/, set the recordType to your
table’s slug, and write the validation body. Because the template’s slug
(example) won’t match a real table, it’s inert until you copy and re-point it.
Hot reload
Section titled “Hot reload”The runtime watches both hook trees with chokidar and reloads the registry on
change — a new file, an edit, a rename, or an .overrides.json change — so a
hook takes effect without a redeploy. Each file is esbuild-transpiled with
mtime+content-hash caching; a single bad file is isolated (its parse error
surfaces in the parse-errors UI) and never breaks the rest of the registry. To
author against a live-feeling instance before deploying, use
turbobase sandbox create.
Enabling and disabling hooks
Section titled “Enabling and disabling hooks”Operators toggle hooks from /data/extensions:
- Code-baseline hooks (
sources/<source>/hooks/) are disabled viacustom/hooks/.overrides.json— the loader drops overridden ids. - Operator hooks under
custom/hooks/are disabled by renaming the file to*.ts.disabled(the loader skips it).
The watcher reloads on either change, so a toggle is immediate.
System-reserved hooks
Section titled “System-reserved hooks”A hook marked systemReserved: true (e.g. ns:bidirectional-readonly) is a
managed-service guardrail whose enforcement is the contract — the customer is
the party being gated, so the hook isn’t theirs to remove. In managed mode
(the standard control-plane-operated instance) the loader ignores any override,
the UI shows a locked badge, and the disable action 403s; to change the
capability, staff flip the gating feature flag. Reserve this flag for platform
code only.
The Mechanic loop Preview
Section titled “The Mechanic loop Preview”The Mechanic is the proactive-learning loop: it watches clusters of sync
failures that share a signature, proposes preventive hooks (written to
custom/hooks/.proposed/), and surfaces them for an operator to accept. A
proposal is inert until accepted (the loader skips .proposed/), at which point
it loads through the identical path as any authored hook. The Mechanic loop is
in Preview.
Grounding over the authoring MCP
Section titled “Grounding over the authoring MCP”When you author through an MCP client — Claude Code via
turbobase mcp over stdio, or any client pointed at
the remote /mcp/authoring endpoint with a
personal access token (PAT) carrying mcp:authoring — the server grounds
the client before its first tool call:
- Instructions at
initializename your connected sources, what’s already deployed, and the authoring loop: scaffold →mock_testuntil green →deploywithapprove: true. - Prompts — the connector skills (record anatomy, domain playbooks) for the
sources you have connected, via
prompts/list/prompts/get. A skill for a source you don’t have never appears. - Resources —
catalog:///andtypes://allfor the schema a hook’s payload satisfies,samples:///for fixture rows, andrecipes:///for the source-filtered intent recipes (which columns and actions matter for a record type + intent) — exactly the field knowledge a validating hook encodes.
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”- Sandboxes and testing —
turbobase ext test/lintyour hook before deploying. - Deploy, plan, promote — ship a hook with
turbobase ext deploy. - Writing conduits — the cross-source counterpart.
- CLI reference — the
ext new hookscaffolder.