Found while fixing the target/execute precedence in #2895.
What the spec says
@objectstack/spec ActionSchema carries a body field, and for script actions it is the preferred binding:
// packages/spec/src/ui/action.zod.ts:306
// For `script` type: prefer `body` over `target`. `target` is kept only for
// legacy bundle.functions[name] references.
// :353
body: HookBodySchema.optional().describe('Action body — expression (L1) or sandboxed JS (L2). Only used when type is `script`.'),
HookBodySchema is a discriminated union on language:
- L1
{ language: 'expression', source } — pure formula, no IO.
- L2
{ language: 'js', source, capabilities, timeoutMs, memoryMb } — function body wrapped in new AsyncFunction('input', 'ctx', source) and run inside an isolated VM, with capability tokens (api.read, api.write, api.transaction, crypto.*, log) enforced at invocation.
What already works — and what doesn't
This is not broken in the console. useConsoleActionRuntime registers a server handler for the whole script type:
// packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
handlers: { api: apiHandler, flow: flowHandler, script: serverActionHandler, modal: modalActionHandler },
and ActionRunner gives registered handlers priority over its built-ins:
// packages/core/src/actions/ActionRunner.ts:493
if (actionType && this.handlers.has(actionType)) { … } // handler wins
…
case 'script': result = await this.executeScript(action); // built-in fallback
So in the console a script action POSTs to /api/v1/actions/{object}/{action}, and the server runs the body through its sandbox (packages/runtime/src/sandbox/{script-runner,quickjs-runner,body-runner}.ts → engine.executeAction). Bodies work.
The gap is the built-in fallback at line 504 — used by every consumer that does not register a script handler (standalone @object-ui/core, SDUI, embedded renderers). It reads only target/execute:
const script = action.target || action.execute; // after #2895
if (!script) {
return { success: false, error: 'No script provided for script action' };
}
A perfectly spec-valid action:
{ "type": "script", "body": { "language": "expression", "source": "input.amount > 1000" } }
fails there with "No script provided for script action" — which is false. A script was provided; this runner just can't run it. ActionDef doesn't even declare body, so nothing type-level flags it either.
The fix is NOT to execute bodies client-side
Worth stating up front, because "implement body" is the tempting reading:
- L2 (
js) must never run in the browser. The whole contract is an isolated VM enforcing declared capabilities, a hard timeoutMs and memoryMb, and mediated ctx.api read/write/transaction access. In a browser there is no isolate and no enforcement boundary — "checking" capabilities client-side is decoration, and api.write from an unsandboxed page is strictly worse than not supporting it.
- L1 (
expression) is a different dialect here. The spec evaluates L1 with the formula engine (CEL — packages/formula/src/cel-engine.ts). This repo's ExpressionEvaluator is a ${…} template-string evaluator for "dynamic UI behavior". The two agree on simple comparisons and diverge on the rest — silent wrong answers, which is worse than an error.
Bodies are a server-side execution surface, reached over HTTP. The client's job is to dispatch, not to interpret.
Proposed fix
Small and honest:
- Declare
body? on ActionDef as an opaque marker (not a client-executed field), documenting that it is server-executed.
- In
executeScript, when there is no client-side target/execute and a body is present, fail with an error that names the real cause and the remedy — the body runs server-side, register a script handler the way the console does — instead of the misleading "No script provided".
- Keep the existing message for actions that genuinely carry no source at all.
That turns a confusing dead end into a directed one, without pretending the browser can honor the L2 contract.
Out of scope
Wiring a default server dispatcher into @object-ui/core (so standalone consumers get body support without registering a handler) is a bigger design question — core has no opinion about auth, base URL, or object resolution today, all of which serverActionHandler needs. Worth its own discussion.
Found while fixing the
target/executeprecedence in #2895.What the spec says
@objectstack/specActionSchemacarries abodyfield, and for script actions it is the preferred binding:HookBodySchemais a discriminated union onlanguage:{ language: 'expression', source }— pure formula, no IO.{ language: 'js', source, capabilities, timeoutMs, memoryMb }— function body wrapped innew AsyncFunction('input', 'ctx', source)and run inside an isolated VM, with capability tokens (api.read,api.write,api.transaction,crypto.*,log) enforced at invocation.What already works — and what doesn't
This is not broken in the console.
useConsoleActionRuntimeregisters a server handler for the wholescripttype:and
ActionRunnergives registered handlers priority over its built-ins:So in the console a script action POSTs to
/api/v1/actions/{object}/{action}, and the server runs the body through its sandbox (packages/runtime/src/sandbox/{script-runner,quickjs-runner,body-runner}.ts→engine.executeAction). Bodies work.The gap is the built-in fallback at line 504 — used by every consumer that does not register a
scripthandler (standalone@object-ui/core, SDUI, embedded renderers). It reads onlytarget/execute:A perfectly spec-valid action:
{ "type": "script", "body": { "language": "expression", "source": "input.amount > 1000" } }fails there with "No script provided for script action" — which is false. A script was provided; this runner just can't run it.
ActionDefdoesn't even declarebody, so nothing type-level flags it either.The fix is NOT to execute bodies client-side
Worth stating up front, because "implement
body" is the tempting reading:js) must never run in the browser. The whole contract is an isolated VM enforcing declared capabilities, a hardtimeoutMsandmemoryMb, and mediatedctx.apiread/write/transaction access. In a browser there is no isolate and no enforcement boundary — "checking" capabilities client-side is decoration, andapi.writefrom an unsandboxed page is strictly worse than not supporting it.expression) is a different dialect here. The spec evaluates L1 with the formula engine (CEL —packages/formula/src/cel-engine.ts). This repo'sExpressionEvaluatoris a${…}template-string evaluator for "dynamic UI behavior". The two agree on simple comparisons and diverge on the rest — silent wrong answers, which is worse than an error.Bodies are a server-side execution surface, reached over HTTP. The client's job is to dispatch, not to interpret.
Proposed fix
Small and honest:
body?onActionDefas an opaque marker (not a client-executed field), documenting that it is server-executed.executeScript, when there is no client-sidetarget/executeand abodyis present, fail with an error that names the real cause and the remedy — the body runs server-side, register ascripthandler the way the console does — instead of the misleading "No script provided".That turns a confusing dead end into a directed one, without pretending the browser can honor the L2 contract.
Out of scope
Wiring a default server dispatcher into
@object-ui/core(so standalone consumers get body support without registering a handler) is a bigger design question — core has no opinion about auth, base URL, or object resolution today, all of whichserverActionHandlerneeds. Worth its own discussion.