Skip to main content

Macro Scripts

The Run Script macro step executes JavaScript in a sandboxed VM. Use it for logic that doesn't fit the other step types — computing a value, iterating dynamic collections, conditional branching beyond the built-in condition steps, or driving several devices from one place.

Authoring a script needs elevated access

Only an elevated (admin) user may add or edit a Run Script step. A non-elevated session — a kiosk or scene-builder login — can still build ordinary command/scene steps, but any attempt to create a Run Script step, or to re-type an existing step into one, is refused with not authorized. A script runs real code with the whole server in scope, so authoring one is an admin-only action. Give the operator an elevated role if they need to write scripts.

The execution surface​

A script runs in an isolated context with these globals:

GlobalWhat it is
gemThe live server. Call gem.command(...), gem.macro(...), gem.getAttributeValue(target, id, name), gem.setAttribute(target, id, name, value, valueType), and read the in-memory collections gem.zones, gem.devices, gem.subsystems, gem.avZones, gem.avSources — each keyed by id, with the entity's current attributes merged onto the object. Read live state from these, not from a database query.
contextThe current macro context: one object shared by every step in this run. Read values placed by a Set Variable step, arguments passed to the macro, or the current item of a For Each, and assign context.<name> = … to hand a value to the steps that follow (they see the same object).
signalAn AbortSignal that fires when the macro run is stopped.
console, setTimeout / setInterval (and their clear…), Promise, Buffer, URL / URLSearchParamsStandard utilities.
JSON, Math, Date, Array, Object, String, Number, Boolean, RegExp, Map, Set, Error, TypeError, RangeError, parseInt, parseFloat, isNaN, isFinite, encodeURIComponent / decodeURIComponent, encodeURI / decodeURIStandard JS built-ins.

process, require, and the filesystem are not exposed as globals.

Command payload keys​

gem.command(...) takes the verb as command, and identifies its target by id with zone_id / device_id or by name with zone / device. Extra values go in args:

await gem.command({ zone_id: 5, command: 'on' });
await gem.command({ zone_id: 5, command: 'level', args: { level: 60 } });
await gem.command({ device_id: 3, command: 'power_on' });

Zone shorthand​

A zone payload that names no command is read as shorthand: the one remaining key becomes the command, and its value is folded into args under that same name.

await gem.command({ zone_id: 5, level: 60 });
// expands to { command: 'level', zone_id: 5, args: { level: 60 } }

The expansion runs once at the very front of dispatch, before the zone or device is resolved — so it is a client-facing convenience only. A driver never sees the shorthand; by the time the payload reaches one it is always the canonical {command, args} form, and drivers read their inputs from args.

The rules are narrow:

  • Only when there is no command key. An explicit command always wins, and a top-level value sitting beside it is then simply ignored — nothing downstream reads it, so that value belongs in args.
  • Only for zone targets. A device_id-only payload is never shorthand; give it an explicit command.
  • Exactly one key may remain besides zone_id / zone / args and any _-prefixed engine keys. A second one turns the expansion off and the payload goes through with no command at all.
  • An existing args is preserved, with the shorthand key merged over it.
  • An id or a name both work. {zone_id: 5, level: 60} and {zone: 'kitchen', level: 60} each resolve.

This is the same contract every surface uses — see Command payloads for the REST and socket forms.

There is no action key — and a payload using one is not rejected. {zone_id: 5, action: 'on'} matches the shorthand rule above, so it dispatches a command literally named action, which no target has. Check the verb key first when a command doesn't reach the hardware.

Reading an attribute — value or row​

gem.getAttributeValue(target, id, name) resolves to the attribute's value, cast to its value_type. This is usually the one you want:

const setpoint = await gem.getAttributeValue('zone', 172, 'setpoint'); // 71

gem.getAttribute(target, id, name) resolves to the whole row — value_type, last_update, quality, readonly, secure and the rest. The cast value appears on it twice: under .value, and under a key named after the attribute.

const row = await gem.getAttribute('zone', 172, 'setpoint');
row.value // 71 — cast to value_type, not the raw TEXT column
row.setpoint // 71 — the same value, under the attribute's own name
row.value_type // 'int'
row.last_update // '2026-08-23T08:19:17.405Z'

They differ when the attribute doesn't exist, and that is the usual source of confusion:

await gem.getAttributeValue('zone', 172, 'nope'); // undefined — falsy, safe to test directly
await gem.getAttribute('zone', 172, 'nope'); // {} — TRUTHY, an empty row

Because a missing attribute still answers with an object, if (await gem.getAttribute(...)) is always true. Take the value unless you actually need the row's metadata.

For entities that live in the in-memory collections, prefer reading the merged property directly — gem.zones[172].setpoint — which is already current and costs no query.

A script inherits the invoker's authority​

When a run was started by a non-elevated caller — a kiosk, a UI button, a REST or voice request from a session without elevation — the gem handed to the script is scoped to that caller: every gem.command(...) it dispatches is stamped non-elevated, so a privileged target (a door controller, gate, or alarm panel) refuses it exactly as it would refuse that user directly. A script cannot lend its own authority to whoever triggered it.

Runs started on-box — triggers, schedules, drivers, and macros invoked by other macros — carry no invoker elevation and are unaffected. If a script works when you run it from the admin UI but its commands are refused from a panel, this is why: give the invoking session an elevated role, or move the privileged dispatch into a macro that runs from a trigger.

Writing a script​

