Hooks
Run custom code at interception points in the ObjectQL execution pipeline — before/after insert, update, delete, and find
Hooks
Hooks are the data-layer "logic layer": they run custom code at interception
points in the ObjectQL execution pipeline (before/after insert, update, delete,
find, etc.). The construct is Hook, imported from @objectstack/spec/data.
A hook targets one or more objects and subscribes to one or more lifecycle
events. Each event is a combined timing+action enum value, e.g.
beforeInsert, beforeUpdate, afterUpdate, afterDelete (read-side events
such as beforeFind/afterFind are also available).
Hooks vs flows
Hooks and flows overlap on record-triggered logic —
a flow's record_change trigger fires on the same lifecycle events — so pick
the layer before writing either. The working rule: a flow is the default
surface for application logic; a hook is the backstop for what a node graph
cannot express.
| You need… | Reach for |
|---|---|
| Side effects after a save — create records, notify, call HTTP, request approval | Flow (record_change) |
| Anything that pauses: approvals, screens, timers, signals | Flow — a hook runs inline and cannot pause |
Scheduled or date-relative sweeps ("30 days before end_date") | Flow (schedule / timeRelative) |
| Mutating the pending record in the same write, before it is saved | Before hook |
| An invariant enforced on every write path, across objects, no matter who writes | Hook — the backstop duty itself |
Read-side interception (beforeFind / afterFind) | Hook — flows have no read events |
Two structural reasons to prefer the flow when either could work:
- A flow's writes are structured metadata; a hook body is code. An
update_recordnode'sfieldsis structural config thatos validatechecks — readonly targets, template dialects, declared expression slots. A hook body's write set is checked too, but only for the literal patterns a parser can recognise and only as an advisory warning (see Hook & Action Bodies). Field existence is checked on both surfaces, at different strengths: a hook body gets a warning, anupdate_recordnode a gating error (flow-node-write-unknown-field) — itsfieldsis a literal map next to a literalobjectName, so nothing could have mis-read it. - A flow reviews as data. The node graph diffs field-by-field and renders in the Console designer with per-node run history; a hook body reviews as code only.
Logic that genuinely needs code still doesn't have to be a hook: a flow
script node calling a function registered via defineStack({ functions })
keeps the orchestration in checked metadata and the code in one named,
testable unit.
Targeting objects
object takes three forms, and the third one carries a review cost the other
two do not:
object: 'account' // one object
object: ['account', 'contact'] // a named set
object: '*' // every object in the tenantAn empty target is refused, not treated as "no target". '', [] and
[''] used to parse; the binder then widened '' and [] to the wildcard, so
a blank target registered the hook on every object, on every event it
listed, with no diagnostic. ([''] failed the other way — an object name
nothing matches, a hook that could never fire.) All three are now a parse
error naming the two spellings that work. A wildcard hook stays entirely
legitimate; it just has to be spelled '*', so it is a choice a reviewer
sees in the diff rather than a default someone fell into (#4001).
Wildcard hooks earn a higher review bar
object: '*' is the right tool for genuinely cross-cutting concerns — audit
trails, provenance stamping, tenant-wide guards. It is also the broadest thing
a hook can be, so review it as such:
- Blast radius is every object, including ones added later. A wildcard hook written today runs against objects that do not exist yet, whose fields it was never checked against. Prefer a named set when the list is actually known.
- Cost multiplies by object count and write volume. A
beforeUpdatewildcard hook runs on every write in the tenant; anything non-trivial in it belongs behind acondition(evaluated before the handler) rather than an earlyreturninside the body. - Reads and writes both widen. A wildcard hook holding an L2 body with
api.writecan write anywhere. Itsctx.inputwrites are also the one shape the write-set lint has to skip — with no single target object there is nothing to resolve field names against — so a wildcard write set is the least verifiable shape in the system.
Where a wildcard is the honest answer, say so in description — it is the one
place a reviewer can find out why the broad target was chosen.
The condition gate
A declarative hook can carry a CEL condition, evaluated before the handler:
the hook fires only when it is true. Three things about it changed in protocol 17,
and the first is breaking.
An unevaluable condition aborts the operation (#4775). A condition the platform
could not work out used to emit a logger.warn and return false — the hook simply
did not fire. "The condition said no" and "the platform could not evaluate the
condition" carry opposite risks depending on the hook: swallowed into a before*
guard it silently lets the write through; swallowed into an audit hook it silently
drops the record. They are now distinct outcomes, and the second fails the write.
Hooks that have been getting by on that skip will start failing — that is how you
find out they were never enforcing anything.
The condition reads the record, not the payload (#4770). It used to evaluate
against ctx.input.data — only the fields the current write happened to carry — so
condition: "record.done == true" did not run on the most ordinary updates there
are (change the status, change the assignee), because done was not in the payload.
It now evaluates against stored ⊕ payload: the prior record overlaid with this
write's data, total over the object's declared fields (null for a declared field in
neither), with the payload winning for the fields it carries. Undeclared or typo'd
keys stay unresolvable — record.stauts is an error, not a silent false.
previous is bound, so a condition can express a transition (#4784). The scope
was a single { record } root, which made the published previous form
(previous.status != 'escalated' && record.status == 'escalated', and the legacy
OLD.x / ISCHANGED(x) mappings) abort with No such key: previous. It is now
bound alongside record, built by the same helper the validation side uses, so one
CEL expression means one thing on both surfaces.
// "after a task transitions to done" — not "whenever a done task is written"
condition: P`previous.done != true && record.done == true`Because record now means the record's state, record.done == true alone is true
on every update of an already-done row. If you wrote a condition under the old
payload semantics expecting "the write that changed it", add the previous half.
Before Hook
Mutate the incoming record before it is saved. The engine exposes the pending
record's fields directly on ctx.input (a flat view over the internal
{ data, options } wrapper — reads and writes of record fields route through
ctx.input.data):
import { Hook } from '@objectstack/spec/data';
export const AccountBeforeWrite: Hook = {
name: 'account_before_write',
object: 'account',
events: ['beforeInsert', 'beforeUpdate'],
handler: async (ctx) => {
const record = ctx.input as Record<string, any>;
// Normalize phone numbers
if (record.phone) {
record.phone = normalizePhone(record.phone);
}
// Auto-populate from website
if (!record.industry && record.website) {
record.industry = await lookupIndustry(record.website);
}
},
};After Hook
React after a record is written — but before the enclosing unit of work
commits, so read After hooks run inside the unit of
work before giving one a side effect
outside the engine. Use ctx.previous for the pre-change snapshot and
ctx.api.object('x') for cross-object writes:
export const OpportunityAfterUpdate: Hook = {
name: 'opportunity_after_update',
object: 'opportunity',
events: ['afterUpdate'],
handler: async (ctx) => {
const opp = ctx.result as Record<string, any>;
const wasWon = ctx.previous?.stage === 'closed_won';
if (opp.stage === 'closed_won' && !wasWon) {
// Create a contract via the scoped cross-object API
await ctx.api.object('contract').insert({
account: opp.account,
opportunity: opp.id,
contract_value: opp.amount,
start_date: new Date(),
});
}
},
};After hooks run inside the unit of work
An after* hook does not mean "the write happened". It means the write
has been requested and will happen unless this unit of work is undone.
afterInsert, afterUpdate and afterDelete are dispatched before the
enclosing transaction commits, so a later refusal in the same unit can roll the
row back after your handler has already run.
Three ordinary operations put a write inside such a unit:
| Operation | What is inside the transaction |
|---|---|
A by-id delete() that cascades to dependent records | Each cascaded child's afterDelete. The parent's own afterDelete runs after that unit closes, so it is unaffected |
batchData / deleteManyData with atomic: true | Every member's after* — the batch aborts and rolls back on the first failure |
Any write you wrapped yourself in ctx.api.transaction(...) or engine.transaction(...) | Everything in the callback |
What this means when you write a handler:
- Effects that go back through the engine are safe. Writes made with
ctx.api.object('x')join the same transaction and roll back with everything else — that is what makes an in-engine audit or projection hook correct in the first place. - Effects that leave the engine are yours to make rollback-tolerant. A webhook, a notification, an email, an external search-index update or a file deletion has already gone out when the rollback happens, announcing a change that did not survive. Make the effect idempotent and reconcilable, or hand it to a worker that re-reads the record before acting rather than trusting the event on its own.
Before hooks carry no such caveat: they run before the write is issued, and throwing from one refuses the operation outright.
This is a deliberate, ruled semantics (#7477),
not an implementation detail awaiting a fix. Deferring after* to commit time
would move a handler's own ctx.api writes outside the transaction the write
ran in, which is a worse guarantee than the one documented here.
Hook Context
handler receives a HookContext with these fields:
ctx = {
id, // tracing id
object, // target object name
event, // e.g. 'beforeInsert' | 'afterUpdate'
input, // mutable input — record fields exposed flat (raw wrapper: input.data, input.options)
result, // mutable operation result (after* events)
previous, // record state before the operation (update/delete)
session, // { userId, organizationId, positions, accessToken, isSystem }
user, // { id, name, email } convenience shortcut — reserved for future use;
// not currently set by the engine, use session.userId instead
transaction, // active transaction handle, if any
ql, // ObjectQL engine reference
api, // scoped cross-object access: ctx.api.object('x')
}Best Practices
✅ DO:
- Use before hooks to enrich/normalize the incoming record
- Use after hooks for related-record side effects
- Use
ctx.api.object('x')for cross-object access so writes stay in scope - Handle errors gracefully
- Verify every field your hook writes exists on the target object(s) — the write-set lint catches the literal cases, but it is advisory, and computed keys, spreads and aliased input are invisible to it (see Hook & Action Bodies)
❌ DON'T:
- Query in loops
- Trigger unbounded cascades of writes
- Perform heavy/long-running work inline in a hook
- Mutate
ctx.resultin before hooks (it is only populated for after hooks) - Treat an
after*hook as proof the write committed — it fires inside the unit of work, so an un-retractable external effect there can outlive a rollback (see above)
Related business logic
Hooks are one of several business-logic layers. Topics formerly covered on this page now live on their own pages:
- Validation rules → /docs/data-modeling/validation
- Formula logic → /docs/data-modeling/formulas
- Flows → /docs/automation/flows
- Approval nodes → /docs/automation/approvals