Skip to content

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.

The authoring SDK ships as the npm package @turbobase/extensions. Install it in your extensions working tree for local typechecking and editor completion:

Terminal window
npm install --save-dev @turbobase/extensions

The 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.

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.

// exported from @turbobase/extensions
export 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'.

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.

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.

The loader scans hook files from two trees (it binds a hook by the recordType

  • event it declares, not by its path — the directory layout is purely organisational):
  1. sources/<source>/hooks/** — code-baseline hooks shipped with the product. Reviewed first-party source (provenance code-baseline); these run in-process with IO.
  2. custom/hooks/** — operator- and Mechanic-authored hooks, cross-source or standalone (provenance operator / 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.

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.

Operators toggle hooks from /data/extensions:

  • Code-baseline hooks (sources/<source>/hooks/) are disabled via custom/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.

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 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.

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 initialize name your connected sources, what’s already deployed, and the authoring loop: scaffold → mock_test until green → deploy with approve: 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:/// and types://all for the schema a hook’s payload satisfies, samples:/// for fixture rows, and recipes:/// 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.