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. 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?

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. This is the body.language: 'expression' shape, used for:

  • Action body for 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:

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) (not yet wired)Sha-256/512 etc. The crypto.hash capability exists in the schema and is inferred by the build-time extractor, but the QuickJS sandbox currently only installs ctx.crypto.randomUUID — calling ctx.crypto.hash(...) throws inside the VM today.crypto.hash
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)

What the sandbox forbids

The CLI builder rejects any source that uses:

  • import / require / dynamic import()
  • fetch
  • process, globalThis
  • eval, 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.)

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 (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:

  1. Load the defineStack config and normalise its shape.
  2. For each inline handler, take its source via String(fn) (the callable is already loaded by tsx/esbuild).
  3. Run a regex allow-list over the stringified body (see "What the sandbox forbids" above).
  4. Pass: emit body: { language: 'js', source: <body>, capabilities: <inferred> }.
  5. Forbidden token (default): extraction fails, a bodyExtractionWarning is recorded, and the callable still ships via the back-compat handler-ref bundle — the build does not abort. Pass objectstack compile --strict-body to 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.

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. 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.
  • Hooks and Flows — 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