The script body is compiled as-is first; if that fails to compile, it is retried wrapped in an async IIFE. The practical result is that top-level await and top-level return both work, and the macro waits for the script — including all awaited async work — before the next step runs:

// side effects — this is how a script changes the system
await gem.setAttribute('zone', 5, 'state', 'on', 'string');

// read live state from the in-memory collections
const zones = Object.values(gem.zones);
const active = zones.filter(z => z.state === 'on');

// hand a value to later steps, and/or produce a result for the run log
context.active_count = active.length;
return { count: active.length };

Wrapping the body in an async IIFE yourself is equivalent and still supported — plenty of existing scripts do, and they keep working unchanged:

(async () => {
await gem.setAttribute('zone', 5, 'state', 'on', 'string');
return { count: Object.values(gem.zones).filter(z => z.state === 'on').length };
})()

What the step does with your script​

  • Side effects are the point. A script affects the system by calling gem.command(...), gem.setAttribute(...), and the other gem.* helpers, and by writing onto context so later steps can read what it computed. Those happen as the script runs.
  • The result is recorded and sets the step status. The value the script produces is stored in the macro's run log (visible in the run history) and decides the step outcome: return an object shaped {error: 'message'} — or throw — and the step is marked error, which stops the macro if it's set to stop on error. The run reports aborted if the macro was stopped mid-script.
  • The result is captured when the script resolves asynchronously. Using await or return at the top level, or ending on an async IIFE or a bare async call such as gem.command({ device_id: 3, command: 'on' }), all produce a captured result. A purely synchronous script whose final expression is a plain value (a number, a bare object literal, with no return) is not captured — the step result comes back empty. The side effects still happen regardless; only the recorded result differs.

Arguments, context, and persistent state​

Script source is never token-substituted​

Every other step type has [token] placeholders in its data replaced from the run context before it runs. Script source deliberately does not — brackets are JavaScript syntax (zones[id], [a, b] = pair), and rewriting source text would corrupt it. Read from the context global instead:

// WRONG — [level] is left alone, and this is just an array literal
await gem.command({ zone_id: 5, command: 'level', args: { level: [level] } });

// RIGHT
await gem.command({ zone_id: 5, command: 'level', args: { level: context.level } });

Arguments passed to the macro land as plain keys on context — a macro argument named level is context.level, not context.args.level — alongside anything a Set Variable step or a trigger put there. If a script's source contains a [token] matching a context key, the run journal warns about it and the step shows a token-collision marker in the diagram's Live mode; older builds did substitute code fields, so this is worth checking after an upgrade.

Earlier steps' Outputs are readable as context._locals.<name>. A step that names an output stores its result there for the rest of the run and its sub-macros — a Command step's driver reply, a Web Request's {data, status}, or another script's return value.

context._state persists across runs​

context._state is a plain JSON object that survives from one run of the macro to the next — loaded at run start, saved at run end when it changed. Use it for counters, debounce timestamps, and previous-value comparisons:

const now = Date.now();
if (context._state.last_fired && now - context._state.last_fired < 60000) {
return { skipped: 'within debounce window' };
}
context._state.last_fired = now;
context._state.run_count = (context._state.run_count || 0) + 1;
await gem.command({ zone_id: 5, command: 'on' });
return { run_count: context._state.run_count };

State is per macro — a nested macro gets its own, and never clobbers its parent's. Secret-named keys are nulled before saving, and the serialized object is capped at ~16KB; exceeding the cap logs a warning and skips the save, leaving the previous state intact.

Timeouts and aborts​

The 30-second limit bounds synchronous code only

The VM's 30-second timeout terminates a synchronous runaway — while (true) {} is stopped, and the controller stays up. It does not bound async work. Once your script hands back a promise, the step waits for it with no built-in deadline, so an await on an unresponsive device or a request that never settles will stall the macro run indefinitely.

Set a timeout on any script that awaits a device or the network. Two are available, both in milliseconds:

  • the step timeout, which races the whole step and fails it on expiry;
  • the macro timeout, which aborts the entire run.

Scripts honor the macro's abort signal, so a long-running async script exits promptly when the macro is stopped — but only when something actually stops it. The signal global lets you cooperate explicitly, which matters inside loops:

for (const zone of Object.values(gem.zones)) {
if (signal.aborted) return { aborted: true };
await gem.command({ zone_id: zone.id, command: 'off' });
}

Scripts can run more than once​

A step that returns {error: …} is retried when the macro's on-error policy is set to retry (with configurable retry count, delay, and backoff). Write scripts so a repeat run is harmless — check context._state before repeating a side effect, or make the dispatch naturally idempotent.

The sandbox is a namespace, not a privilege boundary​

The VM context limits what's in scope — it keeps the global namespace small and predictable, so a script can't casually reach require or the filesystem. It is not a security boundary, and it should not be treated as one. gem is the real server object, which reaches the database and every driver; and a VM context sharing objects with its host has never been isolation you can rely on against code that is deliberately trying to break out.

That's why authoring is the boundary: adding or editing a Run Script step requires elevation, and a script started by a non-elevated invoker dispatches with that invoker's authority. Grant script authoring only to operators you would trust with shell access to the controller.

warning
gem.* and context.* are a frozen public API

Saved macros and user scripts are stored as JSON written by past versions. Changing the signature of a gem.* or context.* method silently breaks every stored script that uses it. New helpers are added; existing ones are never repurposed or removed. Prefer the documented gem.<helper> methods over raw database access.

See the Automation guides for building macros, and Architecture for how gem relates to the rest of the system.