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. There is no separate .mjs file shipped alongside the project artifact, no dynamic import() at runtime, and no filesystem dependency on the cloud. 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/src/objects/account.hook.ts)
export const beforeInsert = defineHook({
name: 'normalize_account',
object: 'account',
events: ['beforeInsert'],
handler: async (ctx) => {
if (ctx.input.website) {
ctx.input.website = ctx.input.website.toLowerCase();
}
},
});// Build artifact (account.hook.json — 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 extracts the function body, AST-checks it, 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. Used for:
- Hook
condition(run-this-hook? predicate) - Action
bodyfor trivial computed values - Validation rules
{ "language": "expression", "source": "input.amount > 1000 && input.status == 'open'" }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.crypto.randomUUID() | UUID generation. | crypto.uuid |
ctx.crypto.hash(algo, data) | Sha-256/512 etc. | crypto.hash |
ctx.log.{info,warn,error} | Structured logging. | log |
ctx.connector(name).<method>(...) | Outbound HTTP / SaaS calls. | (separate Connector spec) |
What the sandbox forbids
The CLI builder rejects any source that uses:
import/require/ dynamicimport()fetch,XMLHttpRequest,WebSocketprocess,globalThis,Buffer,setImmediateeval,new Function,Functionconstructor- 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.)
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.
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 timeouts default to 250ms for hooks and 5000ms for actions; per-invocation memory caps at 32 MB. Both are overridable per body.
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 walks *.hook.ts and *.action.ts in your package:
- Parse with the TypeScript compiler API.
- Find each handler arrow / function expression.
- AST allow-list check the body (see "What the sandbox forbids" above).
- Pass: emit
body: { language: 'js', source: <printed body>, capabilities: <inferred> }. - Fail: the build aborts with a precise diagnostic — for example:
account.hook.ts:42 — fetch() is not allowed in hook bodies. Define a Connector recipe instead.
Capabilities are inferred from the AST (e.g. ctx.api.object(...).insert(...) ⇒ api.write). You can override with a directive comment when the inference is wrong.
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.crypto.hash | crypto.hash |
ctx.log.info / warn / error / debug | log |
To override the inference, add a directive comment as the first line of your handler body:
handler: async (ctx) => {
// @capabilities api.read api.write log
…
}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.
- Business Logic — broader patterns for hooks, actions, flows.
- Cloud Deployment — how artifacts travel from Studio to objectos.
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.