Hook and action bodies — the L1/L2 rules
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
.sudo(and.create(— members that are real on the in-processScopedContext/ObjectRepositoryand absent from the VM'sctx.api, so lowering them would ship a body thatTypeErrors on its first run. Write.insert({ ... })instead of.create({ ... })— it is the only insert verb theIScopedObjectRepositorycontract declares — and reach forrunAs: 'system'instead of.sudo().Object.create()is a real sandbox global and is not affected.ctx.dispatch.scopeandctx.submitted— context members the in-processHookContextcarries and the body face deliberately does not: a body'sctx.dispatchis{ mode, index }only, and it has noctx.submitted. Lowered, a write throughscopeTypeErrors on its first run and a read ofsubmittedseesundefined, so a guard on it evaluates as if nothing were submitted. For cross-dispatch work, do it once atctx.dispatch.index === 0or keep the state in the record throughctx.api; to derive from a read-only field, read the stored row onctx.previous.ctx.dispatch.mode/ctx.dispatch.index, and a field that merely happens to be namedscopeorsubmitted(ctx.input.scope), are not affected.ctx.provenance,ctx.qlandctx.transaction(hook handlers) — the rest of the in-processHookContextthe body face does not carry. A body readsctx.provenanceasundefined, so a guard onctx.provenance.flowRunIdevaluates as if the write had no origin;ctx.ql(the engine) andctx.transaction(a driver handle) are live host objects no copy into the sandbox can carry. Reach other objects throughctx.api.object(name), and group writes withctx.api.transaction(fn), which is not affected. The enumeration behind this list is derived, not remembered: a test compares every memberHookContextSchemadeclares against every member the sandbox marshals, and fails on one that is neither refused here nor recorded as a deliberate exception.
Like .sudo( and .create(, these refuse the body, not the handler: objectstack build bundles the handler into its runtime module (objectstack-runtime.*.mjs) instead and still exits 0 (with a warning naming the hook), os lint reports it as hook-body/bundled-fallback, and objectstack build --strict-body fails on it. The bundled handler runs in-process wherever that module is served beside objectstack.json; a deployment that serves the artifact alone has no function to bind the hook to, so it logs the hook as refused at boot and the hook never fires — build with --strict-body when the artifact must be body-only.
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, 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. The existence question is advisory like this because the answer can depend on a package the build cannot see; the separate writability question — writing areadonlyfield throughctx.api— does gate, because both halves of that judgement are declared in the stack being checked. - 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|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 caught at runtime, and the answer is the same on every driver. The sandbox's mutations are copied back onto the payload verbatim (applyMutationsToInput is a plain Object.assign) and the write-path validator still walks only the object's declared fields — but since #13657 the declared-field door runs a second time, over the payload the before* hooks produced, before any statement is built:
INVALID_FIELD / 400 / Unknown field 'stagee' on object 'deal'Identical on memory, driver-sql and sqlite-wasm, because none of them is reached. Before #13657 the driver decided instead, and the two families disagreed — SQL failed the whole write with an untyped SQLITE_ERROR, while schemaless drivers silently persisted the stray key as a column nothing downstream reads (and which field-level security, keyed by declared field name, could never gate). One app, one body, two meanings decided by which driver a deployment happened to run.
The runtime refusal is now the backstop; the advisory warning is still the earliest signal you get, and the one that names the mistake where it was made.
Because the existence check is advisory, and every write-side check here is 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 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. (The writability check now has a hook-side counterpart — see Writing areadonlyfield below — but it covers only thectx.apichannel.) - Exercise the hook against a real object before shipping — the mistake surfaces on the first write, identically on every driver.
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.
A hook handler's parameter does not have to be named ctx. The sandbox binds only ctx, so the build re-emits the handler's one parameter as the body's first statement: async (hookCtx) => { … } ships as var hookCtx = ctx; …, and async ({ input, previous }) => { … } as var { input, previous } = ctx; …. A handler whose parameter already is ctx, or that takes none, ships unchanged. What cannot be re-emitted that way is refused like a forbidden token — the handler is bundled, with a warning naming the parameter: more than one parameter (an in-process hook handler is only ever called with the context), a default value or a rest parameter on it, a pattern that binds the name ctx itself, a body that declares its own ctx, and a destructured log / crypto / title / api taken apart further than one plain name (capability inference cannot follow it). An action's (input, ctx) parameters are its own wrapper's and are left as they are.
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.
ctx.record is also not an authorization input. An action body runs elevated, so any caller-specific rule has to be re-established inside it — and ctx.record.id carries the requested recordId even when the caller-scope load did not deliver the row, so if (!ctx.record?.id) … never refuses. The key that distinguishes the two is ctx.recordLoadDenied (=== true exactly when a load was attempted and returned nothing; absent otherwise). See Authorization inside an action.
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).
Writing a readonly field
There is an asymmetry here that costs data if you learn it the hard way, so learn it here. A field declared readonly: true can still be maintained by automation from a body — but only through the own-hook stamp, or under a system context. A nested ctx.api write on a non-system trigger is not a channel, and since the maintainer ruling of 2026-09-03 (#14147) that is as true of insert as of update.
readonly governs the caller surface. On every non-system write — UPDATE, and INSERT too since that ruling moved the create-side strip into engine.insert — the engine strips read-only keys from the payload, but only the ones the caller supplied and only when the value is still the caller's. So:
| How the body writes it | What happens |
|---|---|
ctx.input.<field> = … in beforeInsert/beforeUpdate | Lands. The stamp is a server value, not a caller-supplied one, so the strip leaves it alone. This is the recommended shape. |
ctx.api.object('x').update({ <field> }) | Silently dropped — unless the hook declares runAs: 'system'. ctx.api is scoped to the triggering operation's context, so on any non-system trigger the payload is an ordinary caller payload and the key is stripped, and the call still returns success. Declaring runAs: 'system' gives that ctx.api a system context, which the strip skips, so the write lands. |
ctx.api.sudo().object('x').update({ <field> }) | TypeError — not available here. sudo() is a member of the in-process ScopedContext; the VM's ctx.api carries object() and transaction() and nothing else, so a body cannot reach it. Worse than unavailable: the same source works when the handler runs in-process, so it passes a native hook.handler(ctx) test and throws only once the build lowers it into a body — aborting the triggering write under the default onError: 'abort'. objectstack build now refuses to lower such a handler and keeps it bundled instead. The knob to reach for is runAs: 'system' on the hook itself, which is declarative and works on both surfaces. |
ctx.api.object('x').insert({ <field> }) | Silently dropped — unless the hook declares runAs: 'system', exactly as the update row. engine.insert runs the same static strip under the same isSystem gate, so a non-system create no longer seeds a read-only column: the key is removed, the field falls back to its defaultValue, and the call still returns success. Seeding a read-only column at create time is a system act — runAs: 'system' here, or a beforeInsert stamp on the target object. (Before the 2026-09-03 ruling this row read "Lands — INSERT is exempt"; that row is superseded.) |
The dropped case is the dangerous one: nothing fails, the step reports success, and the column is simply always null. Because both halves of that judgement are declared in your own stack, it is checked at author time and gates the build:
hook-api-update-readonly-field— error. A body's literalctx.api.object('…').update()/.updateById()/.insert()writes a field the named object declaresreadonly: true. Since #15394 theinsertrow of the table above is reported at build time exactly like theupdaterow — same id, same severity, a message naming the verb — unless the hook declaresrunAs: 'system'. Only the static shape is judged on an insert: areadonlyWhenfield has no prior record to lock on and the engine runs no conditional strip on INSERT, so no warning is produced there.hook-api-update-readonly-when-field— warning. The same write against areadonlyWhenfield, which strips per record state. The own-hook stamp is the workaround here, exactly as it is for staticreadonly: since #9107 the conditional strip judges the caller's entry payload, so a value abeforeUpdatehook derives is not caller-supplied and lands even on a locked record. (Deriving is the operative word — a hook that merely echoes the caller's own value back has written nothing the strip can tell from the caller's, and it still goes.) What does not help is elevation: unlike the static strip, the conditional lock is not waived by a system context, so neitherrunAs: 'system'nor thesudo()a body cannot reach makes a caller-supplied value survive. On this shape, confirm the write only targets records whose predicate isfalse, or derive the field in abeforeUpdatehook on the target object.
Which hooks these rules can see depends on the command, because every rule in this family opens on body.language === 'js'. A hook authored as an inline handler function carries no body, so it is judged only where the command has first lowered the handler to a metadata body: objectstack build always has (it lowers before it parses — see How the build lowers a handler), and since #16095 objectstack lint judges that same lowered view, so an author who runs only the pre-flight is told the same thing the build would refuse. Since #16544 objectstack validate lowers before it parses as well, so all three commands judge the same view of a handler-authored hook — a stack objectstack validate passes is one objectstack build does not refuse on this family. A handler the build cannot lower (a forbidden token, a module-scope identifier) has no body on any command and is reported by the lowering rules instead, never guessed at here.
Only literal object names and literal payload keys are seen; a sudo() chain, a dynamic object name and an object this stack does not declare are all skipped, so the rule has no opinion on them. .create() is skipped too, and since #16249 it cannot even arrive: objectstack build refuses .create( at lowering, so a handler spelling it is bundled and never becomes a body these rules parse, and the write-shape ledger no longer advertises the verb. The reason behind that refusal is about the sandbox rather than the engine: the VM-side ctx.api.object() installs insert / update / delete / updateMany / deleteMany / upsert and no create leaf, and the IScopedObjectRepository contract declares insert only — so a body calling .create() threw TypeError: not a function on its first run, aborting the triggering write under the default onError: 'abort'. A loud failure, never a silent drop; the same payload spelled .insert() is what the rules judge. The flow surface has carried the same gate as flow-update-readonly-field since #3425, and since #15394 it reports a non-runAs: 'system' create_record node's static-readonly write at the same error, again with no conditional finding on a create.
The table above is about a hook body. An action body is the one surface where the answer changes, so read this before you move a body from one to the other: an action body runs elevated — its ctx.api is built over the caller's envelope with isSystem set, which is the same trusted posture that lets an action bypass row and field permissions — and the static strip applies only to non-system callers. So ctx.api.object('x').update({ someReadonlyField }) lands in an action, and there is no finding for it. Elevation does not waive the conditional lock, though, so that half does carry across: action-api-update-readonly-when-field — a warning — on an action body's literal ctx.api update to a readonlyWhen field (#13770). Net effect when you move a body: a readonly write changes behaviour, a readonlyWhen write does not.
Elevating a body — runAs
The knob that makes "guarded and maintained" expressible is a hook-level declaration, not a body API: runAs: 'system' gives the hook's ctx.api a system context, which is what lets a column nobody may hand-write still be maintained by the automation that owns it. It is honoured identically for an in-process handler and a sandboxed body — which is the whole reason it exists rather than ctx.api.sudo(), a member only the in-process surface has.
{
name: 'stamp_account_grade',
object: 'rating',
events: ['afterInsert'],
runAs: 'system', // this hook maintains a column nobody may hand-write
body: {
language: 'js',
source: `await ctx.api.object('account').update({ id: ctx.input.account_id, current_grade: ctx.input.grade });`,
capabilities: ['api.read', 'api.write'],
},
}It scopes ctx.api and nothing else — the condition gate, ctx.session, the strip applied to the hook's own ctx.input, and async behaviour all still read the triggering operation's context. Full semantics, including the 'user' value and what it refuses, are on Hooks → Elevation.
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 |
The matcher is deliberately over-inclusive — it names spellings the VM does not install (patch, remove, get, list, create) because an over-inferred token costs nothing the sandbox ever checks, while an under-inferred one surfaces as a sandbox refusal far from its cause. create is listed only for that reason: a body spelling it is refused at lowering (see What the sandbox forbids) and never reaches inference at all.
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.