Hook & Action Bodies (L1 / L2)
How hook handlers and script-action bodies travel through ObjectStack as pure metadata, and the spec they must conform to.
Hook & Action Bodies
ObjectStack treats every hook handler and every type: 'script' action as pure metadata. In the self-contained (body-only) form there is no separate .mjs file shipped alongside the project artifact, no dynamic import() at runtime, and no filesystem dependency on the cloud — though today a legacy objectstack-runtime.{hash}.mjs back-compat bundle can still ship, and does get dynamically imported at boot, for any handler that hasn't been lowered to a metadata body yet (see Migration below). A body is either:
- L1 — Expression a formula-engine string, side-effect-free.
- L2 — Sandboxed JS a JavaScript source string executed inside an isolated VM with declared capabilities.
A third "compiled module" form (L3) was considered and explicitly disabled — it broke the cloud-parity guarantee that every artifact is a single self-contained JSON.
TL;DR
// Authoring (TS source — packages/myapp/objectstack.config.ts)
export default defineStack({
hooks: [
{
name: 'normalize_account',
object: 'account',
events: ['beforeInsert'],
handler: async (ctx) => {
if (ctx.input.website) {
ctx.input.website = ctx.input.website.toLowerCase();
}
},
},
],
});// Build artifact (excerpt from dist/objectstack.json's "hooks" array — what objectos actually loads)
{
"name": "normalize_account",
"object": "account",
"events": ["beforeInsert"],
"body": {
"language": "js",
"source": "if (ctx.input.website) ctx.input.website = ctx.input.website.toLowerCase();",
"capabilities": []
}
}The CLI builder stringifies the inline handler, runs a regex allow-list over the source, and emits the metadata above. No runtimeModule, no bundle.functions[normalize_account] — the artifact is self-contained.
Why metadata-only?
| Constraint | Implication |
|---|---|
Cloud parity. objectos in production receives projects through the cloud-artifact-api. | The transport must be a single JSON. |
| Edge runtime support. objectos must run on Cloudflare Workers, Vercel Edge, Deno Deploy. | No native modules, no Node-only filesystem APIs in the execution path. |
| Hot-reloadable. Studio in-browser editor must save handler edits and have them take effect on next request. | Bodies must be data, not code that requires a build step. |
| Audit & multi-tenancy. Every body should be inspectable, sandboxable, and scoped per tenant. | Bodies travel through the same RBAC pipe as data. |
This is the same trade-off ServiceNow made (Business Rules), Salesforce made (Formulas + Apex Triggers stored as metadata), Retool made (transformer JS strings), and Airtable made (Scripting blocks). It's the standard low-code shape.
L1 — Expression bodies
Pure formula. No IO, no mutation. This is the body.language: 'expression' shape, used for:
- Action
bodyfor trivial computed values - Validation rules
{ "language": "expression", "source": "input.amount > 1000 && input.status == 'open'" }Hook condition is a separate field with a different envelope — a bare CEL string, or { dialect: 'cel', source } (ExpressionInputSchema in packages/spec/src/shared/expression.zod.ts), not a { language, source } body. e.g. condition: 'record.status == "open" && record.amount > 1000'.
Both forms are evaluated by the same formula engine that powers field formulas — see Formula Reference.
L2 — Sandboxed JS bodies
A JavaScript function body (not a full module) executed inside QuickJS.
{
"language": "js",
"source": "const total = await ctx.api.object('opportunity').count({ account_id: ctx.input.id }); ctx.input.opportunity_count = total;",
"capabilities": ["api.read"],
"timeoutMs": 250,
"memoryMb": 32
}Sandbox surface
The script sees only what the surrounding ctx object exposes:
| Field | Description | Capability required |
|---|---|---|
ctx.input | Mutable record being inserted/updated/etc. | none |
ctx.previous | Pre-update record (update events only). | none |
ctx.user / ctx.session | Identity context. | none |
ctx.api.object(name).find|count|aggregate | Cross-object reads, scoped to current tenant. | api.read |
ctx.api.object(name).insert|update|delete | Cross-object writes. | api.write |
ctx.title() | This record's title — the object's nameField, including when it is a formula (evaluated server-side against the record already in hand, no extra read). | none |
ctx.title('<lookup field>') | The related record's title, through a lookup / master_detail / user / tree column. Costs one findOne. | api.read |
ctx.crypto.randomUUID() | UUID generation. | crypto.uuid |
ctx.log.{info,warn,error} | Structured logging. | log |
ctx.connector(name).<method>(...) (planned) | Outbound HTTP / SaaS calls. Not yet wired into the sandbox — ships with the separate Connector spec. | (separate Connector spec) |
Naming a record — ctx.title()
A body composing a message needs the record's name, and until this accessor
existed it could not get one: ctx.input / ctx.previous carry stored columns,
while a nameField is very often a formula computed on read. The result was
that every hook re-implemented the object's title inline — or, more often,
printed record.id, which is the one identifier always in scope and the one
string the UI never shows.
// this record — resolves `nameField`, formula or stored column alike
await ctx.api.object('sys_notification').insert({
subject: `${await ctx.title()} was closed`,
});
// a related record, through the lookup column that holds its id
const account = await ctx.title('account_id');Three properties worth knowing:
- A formula
nameFieldcosts nothing extra. It is evaluated server-side against the record the hook is already firing on — the same expression, the same evaluator and the same rounding aGETof that record would use, so the title a hook writes and the title the UI shows cannot drift. - The related form costs exactly one
findOne, through your body's own read channel — so it obeys the caller's scope and joins an openctx.api.transaction. That is why it requiresapi.readwhile the bare form requires nothing: the token gates the read, and there is no read to gate. - It never falls back to the id. No title resolvable ⇒
null. An id-shaped string is a plausible-looking title to whatever renders it, so the platform will not manufacture one; write your own fallback if you want one.
There is no hashing capability — crypto.hash was removed in spec 17. Until
17 the crypto.hash token was declared in HookBodyCapability, listed in this
table, typed on ScriptContext and auto-inferred by the build-time extractor —
but the sandbox never installed the function. Every call the token authorised
threw inside the VM, while os build reported success precisely because writing
ctx.crypto.hash(...) is what made the CLI grant the capability. All four
declarations were removed together in #4391: declaring crypto.hash is now a
parse error that explains this, and writing the call no longer earns a capability.
If you declared it: delete the token from capabilities and delete the
ctx.crypto.hash(...) call — the call has never returned a value, so nothing
that works today depends on it. os migrate meta --from 16 strips the token for
you; the dead call is yours to remove. Hash in the host instead (a Connector
recipe, or an engine-side hook). Hashing inside the sandbox comes back only
with an implementation, via the capability admission process — the declaration
follows the implementation, it never leads it.
What the sandbox forbids
The CLI builder rejects any source that uses:
import/require/ dynamicimport()fetchprocess,globalThiseval,new Function- references to identifiers from value-only top-level imports
Need outbound HTTP? Define a Connector recipe as metadata and call it via ctx.connector(...). (Connector spec is tracked separately and ships after L1+L2 stabilises.)
Write-set checking
Static validation around a hook is asymmetric, and it is worth knowing exactly where the line is:
- Checked — read side.
hook.conditionis validated at build time against the target object's fields by the expression validator (@objectstack/lint), including array-valuedhook.objecttargets. A condition referencing a nonexistent field fails the lint. - Checked — capability side.
body.capabilitiesgates whichctxAPIs the body may call at all; the sandbox throws on an undeclared call. - Checked — write side, advisory and literal-only. Since #4271,
body.sourceis parsed (never executed, never type-checked) and the field names it writes are resolved against the target object's declarations. An unknown field raiseshook-body-write-unknown-field— a warning carrying a did-you-mean suggestion, which never blocks a build. Action bodies get the same check on theirctx.apiwrites (action-body-write-unknown-field). Both run underos validate,os lintandos compile. - Checked — writes to a system column the object has no storage for. Since #8663, a write to an injected system column is no longer exempted on the strength of its NAME alone. The registry injects
owner_id/organization_id/ the audit family onto an ADR-0015externalobject exactly as onto a local one, but the remote database owns that schema and no column exists behind them. Writing one raiseshook-body-write-unprovisioned-anchor(oraction-body-write-unprovisioned-anchor/flow-node-write-unprovisioned-anchoron the other two surfaces) — a warning on all three, including the flow-node rule that otherwise gates, because the claim is about a remote schema the build cannot see. A column you declare yourself is untouched: on a federated object a declaredowner_idmaps a remote column you vouch for. Why it matters more than an ordinary typo: an undeclared name is refused upstream by the engine's own write-path validator (INVALID_FIELD), whereas the injected anchor is in the registered schema and passes it — so it is the one payload key that reaches the remote database raw, where a SQL remote aborts the whole statement with an untypedno such columnand takes the correctly named fields of the same payload with it. - Checked — writes that reach nothing at all. Since #4345, an action body assigning to
ctx.recordraisesaction-record-write-discarded, also a warning. This one is not a field-resolution question: an action'sctx.recordis a snapshot the runtime never writes back, so the assignment is discarded whether or not the field is declared — see Signature conventions below.
Four literal write shapes are recognized, and only these:
| Write shape | Hook body | Action body |
|---|---|---|
ctx.input.<field> = … / ctx.input['<field>'] ⟨op⟩= … (including +=, ??=, …) | checked | not checked — an action's ctx.input is its params bag, not a record |
Object.assign(ctx.input, { <field>: … }) | checked | not checked — same surface |
ctx.api.object('<literal>').insert|create|update({ <field>: … }), .updateById(id, { <field>: … }) | checked | checked |
ctx.record.<field> = … / ctx.record['<field>'] ⟨op⟩= … | n/a — a hook context has no ctx.record (the expression throws) | checked: warns as discarded, declared field or not |
A missing warning is not a clean bill of health. The rule bails silently on everything it cannot resolve statically, deliberately preferring a missed finding to a false one — a false positive kills an advisory lint, while a miss just leaves the gap open a little longer:
- computed keys (
ctx.input[k] = …), spreads, and non-literal payloads; - dynamic object names (
ctx.api.object(name)); ctx.inputwrites in a wildcard (object: '*') hook — there is no single target to resolve against;- multi-target hooks where the field exists on some target: a body may legitimately branch per object, so only a field missing on every named target is flagged;
- objects declared by another package;
- aliased input (
const doc = ctx.input; doc.x = 1) — v1 does no data-flow analysis; ctx.recordwrites in a body that handsctx.recordto anything — an argument, an assignment RHS, a spread, a return. Mutating the snapshot and then persisting it (ctx.record.stage = 'won'; await ctx.api.object('crm_deal').update(ctx.record)) is a live payload, so the whole body's record writes are skipped rather than guessed at. Truthiness and type guards (ctx.record && ctx.record.id,if (!ctx.record) …) are not escapes — they cannot persist anything.
System/audit columns and the flat-input envelope keys (id, options, ast, data) are never flagged.
A structured writes declaration was considered and dropped (#3700, closed as not planned) — but the gap it left was closed from the other end, by parsing the write set out of the source. The practical consequence of that route: coverage is bounded by what a parser can see, not by what an author remembered to declare.
What still happens at runtime
An unknown field is not caught at runtime, and it does not fail quietly either. The write-path validator walks the object's declared fields, so an undeclared key is neither rejected nor stripped, and the sandbox's mutations are copied back onto the payload verbatim. What happens next is the driver's call:
- SQL drivers put the stray column into the statement, so the whole write fails with a driver-level error (
table deal has no column named stagee) — nothing is stored, and the error surfaces far from the authoring mistake. - Schemaless drivers (memory, MongoDB) silently persist the stray key alongside the real ones.
Neither outcome is the one you wanted, and the advisory warning is the earliest signal you get.
Because the checking is advisory and literal-only:
- Treat
hook-body-write-unknown-fieldas a build failure by convention. It does not gate, but the rule is tuned for near-zero false positives — in practice a warning is a real typo. - Check by hand what the parser cannot see. Computed keys, spreads, aliased input and dynamic object names are invisible to the rule; for an array or
"*"hook, every field must exist on every target. - Prefer a flow
update_recordnode when the write set is fixed — and for this check most of all. A flow node's writes are structured config: they diff field-by-field, render in the Console designer, and a write to areadonly:truefield is a gating error (flow-update-readonly-field) that hooks have no counterpart for. Since #4271 the field-existence check gates there too —flow-node-write-unknown-fieldis an error, not the advisory warning a body gets, because a node'sfieldsis a literal map next to a literalobjectName: there is no parser in between that could have mis-extracted it, so a finding is a certainty rather than a best effort. - Exercise the hook against a real object before shipping — on SQL drivers the mistake surfaces on the first write; schemaless drivers won't tell you.
Signature conventions
| Surface | TS authoring | Sandbox invocation |
|---|---|---|
| Hook | (ctx: HookContext) => Promise<void> | (ctx) => Promise<void> |
| Action | (input: I, ctx: ActionContext) => Promise<O> | (input, ctx) => Promise<O> |
Hooks mutate ctx.input/ctx.result; actions return their output value explicitly.
An action's ctx is not a hook's. ctx.input is the action's params bag — validated against its declared params, not a record. ctx.record is the record the dispatcher pre-fetched, and it is read-only in effect: the sandbox receives a plain snapshot and the runtime never writes it back, so ctx.record.<field> = … is discarded even for a perfectly valid field name. There is exactly one way an action body persists anything:
await ctx.api.object('crm_deal').updateById(ctx.recordId, { stage: 'won' });Mutating the snapshot as a payload and then handing it to such a call is fine — that write is live, and the lint leaves it alone.
Engine
The sandbox engine is quickjs-emscripten — pure-WASM, runs on every JS host. We considered isolated-vm but its native dependency disqualifies edge targets. The choice is hidden behind the ScriptRunner interface in packages/runtime/src/sandbox/, so a node-only deployment can swap in a faster engine later without touching call sites.
Per-invocation budgets default to 250ms (hooks) / 5000ms (actions) of script CPU time — VM-active time, not wall clock (ADR-0102): time spent awaiting host calls, or running a nested hook, is not charged. A separate 30s wall-clock ceiling backstops a body stuck on a host call that never settles. Per-invocation memory caps at 32 MB. All are overridable per body and deployment-wide via OS_SANDBOX_HOOK_TIMEOUT_MS / OS_SANDBOX_ACTION_TIMEOUT_MS / OS_SANDBOX_WALL_CEILING_MS.
Nested cross-object writes
A body may write other objects — e.g. await ctx.api.object('parent').update({ ... }) from a child's afterInsert/afterUpdate (requires api.write). The target's own hooks fire too: the nested write runs in a fresh sandbox VM while the calling body is suspended, and this composes to any depth. This is the natural "when a child changes, roll the total up to the parent" automation — it does not need a denormalized, hand-maintained mirror field. Because each body's budget is CPU time (ADR-0102), the caller is not charged for the nested write's own run — so the stock 250ms default comfortably covers deep rollup chains, and you rarely need to raise timeoutMs (the spec still permits up to 30_000ms for a genuinely CPU-heavy body).
Errors from ctx.api
A rejected ctx.api call gives your body the host error's name and message, plus two structured properties when the host supplied them:
| Property | Meaning |
|---|---|
e.code | The semantic code, e.g. 'VALIDATION_FAILED' |
e.fields | Per-field validation envelopes — { field, code, message }[] |
try {
await ctx.api.object('invoice').update({ id: input.id, status: 'sent' });
} catch (e) {
if (e.code === 'VALIDATION_FAILED') {
// e.fields → [{ field: 'issued_on', code: 'required', message: 'issued_on is required' }]
throw e; // re-throwing keeps the payload; see below
}
throw e;
}Nothing else crosses into the VM. That is a deliberate allowlist, not an oversight: host errors routinely carry driver state, connection details or whole record payloads, and anything reachable on a rejection is readable by body code.
An error your body lets escape — or re-throws — keeps code and fields on the way back out too, so an action's HTTP response can carry them (data.code / data.fields) and a form can highlight the offending input rather than only raising a toast. Errors you construct yourself are treated the same way: set e.code before throwing and it reaches the caller.
L3 — Compiled modules (intentionally disabled)
An earlier design allowed the CLI to emit a sibling objectstack-runtime.<hash>.mjs that objectos would import() at runtime. We removed that path because:
- It meant cloud-deployed
objectoshad to download a JS module out-of-band, adding another transport, another cache, another vector for tenant cross-talk. - It bypassed the sandbox entirely — a misbehaving module could call any Node API on the host.
- It made hot-reload from Studio impossible; you cannot edit a baked
.mjsfrom a browser.
If you have a body that genuinely cannot be expressed in L1+L2 (typically: it needs an npm package's behaviour), the right escape hatch is a plugin — install it on the host, register a service via DI, and call it from L2 with a capability-gated proxy. Bodies stay metadata; the heavy lifting moves to a place where it's auditable and shared.
Build pipeline
objectstack build (an alias for objectstack compile) loads your defineStack({...}) config and lowers every inline hook/action handler. It does not glob *.hook.ts / *.action.ts source files off disk — the body source comes from the live function objects in the loaded config:
-
Load the
defineStackconfig and normalise its shape. -
For each inline handler, take its source via
String(fn)(the callable is already loaded by tsx/esbuild). -
Run a regex allow-list over the stringified body (see "What the sandbox forbids" above).
-
Pass: emit
body: { language: 'js', source: <body>, capabilities: <inferred> }. -
Forbidden token (default): extraction fails, a
bodyExtractionWarningis recorded, and the callable still ships via the back-compat handler-ref bundle — the build does not abort. The warning is printed (and carried in--jsonunderbodyExtractionWarnings), so a forbidden pattern is a visible warn-and-bundle rather than a silent success; before #10678 it was recorded and shown to nobody. Passobjectstack compile --strict-bodyto turn extraction warnings into a hard build failure (exit 1) with a per-callable diagnostic, e.g.hook 'normalize_account': fetch() is not allowed in hook/action bodies — declare a Connector recipe instead.Note that a CommonJS
require('node:os')in a TypeScript config reaches the extractor as esbuild's__require("node:os"). Both spellings are refused under the samerequire()reason.
Capabilities are inferred by matching known patterns in the body source (e.g. ctx.api.object(...).insert(...) ⇒ api.write). A fuller AST-based analysis is planned for a later version. When the inference is wrong, supply body yourself with an explicit capabilities array — that path is data rather than a comment, so it survives the build.
Migration
If you have an existing project that uses the old handler: 'function_name' + bundle.functions[name] shape, both forms are accepted during the transition:
| Phase | Status |
|---|---|
| Phase 1 (now) | Both handler and body accepted. Loader prefers body. |
| Phase 2 | Build emits a deprecation warning when handler is present without body. |
| Phase 3 | handler removed. body becomes the only accepted form. |
The CLI extractor handles the conversion automatically — you don't need to rewrite TS source files. Run objectstack build and your project artifact is in the new shape.
Bundle format
The compiled artifact dist/objectstack.json carries every hook and type='script' action body inline. The shape is identical for both:
{
"hooks": [
{
"name": "account_protection",
"object": "account",
"events": ["beforeInsert", "beforeUpdate"],
"priority": 200,
"handler": "account_protection",
"body": {
"language": "js",
"source": "const { event, input } = ctx; if (event === 'beforeInsert' || event === 'beforeUpdate') { … }",
"capabilities": ["api.read"]
}
}
],
"actions": [
{
"name": "send_quote",
"type": "script",
"target": "global_send_quote",
"body": {
"language": "js",
"source": "await ctx.api.object('quote').update(input.id, { sent_at: new Date().toISOString() }); return { ok: true };",
"capabilities": ["api.write"]
}
}
]
}handler / target strings still refer to entries in the sibling objectstack-runtime.{hash}.mjs bundle. That bundle is only used as a back-compat fallback for runtimes that haven't yet enabled the QuickJS interpreter — once the deprecation phase ends (Phase 3 above) the bundle disappears entirely and the artifact becomes a single self-contained JSON file. This is the cloud-deployable shape: cloud-artifact-api ships only the JSON; the QuickJS runtime in @objectstack/runtime rehydrates every body inside its sandbox at boot.
Capability inference
The extractor scans each body for known patterns and adds the matching capability tokens to body.capabilities:
| Pattern in source | Inferred capability |
|---|---|
*.object(…).find / findOne / count / aggregate / get / list | api.read |
*.object(…).insert / update / upsert / delete / patch / remove / create | api.write |
ctx.crypto.randomUUID | crypto.uuid |
ctx.log.info / warn / error / debug | log |
*.title(<argument>) — the related-record form only; bare ctx.title() performs no read | api.read |
When inference does not derive what a body needs, declare the tokens yourself by
supplying body on the hook or action instead of a handler:
hooks: [
{
name: 'notify_owner',
object: 'account',
events: ['afterInsert'],
body: {
language: 'js',
source: "await ctx.api.object('task').insert({ subject: 'follow up' });",
capabilities: ['api.write', 'log'],
},
},
]That path is data, not a comment, so nothing in the build pipeline can strip it on the way through — which is what makes it the supported way to say what a body needs.
The `@capabilities` directive comment was removed in 17.1
A directive comment on the first line of a handler body was once documented as a way to override inference. It was retired (#10917) and the extractor no longer reads it.
It had never worked from any ordinary authoring path. The override was read off
the handler's stringified source (String(fn)), and loadConfig runs your config
through bundle-require → esbuild, which strips // line comments before the
handler is ever a runtime function. Measured on all four shapes —
objectstack.config.ts, .js, .mjs, and a handler imported from a local module
— the build exited 0, printed nothing, and emitted the inferred capabilities
alone. A handler asking for more than inference derived was then refused by the
sandbox at runtime, far from the cause.
If a config of yours still carries one: removing it changes nothing, because it
was already inert. If you meant the tokens it named, declare them in
body.capabilities as above. Inference is unaffected either way — it matches the
code, which esbuild keeps.
Build pipeline at a glance
objectstack.config.ts
└── defineStack({...}) ← functions live in JS
└── normalizeStackInput() ← shape normalisation only
└── lowerCallables() ← extracts body + builds fn map
├── body:{...} ← shipped in dist/objectstack.json
└── handler:"ref" ← bundled into objectstack-runtime.{hash}.mjs
└── ObjectStackDefinitionSchema.safeParse()
└── writeFile(dist/objectstack.json)See also
- Formula Reference — the L1 expression engine.
- Hooks and Flows — broader patterns for hooks, actions, flows.
- Deployment Overview — the metadata-app lifecycle: how a compiled artifact reaches a running platform.
packages/spec/src/data/hook-body.zod.ts— canonical Zod schema.packages/runtime/src/sandbox/script-runner.ts— engine decision rationale.packages/cli/src/utils/extract-hook-body.ts— extractor + capability inference.