---
title: Hook & Action Bodies (L1 / L2)
description: 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** &nbsp;&nbsp; a formula-engine string, side-effect-free.
- **L2 — Sandboxed JS** &nbsp;&nbsp; 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

```ts
// 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();
    }
  },
});
```

```jsonc
// 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 `body` for trivial computed values
- Validation rules

```jsonc
{ "language": "expression", "source": "input.amount > 1000 && input.status == 'open'" }
```

Evaluated by the same formula engine that powers field formulas — see [Formula Reference](./formula).

## L2 — Sandboxed JS bodies

A JavaScript **function body** (not a full module) executed inside QuickJS.

```jsonc
{
  "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` / 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

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

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:

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

```json
{
  "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:

```ts
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](./formula) — the L1 expression engine.
- [Business Logic](./business-logic) — broader patterns for hooks, actions, flows.
- [Cloud Deployment](./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.
