ObjectStackObjectStack

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?

ConstraintImplication
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 body for 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:

FieldDescriptionCapability required
ctx.inputMutable record being inserted/updated/etc.none
ctx.previousPre-update record (update events only).none
ctx.user / ctx.sessionIdentity context.none
ctx.api.object(name).find|count|aggregateCross-object reads, scoped to current tenant.api.read
ctx.api.object(name).insert|update|deleteCross-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 / dynamic import()
  • fetch, XMLHttpRequest, WebSocket
  • process, globalThis, Buffer, setImmediate
  • eval, new Function, Function constructor
  • 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

SurfaceTS authoringSandbox 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:

  1. It meant cloud-deployed objectos had to download a JS module out-of-band, adding another transport, another cache, another vector for tenant cross-talk.
  2. It bypassed the sandbox entirely — a misbehaving module could call any Node API on the host.
  3. It made hot-reload from Studio impossible; you cannot edit a baked .mjs from 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:

  1. Parse with the TypeScript compiler API.
  2. Find each handler arrow / function expression.
  3. AST allow-list check the body (see "What the sandbox forbids" above).
  4. Pass: emit body: { language: 'js', source: <printed body>, capabilities: <inferred> }.
  5. 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:

PhaseStatus
Phase 1 (now)Both handler and body accepted. Loader prefers body.
Phase 2Build emits a deprecation warning when handler is present without body.
Phase 3handler 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 sourceInferred capability
*.object(…).find / findOne / count / aggregate / get / listapi.read
*.object(…).insert / update / upsert / delete / patch / remove / createapi.write
ctx.crypto.randomUUIDcrypto.uuid
ctx.crypto.hashcrypto.hash
ctx.log.info / warn / error / debuglog

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.

On this page