Skip to main content

Macros

Macros are ordered sequences of steps that automate multi-step operations — scenes, modes, and coordinated control across lighting, AV, shades, climate and security. A macro is the unit almost everything else in GEM calls: schedules fire macros, triggers fire macros, UI buttons fire macros, AV zones fire on/off macros, and access-control events fire macros.

Open Macros

Viewing Macros

The main grid lists every macro with these columns:

  • ID — Unique macro identifier
  • Name — Internal name (lowercase, numbers, underscores)
  • Label — Display name shown to users
  • Subsystem — Logical grouping
  • Args — The argument names this macro accepts, if any
  • Elevated — Whether an elevated role is required to run it
  • Enabled — Whether the macro can be executed

Cells are editable in place: click the cell to focus it, press Enter to open the editor, type the new value and commit. This is how you set a macro's Label — the macro properties editor has Name and Subsystem but no Label field.

Grid Actions

  • Add — Create a new macro (opens the macro properties editor)
  • Edit — Opens the step editor for the macro. Editing or clicking a row goes straight to its steps, since that's what you usually want
  • Delete — Remove a macro. The confirm dialog reports how many steps the macro has, because deleting it also permanently deletes all of its steps (they are removed first so the macro row can go)
  • Reload — Refresh the grid data

Quick Actions (Toolbox)

For each macro row:

  • Run Macro (run icon) — Execute the macro immediately. An info toast confirms it started; a success or error toast reports the result
  • Edit Definition (pencil) — Open the macro properties editor (name, subsystem, timeout, enabled/elevated/stop-on-error, arguments, expectations, run history)
  • Duplicate (copy) — Prompts for a new name, then deep-copies the macro and every one of its steps
  • Schedule Macro (calendar) — Open the macro schedule creator in a modal, already pointed at this macro. It saves the schedule directly, so the list keeps its filters and scroll position
  • Create Trigger (bolt) — Open the attribute trigger creator in a modal with this macro prefilled as the action, so you only have to pick the attribute that fires it
  • Find References (magnifier) — Show everywhere in the system that references this macro: schedules, command macros, AV zones and sources, UI macros, and other macros whose steps call it

The two editors are reciprocal: from the step editor an Edit macro details button opens the properties editor in a modal, and from the properties editor an Edit steps → button opens the step editor in a modal — so you can move between a macro's definition and its steps without losing your place.

Creating a Macro

  1. Click Add in the grid toolbar. The macro properties editor opens with the header Create a Macro.

  2. Basic Information

    Name — Internal identifier, required. Lowercase letters, numbers and underscores only; the field auto-formats what you type. Examples: good_morning, movie_time, all_lights_off.

    Two shapes are refused. A name that is only digits (7, 42) collides with the macro id space — macros are looked up by name and by id, and several places try the name first, so 7 would answer a reference that meant macro id 7. Use scene_7 or preset_7 instead. A name that is only a JavaScript coercion word (undefined, null, __proto__, constructor) is refused for the same reason. Digits anywhere else in the name are fine.

    Subsystem — Required. Pick the subsystem this macro belongs to (Lighting, AV, Security, Climate…). It groups the macro in selectors and UIs.

  3. Configuration

    Timeout (ms) — Maximum execution time in milliseconds. When the macro is still running past this, the run is aborted and reported as aborted with reason timeout. Leave it at 0 for no limit.

    Stop on Error — When on, the macro halts at the first step that fails (whether it throws or returns an error). When off, it continues to the next step regardless.

    Enabled — Whether the macro can be executed at all.

    Elevated — When on, only users with an elevated role can run the macro.

    When already running — What a new invocation does while a run of this macro is still in flight. See When a macro is already running.

  4. Click Create Macro. (On an existing macro the same button reads Update Macro.)

Switch the toggles on deliberately

All four switches start off on a blank form, including Enabled. A macro saved without touching them is created disabled and will not run from a UI button, schedule or trigger until you switch Enabled on. If you were just editing another macro's definition, the form keeps that macro's switch positions — check all four before saving.

What Elevated actually gates

The Elevated flag is enforced at every client entry point: running a macro from a UI or the admin, a command-shaped macro redirect, a voice/Alexa intent, the AI assistant's run-macro action, and the REST API POST /api/control/macro and /api/control/command. Server-side schedules and triggers run the macro regardless of the flag.

Editing an elevated macro also requires an elevated role, and a non-elevated session cannot clear the Elevated flag — so the flag can't be turned off and the macro then run.

Keep it on for macros that arm or disarm security, unlock doors, open gates, run scripts, or otherwise shouldn't be reachable from the user UI. Turn it off for scenes, UI macro buttons, and anything meant for non-admin users.

Arguments & Options

Arguments — A list of argument names the macro accepts when called (for example level, color, zone_id). Add each name to the list.

Argument Options — Select an argument in the Arguments list, then define the values it may take:

  • Leave Dynamic unchecked and add entries to the list to give the argument a fixed set of choices. Anywhere the macro is invoked with a form — the Test dialog, a Run Macro step, a schedule — the argument renders as a dropdown of those choices instead of a free-text box.
  • Tick Dynamic and type an expression (for example [$sunset]) to resolve the value at call time.

Arguments are substituted into step fields as square-bracket tokens — [level], [color] — not as {args.level}. See Using arguments and context in steps.

Expectations

Expectations declare the state the system should be in after the macro runs, so GEM can verify the macro actually achieved its result.

Fill the row and click the + button to add one:

  • System TargetZone or Device
  • Target Object — The specific zone or device to check
  • Attribute Name — Which attribute to verify (pick from the object's current attributes, or type one in)
  • Expected Value — The value it should hold once the macro is done
  • Value Typestring, int or bool

Expectations are checked roughly ten seconds after the run finishes — long enough for devices to report back. Any mismatch is recorded on the macro's own expectation_error attribute, which clears again on the next run where everything matches. That attribute is what to watch (or trigger on) if you want to be told when a scene didn't take.

Editing Macro Steps

Click a macro row in the grid (or use Edit steps → from the properties editor). The step editor opens with a breadcrumb showing [id] name and an Edit macro details button, and two tabs: Diagram and List. Click Back to List at the bottom when you're done.

When the macro is not flagged Elevated but one of its Command steps operates an elevated target — a device, zone or macro flagged Elevated — a warning banner above the tabs lists those steps. They run for every user who can invoke the macro, because a literal step target is a capability the author granted, not something the server re-checks at run time (see Who can author which steps). That is often exactly right — a curated "buzz the gate" action on a device users can't command directly. When it isn't, flag the macro Elevated in Edit macro details to restrict who may invoke it. Saving a step that newly creates such an exposure raises the same warning as a toast. Steps with dynamic [token] targets never appear in the banner — those stay gated at run time against the caller.

Diagram tab

The Diagram tab is the main working view: a palette of step types on the left, the flow in the middle, and a side panel on the right that shows whatever you are working on — the step editor, the live inspector, or the selected step's summary and actions.

The flow draws the macro exactly as the engine runs it: one column of steps in execution order, from START to END. Nothing about the picture is stored; it is laid out from the step list every time, so it can never disagree with what runs. What the drawing means:

  • A step shows its label (or type), a one-line summary of its settings, and badges for an Output name, a stop verb, or a skip verb. The palette lists every step type the server knows, so newly added types such as Start Timer and Cancel Timer appear without an update to the editor
  • A containerIf / Else, Switch, Repeat, Parallel — is a box with a lane per body: THEN beside ELSE for If / Else, a lane per case beside OTHERWISE for Switch, a single lane for Repeat (with a loop-back arrow) and Parallel. Its children sit inside the lane in the order they run. Click the on a container to collapse it to one line; Collapse all / Expand all in the toolbar does every container at once
  • A dashed amber bypass arrow from an If step jumps over the step it skips, labelled with the outcome that skips. A skip on the last step is drawn faded with "nothing to skip"
  • A chip beside a step names another macro it fires (click to open it) or an inline action on an If step's outcome
  • A disabled step is greyed and struck through

Working with steps:

  • Click a step to select it. The side panel shows its summary with Edit, Duplicate, Enable / Disable, Move and Delete. Double-click or press Enter to edit
  • Drag a top-level step up or down to reorder it; the blue line shows where it will land. One write reorders the whole macro
  • Add a step by dragging a type from the palette onto the flow — the blue line shows the gap it will be inserted in — or by clicking a type, which adds it after the selected step (at the end when nothing is selected). The palette lists every step type the server knows, grouped, with a search box
  • Keyboard: move the selection through every step including container children, Home / End jump, Enter edits, Space collapses a container, Delete deletes, Ctrl+D duplicates, Ctrl+Z undoes the last reorder, delete, duplicate or enable change, Esc clears the selection
  • Zoom with Ctrl+wheel, the / + buttons, or Fit to see the whole macro. A plain wheel scrolls
  • Children of a container are edited through the container: selecting one offers Edit container, and the editor opens on the container's form with its child list

The side panel can be resized by dragging its left edge, given the whole width with , or closed with ×; it reopens when you select or edit a step. Its Context section — the simulated context every step is previewed and linted against — is there whenever you are editing, whether or not a step is selected, and the toolbar's Context button opens it (see Context Simulator). When editing a step, the panel adds the Context panel, a Preview with context button, and the step form. The List tab is unchanged and edits open in the same panel.

The data rail. With Data ticked in the toolbar, a rail to the right of the flow shows every context key the macro touches: a column per key, a chip where it comes into existence — at START for arguments, simulated context and persistent state, at the step that writes it otherwise — a lifeline while it is in scope, and a hollow mark at every step that reads it. A key read but never provided is a dashed red column, and the toolbar counts them. In Live mode the marks' tooltips carry the actual values from the run.

Live mode. Edit / Live switches between authoring and watching. In Live mode a timeline across the top shows the run as bars, one per journaled step, placed and sized by time and coloured by status; click a bar to select its step. Every step and every container child gets a status dot as the run progresses, and the toolbar adds Last Run / Last Failure, an Earlier runs picker over the runs kept for this macro (see Kept runs), and a Stop button while a run is in flight. A dry run shows a dry run badge and its skipped steps show as skipped. The side panel becomes the inspector for the selected step: status and timing, the error, warnings (token collisions, dispatch dead-ends), where the result was stored, what changed in the context since the previous journaled step, the resolved settings it actually ran with, its result, and the full context it received. Select END to see the run's ending: the context as the run left it — every argument, local and output — what changed since the run started, and the persistent state the next run will load. Runs recorded before this build have no final context on record. The Last Failure view is kept separately from Last Run, so a failure stays inspectable after later successful runs have overwritten the last-run record.

Test runs the whole macro and switches to Live (see Test runs with context); Dry run rehearses it instead (see Dry runs); Reload re-reads the macro and its steps into the running system.

Problems. The side panel's Problems section lists what the linter finds in the macro as it stands, and every step with a finding carries a coloured dot on the flow — red for an error, amber for a warning, blue for a note; click a finding to select its step. Lint runs on every change and again when a step is saved, when a toast repeats the first finding for that step. It checks for step types the server does not know, missing required settings, literal references to zones, devices, subsystems, macros, schedules or triggers that no longer exist, tokens that no argument, simulated context or earlier step provides (a note until you simulate a context, a warning once you have), a skip verb on the last step, the legacy If steps' implicit stop-if-false, a legacy If inside Parallel, empty containers and a Switch without cases, steps after a top-level Stop, and output names that are malformed, duplicated or shadow an argument. Findings never block a save — they are advice.

History. The History section lists the macro's revisions. One is recorded a moment after every change to the macro or its steps, whichever way the change arrived — this editor, the List tab, the AI assistant, an import — identical states collapse into one, and the newest fifty are kept. Save revision records the current state under a name of your choosing. The button on a revision compares it with the macro as it is now — steps added, removed, changed (and which fields) or moved since, and macro settings that differ — and the restore button rewrites the steps from it. A restore keeps the state it replaced as a revision of its own, so it can be undone the same way. The macro's name is never restored; its other settings are.

A Run Script or If Expression step whose code contains a [token] matching one of the run's context keys also gets a yellow warning dot, and the inspector's Warnings section names the field and the colliding keys — a reminder that code fields are never substituted, so the value should be read via context.* (or ctx.*) if it was intended. The collision carries field and key names only, never values, so it appears even in the redacted timeline a non-elevated session receives.

A step whose command dead-ends in dispatch gets the same yellow warning dot with the server's warning quoted in the inspector — an unknown zone, an AV verb with no device to receive it, a source value that doesn't resolve to an AV source, a stale AV-group member, and the like. These are cases where the command produced no error the step could surface (the step still shows ok) but nothing reached the hardware, which previously was only visible in the server journal. Warnings raised by scripts' gem.command(...) calls are collected too, and like token collisions they carry entity names and ids only, so they survive the redacted timeline.

The live feed and its catch-up snapshot carry each step's resolved data — the values a step actually executed with after context substitution, which on a security-panel step includes the command's code arguments. Subscribing to it therefore requires an elevated session. A role granted the function by name without elevation receives the run timeline only — which step, when, ok or error — with the resolved values redacted, and an Elevated macro's runs cannot be subscribed to at all without an elevated session.

List tab

The List tab is a plain grid of the steps: ID, Macro, Driver, Data, Last Run, Enabled, Sort Index, with Add, Edit, Delete and Save Sort. Sort the grid by a column and click Save Sort to renumber every step's sort_index to the order shown. You can also just type a new Sort Index on the step itself.

Step Configuration

Every step, whatever its type, has the same surrounding fields plus its own settings:

  • Action — The step type
  • Macro — Which macro the step belongs to (fixed once the step exists)
  • Label — A display name for the step. The diagram node and its tooltip show it instead of the step type, so a twelve-step scene reads as "Theater shades", "Projector on" rather than twelve boxes labelled command
  • Output — A local name. When set, the step's result is stored in the run under that name once the step completes, so later steps can read it — see Step outputs. Letters, digits and underscores, starting with a letter
  • Notes — Free text for whoever reads the macro next. Shown in the node's tooltip
  • Timeout — Milliseconds. If the step hasn't returned by then it fails with a timeout error. 0 means no per-step limit
  • On error — What happens when this step fails (returns an error, throws, or times out). See On error
  • Sort Index — Execution order within the macro
  • Enabled — Off skips the step without deleting it

Buttons: View JSON / Form View, Save, and Back to Steps.

GEM waits 100 ms between steps, so a long serial macro carries a small built-in pause per step.

On error

Each step chooses what a failure means:

  • Use the macro's Stop on Error setting (the default) — the macro-level switch decides: stop the run, or carry on
  • Stop the macro — stop the run at this step, whatever the macro-level switch says
  • Continue with the next step — carry on even if the macro is set to stop on error. The step still shows as failed in Live mode, marked handled
  • Retry the step — run it again up to Tries times, pausing Wait ms before each retry, multiplied by the × backoff factor each time (1000 ms × 2 gives 1 s, 2 s, 4 s). Every attempt re-resolves the step's [tokens] and honours the step timeout. When the last retry fails, After the last retry fails decides: the macro setting, continue, or stop
  • Run another macro — start a handler macro and, by default, continue. The handler receives the run's context plus error, failed_step_id, failed_step_label, failed_step_driver, failed_macro_id, failed_macro_name and attempts, so one "notify the integrator" macro can serve every step. Tick wait for it to hold the run until the handler finishes; choose stop the macro afterwards to still end the run

An aborted step — the run was stopped or timed out — is not a failure and is never retried. Container children carry the same setting; the child list offers the common choices and the JSON view the full form. The flow shows a step's policy as a badge, and the Live inspector shows how many attempts a step took.

JSON View

View JSON swaps the step's settings form for a raw JSON editor. Use it to copy step configuration between macros, to set options the form doesn't expose (such as a Run Macro step's wait-for-completion flag), and to inspect exactly what a step stores. Invalid JSON is refused on save with the parse error. The Action, Timeout, Sort Index and Enabled fields stay as form controls either way.

Step Reference

Command

Send a command to one or more zones, or to a device.

Pick a Subsystem to load its zones, select one or more Zones, then set the Device, Command and any command arguments. Selecting several zones is supported — the step issues the command to every selected zone.

Argument fields each have a Dynamic checkbox and a settings icon that opens the Dynamic Value Builder for constructing an expression visually.

Dynamic targeting — tick Dynamic next to the Zone selector (or Dynamic Device next to the Device selector) to leave the target unresolved. The step stores [zone_id] / [device_id] and resolves the real zone or device at run time from the triggering context or macro arguments. Select an example zone or device first to enable the checkbox; the example is only used to populate the command and argument options, not saved as the target. Dynamic Device replaces the device picker with a Command Set selector, since there is no concrete device to read commands from. Reopening a step that already uses dynamic targeting shows the checkbox already ticked.

Test sends the command as configured, without running the rest of the macro. Results collect in the Test Console docked below the step, newest run first, so you can change an argument and compare the next run against the last without dismissing anything. Each run shows:

  • A verdict and round-trip timeOK, Error, Partial (some zones of a multi-zone command failed), or No response. Plenty of drivers acknowledge a command with silence and report the outcome through an attribute change instead, so No response is not by itself a failure.
  • Sent — the resolved payload the step actually dispatched, expandable and copyable. If the step uses dynamic targeting, a warning names each token left in the payload: a test runs outside a macro, so [zone_id] and friends resolve to nothing and reach the device as literal text.
  • Reply — rendered as what it is. An object becomes a name/value table, a multi-zone command becomes one block per target with its own verdict, and a raw protocol string is shown as text with a hex toggle when it carries non-printable bytes.
  • Device pushes — attribute changes the device sends after the command are appended beneath the run that caused them, timestamped. For a silent driver this is the evidence the command took.

Clear empties the console; Hide collapses it while leaving the subscription in place, so pushes still accumulate.

Delay

Pause execution. Delay (Milliseconds)1000 is one second. Use it for equipment warm-up between steps.

Run Macro

Run another macro. Choose the macro; if it declares arguments they appear underneath, each with a checkbox — tick an argument to pass it and set its value.

By default the sub-macro is started and the parent moves straight on. To make the parent wait for the child to finish, add "wait_complete": true in the JSON view.

The edit icon next to the macro selector opens the referenced macro's steps in a modal so you can check or change it without leaving the current editor. Parent context and arguments flow into the sub-macro, and GEM refuses to run a macro that is already in the current call chain — an A → B → A loop is stopped and logged rather than run.

Set Attribute

Write an attribute on any entity. Choose the System Target (zone, device, subsystem, variable, system or UI), narrow by Subsystem where that applies, pick the Target Object, then set Attribute Name, Value and Value Type (string, int, float, bool, json, datetime). The value supports dynamic expressions such as [cool_setpoint]+5.

Set Variable

Set an automation variable.

  • Scope: global (default) — Persists as a variable attribute and survives the run, so later macros and triggers can read it.
  • Scope: local — Lives only in this run's context. Later steps in the same run (and any sub-macro it calls) can substitute it as [name], but nothing outside the run sees it.

Set the Value to [N][1], [-5] — to add N to the current value instead of replacing it, which is how you build counters.

UI Command

Broadcast an action to connected UIs — navigate, show a page, show a toast. Set Target UIs, the Command, and where the command needs them a Page Name and Parameter. At least one target UI is required; hold Ctrl/Cmd to select several.

Test sends the command to the selected UIs immediately, without running the rest of the macro, and reports the result in a Test Console below the form — the same console the Command step uses.

Two things are worth knowing before you press it:

  • It affects live panels. A test is the real broadcast, so a "Reload" or "Go to Page" test visibly moves every panel on the targeted UIs.
  • Nothing happens in the admin window. The admin deliberately ignores broadcasts so that testing "Go to Page" cannot navigate the editor away. There is no local effect to watch for, which is why the console reports how many clients were connected on each targeted UI at the moment of dispatch. 0 connected clients is the answer to "I pressed Test and nothing happened" — the UI exists but no panel is currently on it.

Email / SMS

Email takes To Address (comma-separate for multiple), Subject and Body. SMS takes To Number in E.164 format (+15551234567) and Message. Both respect each recipient's notification profile unless Override Notification Profile is on, and both use the mail/SMS provider configured for the site. If that provider isn't configured, the editor shows a warning at the top of the form — the step will save, but nothing will send until mail/SMS is set up in gem.json. Each has a Test Email / Test SMS button that sends the message as configured, with any [token] placeholders resolved from the Context Simulator.

Web Push

Send a push notification to subscribed UIs and users.

Target UIs, Target Users, Title, Body, Click URL, Image URL, Tag, Timeout (ms), Require Interaction, Override Profile.

At least one UI or one user must be selected — the step refuses to run with both lists empty, so a misconfigured step can't fan out to every connected client.

Call Notification

Ring mobile devices with the native incoming-call UI (CallKit on iOS, a full-screen notification on Android).

Target UIs, Target Users, Caller Name, Answer URL, Override Profile. Same "at least one UI or user" guard as Web Push. Only native mobile app clients receive it — web browsers are skipped. If no mobile app client has ever registered for push notifications, the editor shows a warning at the top of the form — the step has no one to ring until someone signs in from the mobile app. Typical use: a gate intercom press rings the house with an answer URL pointing at the gate camera.

Toggle Account

Enable or disable a user account. Set Account to the username and Enabled to the state you want. Useful for time-boxed access — a schedule enables a contractor's credentials at 07:00 and disables them at 17:00.

Set Notification Profile

Switch a user to a different notification profile — on-call rotation, after-hours quiet mode, vacation mute. Takes the user and the profile.

Set Site Mode

Set or clear the active mode for a Site or Site Space. Choose the Mode, and optionally a Space — leave the space empty to affect the whole site. Tick Clear Mode to drop the current mode instead of setting one. A mode that is being held rejects the change.

Wait For Attribute

Pause the macro until something is true, then continue the moment it is. The wait is event-driven: it reacts to attribute changes as the server records them rather than polling, with a slow fallback re-check in case an event is ever missed.

Pick what to wait for:

  • Attribute matches — one or more conditions built with the same builder as If Attribute, waiting for ALL of them or ANY one
  • Attribute changes — pick a target and an attribute; the wait ends on its first change after the step starts, whatever the new value. The way to wait for "the next press", "the next reading"
  • Expression is true — a JEXL expression, the same grammar as If Expression, re-evaluated on every attribute change

Under Timing: Timeout (ms) is how long to wait before giving up (default 30000; 0 checks once). On timeout is Stop the macro (the default), Continue, Skip the next step, or Fail the step, which makes the step error so its On error policy applies — a retry, a handler macro, or a stop. Re-check every is the fallback interval (default 5000 ms, minimum 100).

Typical use: after issuing a level command, wait for the zone's level attribute to reach the target before continuing; or wait up to 45 s for a garage to report closed and fail the step if it doesn't.

A timeout stops the macro by default

On timeout starts at Stop the macro. If you want the macro to carry on — to fall back to a second command, say — set it to Continue, Skip the next step, or Fail the step with an On error policy of your choosing; don't assume execution continues past a missed wait.

Camera Snapshot

Capture a still from a camera. Set the Device, and for an NVR or a multi-camera device the specific Camera channel, with optional Resolution and Compression overrides. Works with any driver that implements a snapshot command (ONVIF, Vapix and similar).

Under Output, choose where the image goes. Global variable stores the data: URL as an automation variable, which persists and is shared by every macro, readable as [$name]. Local to this run keeps it in the run's own context, readable by the following steps as [name] — a gate that is pressed twice in quick succession then produces two runs that each carry their own image instead of overwriting one shared variable. A local name is letters, digits and underscores, starting with a letter; the step refuses to run with anything else. Existing steps keep the global behaviour.

If Attribute

Evaluate one or more attribute conditions and branch on the result.

Under Condition, build the test from system target + object + attribute + operator + value — the same condition builder the Triggers page uses, including the option to compare against the attribute's previous value when you want to detect a transition rather than a state. Add further conditions and combine them with and or or. Test Condition evaluates it against current live state.

Below that are two collapsible panels, When TRUE and When FALSE, each collapsing to a one-line summary of what it does. In each you can set:

  • Flow control — Continue, Stop macro, or Skip next step
  • Branch to macro — Start another macro (fire and forget; the step won't re-enter its own macro)
  • Run command — A full command form, same as a Command step. It is also gated the same way at run time: a target the caller supplied through a [token] is re-checked against them, while a target the author wrote literally runs as authored (see the note under Who can author which steps)
  • Set attribute — A full attribute form, same as a Set Attribute step

With neither branch given any flow control, the step defaults to stopping the macro when the condition is false.

This step does not contain child steps — it acts on the steps around it. To run a whole block conditionally, use an If / Else step, which holds the block inline.

If Expression

Branch on a free-form expression evaluated against live system state — the gap between If Attribute (one attribute against a literal) and Run Script (full JavaScript). Use it for cross-entity, arithmetic or derived conditions, e.g. zone(5).temperature > zone(5).setpoint + 2.

Expressions use JEXL, a sandboxed grammar with no loops, require or eval.

  • Accessors: zone(id), device(id), subsystem(id), attr(target, id, name), and vr(name) for an automation variable value. vr() takes the produced variable name — exactly what [$name] uses, prefix included: vr('sunset'), vr('lake_elevation'), vr('travis_gage_height'). Copy it from Current Values on the Variables page.
  • Context: ctx.* (the macro's runtime context), now (epoch ms), and time.hour / time.minute / time.minutes (since midnight) / time.dow
  • Transforms (piped): |lower, |upper, |number, |int, |round, |abs, |default(x)

Secure attributes (passwords, API keys) are never exposed to an expression.

Type the expression in the monospace box; grammar is checked on blur, and a red ✕ flags a syntax error before you save. Test evaluates it against current live state and shows the result, its type, and whether it is TRUE or FALSE — the Context Simulator above supplies ctx.* for the test.

Branching is identical to If Attribute, set per outcome. An empty or invalid expression is treated as false and logged; it never crashes the macro. The expression is never rewritten by [token] context substitution — brackets are JEXL syntax (list[0], filters) — so read macro arguments via ctx.*. The legacy [$sunset]-20 bracket syntax is separate and unchanged — its equivalent here is vr('sunset') - 20.

If Time

Branch on whether the current time falls inside a window.

Give the window as literal Start Time and End Time values, or use Or Variable to reference a variable such as sunrise or sunset, each with an Offset (min). An optional day filter limits the step to particular days of the week. Choose whether to stop when outside the window (the default) or when inside it; branching to another macro, running a command or setting an attribute works the same way as If Attribute.

For Each

Iterate over a collection and run a sub-macro once per item.

  • Iterate Overzones, devices, subsystems, or a literal list
  • Subsystem (optional) — Filter zones or devices to one subsystem
  • Items — For a literal list: the values, or a comma-separated string
  • Sub-macro to run per item — Required
  • Item Variable Name / Index Variable Name — Default item and index
  • Max Iterations — Default 1000, hard-capped at 10000. Over the cap the extra items are skipped and the truncation is logged
  • On Error — Stop iterating on the first failing sub-macro, or keep going and count the errors

Iterations run one after another, not concurrently. Inside the sub-macro the current item is available as [item], and for object items also as [item_id], [item_name] and [item_label] (using whatever you named the item variable), plus [index]. Example: for each zone in the lighting subsystem, run a random_dim macro.

Parallel

A container that runs its child steps at the same time instead of one after another. Use it to fan out independent actions in a large scene so the macro finishes in the time of the slowest single action rather than the sum of them all — a 40-command scene that took about 14 seconds serially collapses to the latency of one command.

Add child steps to the list, each with a label, its type picker, an optional Output name, an enabled switch and the normal form for that type. Reorder or remove children inline.

  • Max Concurrency0 runs everything at once. Set a cap such as 4 when the targets share one constrained gateway (a single serial port, one lighting processor) that a burst would overwhelm
  • On Error — By default every child runs and errors are collected. Switch it on to abort the remaining children and cancel in-flight ones at the first failure

Parallel composes with the sequential steps around it — do A, then a parallel block of B/C/D, then E — and does not change the ordering of the rest of the macro. Children are run by the engine exactly like top-level steps: tokens are substituted, per-child timeouts apply, an Output lands in the run, and each child is journaled and shown with its own status in Live mode. The macro's Stop on Error applies to a failing child as it does to any step. The legacy If steps (If Attribute, If Expression, If Time) cannot branch the parent macro from inside a group and are refused; an If / Else or Repeat is self-contained and may be a child, and containers nest up to three levels deep in the editor.

If / Else

A container that runs one of two inline bodies. Pick the condition kind:

  • Attribute — the same condition builder as If Attribute. Add further conditions and combine them with AND or OR
  • Expression — a JEXL expression, the same grammar and Test button as If Expression
  • Time window — the same window as If Time: a start and end (literal or a variable such as sunset, each with an offset) and a day filter

Then fill the THEN body and the ELSE body. Either may be empty. Each child has a label, type, optional Output, enabled switch and its own form; children may themselves be If / Else, Repeat or Parallel steps.

Children run exactly like top-level steps. A child that stops the macro (an If Attribute set to Stop macro, say) stops it. "Skip next step" inside a body skips the next child of that body and never leaks past the container. A child's Output is available to the children after it and to every step after the container — the Context panel lists it with its branch, since a value written inside THEN only exists when THEN ran.

The three older If steps are unchanged and still work; they remain the right tool when all you need is to stop the run or skip a single step.

Repeat

A container that runs its body repeatedly. Pick how it ends:

  • A number of times — a whole number, or a [token] resolved when the macro runs
  • While — a JEXL expression checked before every pass; the body runs as long as it is true
  • Until — the body runs once, then the expression is checked after each pass; the loop stops once it is true

Pause between passes waits the given milliseconds between passes (never after the last), and aborts with the run. Max passes caps every mode, default 100, hard cap 10 000. The pass number, starting at 0, is available inside the body as a run-local named by Index name, [index] by default, or ctx._locals.index in an expression.

A child that stops the macro ends the loop. Errors in the body are counted, make the loop report success: false, and do not end it — set the macro's Stop on Error if they should. An empty or invalid while expression is false, so the body never runs; an empty or invalid until expression never becomes true, so the loop runs to its cap.

For walking a collection of zones or devices, For Each remains the step to use.

Start Timer

Start a named timer that runs a macro when it expires. Starting a timer whose name is already running restarts it from now, so repeated events push the expiry out instead of queuing — the building block for "off five minutes after the last motion", "close the garage if it is still open in twenty minutes", and debouncing a chatty sensor.

  • Timer name — Timers are global: the same name used from any macro is the same timer. A token such as off_[zone_id] keeps one timer per target
  • ExpiresAfter a delay in milliseconds (with presets; a token such as [off_delay] resolves at run time), At a time (HH:MM, the next occurrence), or At a variable's time such as sunset, with a minute offset either way
  • Run macro and Arguments — What runs when the timer expires and what it receives. Tokens in the arguments resolve when the timer is started. The expiring macro also receives timer_name, timer_started_at and timer_expired_at
  • Restart the timer if it is already running — On by default. Off leaves a running timer's expiry alone and makes this step a no-op while it runs
  • Survive a server restart — The timer is written to the system attribute macro_timers and re-armed on boot. One that expired while the server was down still fires, so an auto-off that was due still happens — about 15 seconds after boot, once device connections are under way, so the macro does not run against drivers that have not connected yet. It stays listed (and can be cancelled) during that window

The step's result is {started, restarted, name, expires_at, remaining_ms}, or {started: false, already_running: true} when restart is off and the timer was running. Pending timers are listed at the bottom of the step editor's side panel, with a cancel button, and are also readable from a Run Script as gem.macroTimers.list().

Cancel Timer

Stop a named timer so its macro never runs. Cancelling a timer that is not running is fine; the step reports cancelled: false and the macro continues.

Switch

A container that compares one subject — an expression such as ctx.mode or variable('house_mode'), or an attribute such as the house_mode variable or a zone's state — with each case's values in order and runs the body of the first case that matches; the Otherwise body runs when none does. Values compare loosely (a case value 5 matches "5", true matches "true") and a case may list several, comma-separated, so away, vacation is one case. The flow draws a Switch as a fork with a lane per case, labelled with the case's label or its values, beside OTHERWISE. Its result is {subject, matched, branch, ran, ok, errors, stopped}; in Live mode the children of the case that ran carry status under the switch.

Stop

End the run here, cleanly. The run is recorded as a success with the step's reason, the steps after it do not run, and an optional value is handed back to whatever started the macro — a Run Macro step in another macro sees it as the result's value. Put a Stop inside an If / Else branch or a Switch case to end early on a condition, which is what the legacy If steps' stop-if-false did implicitly. Treat as an error records the run as failed with the reason instead, so the macro's error handling applies and the last-failure view keeps it.

Cooldown

Let the run continue at most once per window: the first pass opens a window of the given minutes and every pass within it is a hit. On a hit the step stops the run (default), skips the next step, or continues with hit: true in its result. The window lives in the macro's persistent state, so it survives a restart. One window per step by default; a key with a token — alert_[zone_id] — keeps a separate window per target ("one leak alert per zone per hour"), and the same key in two steps or two macros shares one window. The result is {hit, remaining_ms, key}.

Random

Three kinds: wait a random time between two millisecond bounds (the wait taken is the result's waited_ms, and it aborts cleanly with the run), pick one from a list of choices, or a random number from a range, inclusive. The pick and the number are stored as a run-local under the name given (default random), so later steps read [random] or [room]. A vacation "lived-in" macro is a Repeat around a Random wait and a Random pick of a room.

Compute

Evaluate an expression — the same language as If Expression, with ctx.* for the run context, zone(id), device(id), variable(name), arithmetic, comparisons and string functions — and store the result under a name, as a run-local for the rest of this run (default) or as a global variable that survives it. An optional type casts the result (string, integer, number, boolean, json). Later steps read it as [name]; the Context panel lists it as produced by the step.

Ramp

Move a zone's level (or a device's, or volume for AV) from where it is to a target over a duration, in even steps: a level command every step milliseconds (1000 by default, 100 at the fastest) along a straight line, the last one landing exactly on the target. From defaults to the zone's current value of the attribute the command sets; give it to start elsewhere. Target, from and duration take tokens. Sunrise wake-ups, slow dims and volume fades. The ramp aborts cleanly with the run (result aborted: true with how many commands were sent); a command that fails ends it with an error.

Snapshot State / Restore State

Snapshot State captures the current state, level, volume and mute (or any attributes you name) of a set of zones — chosen one by one, or every zone of a subsystem — into a run-local under a name (default snapshot), or into a global variable so another macro or a timer can restore it later. Restore State puts them back with a conservative mapping: state becomes on / off, level a level command, volume a volume command, mute mute on / off; anything else captured is kept for reading but not driven, and the result names it under skipped. Zones are restored in parallel. Only restricts a restore to some attributes. The doorbell flash: snapshot the hall lights, blink them, restore.

Enable / Disable Automation

Turn a trigger, a schedule or another macro on or off. The row's enabled flag is written and the running system reloads it at once, so a disabled trigger stops firing immediately and stays disabled until something enables it again — pair the Disable in the macro that starts a mode with an Enable in the one that ends it. The target can be a token. A macro cannot disable itself while it runs.

Note / Log

A Note is a comment that lives in the flow: its first line shows on the node, the whole text in the step summary, and it never runs anything. A Log writes a line to the server log at the chosen level (log, warn, error) and records it as the step's result, with tokens resolved — the quickest way to see what a value is while a macro is being built.

Ask

Pause the run and put a question in front of people; continue with the first answer. The question appears as a modal with its options as buttons on every targeted UI (or every UI when none is chosen) and, when push is on, as a push notification whose first three options are buttons on the web app — tapping one answers without opening the app; the native apps open the question. A user is asked on whatever UI they are using and by push; a UI is asked on that panel. Who may see and answer a question follows the user's role, not the panel a session happens to be on: a question sent to a UI is answerable by anyone whose role lets them open that UI, a question sent to a user only by that user, and elevated users can answer anything. The first answer from anyone wins and the question disappears everywhere. Admin pages never show prompts.

The macro sees the chosen option as its id — a lowercase version of the label, Openopen; write id|Label in the options to choose the id — stored as the run-local named in Store the answer as (default answer), so the next step is usually an If / Else on ctx.answer == 'open'. Also accept a typed answer adds a text box whose text becomes the answer. Wait up to bounds the wait (default 5 minutes); when nobody answers the step continues with the default answer, stops the run, treats it as an error, or skips the next step. The step's result is {prompt_id, answer, label, answered_by, timed_out, elapsed_ms}. Questions still waiting are listed at the bottom of the step editor's side panel with a cancel button, and a run that is stopped abandons its question. Questions live in memory: a restart ends them along with the run.

Announce

Say a sentence out loud. UIs speak it with the tablet's or phone's own voice (the browser's speech synthesis, no server audio; on iOS the page needs one tap before it may speak). Speakers are audio zones whose device can play a clip from a URL: the controller turns the text into an audio file and the device plays it. Sonos zones use the new play_notification command, which snapshots what is playing, plays the clip at the speaker volume given (or the current one), and returns to the previous source and volume when the clip ends — HEOS and any other device with a play_url command play it without a restore. Only interrupt a zone that is playing leaves silent Sonos zones alone.

Text-to-speech for speakers uses one of two engines, chosen by voice engine or, on auto, the first available: the OpenAI audio API through any OpenAI-compatible AI provider that has an api key (model gpt-4o-mini-tts, voices such as alloy and nova), else espeak-ng installed on the controller (apt install espeak-ng; voices such as en-us, en-gb), which needs no network and sounds like it. Preview in the step renders the clip and plays it in the browser. Clips are cached under cache/tts and served publicly at /tts/<hash> because speakers fetch them with no session — so keep secrets out of announcements — from the controller's detected LAN address; a controller that speakers cannot reach at that address (or that only answers over https with a self-signed certificate, which speakers refuse) needs the system attribute tts_base_url set to the address they can fetch from. The other system attributes — tts_provider (auto, openai, espeak), tts_voice, tts_openai_model, tts_openai_provider — set the defaults for every Announce step. The step's result is {text, uis, provider, url, zones: [{zone_id, ok, command, error}]}, and a dry run skips the whole step.

Run Script

Execute JavaScript on the server. The editor is preloaded with a skeleton showing the available globals: gem, context, console, setTimeout, setInterval, Promise, Buffer, JSON, Math, Date. The source is never rewritten by [token] substitution — brackets are JavaScript syntax, so gem.zones[id] stays exactly as written even when the context carries an id; read arguments and trigger data from the context global instead (context.args.level). When a script (or an If Expression) does contain a bracket token that matches one of the run's context keys, the server logs a one-time warning naming the field and keys — a breadcrumb for scripts written against older builds, where those tokens were substituted. The same collision is also reported on every run, not just the first: the run feed carries it per step (names only, no values), and the diagrammer's Live mode marks the step with a yellow warning dot and a Token Collision tooltip row (see Diagram tab). Scripts may be synchronous or asynchronous — top-level await is supported, and the macro waits for the script to finish (including all awaited work) before moving to the next step. Use return to produce a value. Synchronous execution is cut off at 30 seconds; async work (awaited commands, web requests) runs until it settles, so set the step Timeout — or a macro timeout — on any script that awaits devices or the network.

When a macro run was started by a non-elevated caller, the script's gem.command(...) calls carry that caller's authority — commands aimed at an elevated device, zone or macro are refused, the same as everywhere else the caller could reach. Triggers, schedules and internal runs dispatch with full authority as before. See the run-time re-check note.

When the AI Assistant is enabled, an AI button in the script editor opens a sidebar chat that knows the run-script sandbox and can look up real zone and device IDs; it proposes a complete script you paste into the editor.

Web Request

Make an HTTP(S) request to an external service or to a device on the LAN.

URL, Method (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS), Headers, body as JSON / form / query string / raw, basic or digest auth, and a Timeout. Optionally save the response body — or a nested value via a dot-notation key like data.items[0].name — for a later step to use. Store the response as picks where: a Global variable (persists, shared by every macro, read as [$name]) or Local to this run (read by the following steps as [name], or [name.path] when the value is an object; nothing outside the run sees it, and two runs cannot overwrite each other's response). A local name is letters, digits and underscores, starting with a letter. Existing steps keep the global behaviour.

The trigger-level Web Request action is a separate surface and is unchanged.

tip

A step type not in the palette — from a custom or newer server-side step — still gets a generic settings form built from that step's own schema, so it is configurable in the admin without any UI work.

Who can author which steps

Authoring steps requires an elevated session. A step's settings are free-form JSON on a row that later executes, so the step table sits outside the generic writes a non-elevated role is allowed. Panel-side scene building is unaffected — the lights and shades scene editors don't write steps through that path; they call purpose-built functions that build each step server-side with a fixed target and level.

Behind that table gate sits a second one, in force whenever a non-elevated session reaches the step handlers at all: the step's type must be Command, Delay or Set Variable, and everything else is refused. It is written as an allowlist rather than a roster of banned types, so a step type nobody has reviewed — including one a future release adds — is refused until it is deliberately allowed, and a step naming no type at all is refused rather than waved through.

A Command step is judged further, by its target: an ordinary scene step aimed at lights or shades is fine, but one aimed at an elevated device (a lock, gate or alarm panel) is refused — when re-pointing an existing step as well as when authoring a new one. The target must also sit inside the author's own UI scope: when every UI the author's roles allow carries a zone scope, a step aimed at a zone on none of them is refused — you cannot persist a command you could not run. Sites whose UIs express no scope are unaffected. A non-elevated author also cannot give a Command step a dynamic target such as [zone_id]: it resolves to nothing while the step is being saved, so there is nothing to check, and at run time it becomes whatever the caller passes in. Elevated authors may target dynamically as usual. Deleting any step requires an elevated role.

The Camera Snapshot step's optional command name is limited to the snapshot verbs drivers actually use (snapshot, get_snapshot, get_image, snapshot_url and near spellings). It talks to the camera driver directly, so an arbitrary name there would be a way to send any command to any device.

Authoring is gated, not execution — macros that already contain these steps keep running normally for everyone. If the step editor returns not authorized, the account's role needs Elevated. See Roles.

Commands are re-checked when they run — on the parts the caller influenced

When a macro is started from a client — a UI, the admin, the REST API, a voice request, or a remote key — the caller's authority travels with the run, and every command the run dispatches is judged on what the caller actually influenced. That covers Command steps and the Run command action on either branch of an If Attribute step, which are judged by the identical rule:

  • A step whose target and command are exactly what its author stored runs as authored, even against an elevated device. Authoring steps requires an elevated session, so a literal step is a capability the author deliberately granted — the warning banner in the step editor makes that grant visible.
  • A step written with a placeholder such as [zone_id] takes its target from the caller's own arguments, so it is re-checked after substitution: pointed at a lock by a non-elevated caller, it is refused and returns not authorized, even though the macro itself was runnable. A substituted command name on a literal device is judged the same way — a fixed elevated device with a free verb is not a limited grant.
  • A substituted target is also checked against the caller's UI scope, not only against the Elevated flag. A templated macro is otherwise a way to reach any non-elevated zone in the building by passing someone else's zone_id to a button you are allowed to press. Sites whose UIs express no scope are unaffected, and the check only applies to the part of the target the caller supplied.
  • A step's literal address argument keeps its exemption from the address confinement only when the step's target is literal too. A step that pairs a stored address with a [zone_id] target — {zone_id: '[zone_id]', command: 'pulse', args: {address: '1:4'}} — has an admin-chosen address landing on a caller-chosen device, so the address is judged normally.
  • A Run Script step's gem.command(...) calls are judged entirely as the caller's when the run was started by a non-elevated caller: elevated targets are refused and the address confinement applies, exactly as if the caller had sent each command directly. A script computes its targets at run time, so nothing in it counts as a literal grant — to deliberately hand non-admin users an action on an elevated device, author it as a literal Command step instead.

Triggers, schedules and driver-initiated runs carry no caller, so they run exactly as before.

Using arguments and context in steps

Any text field in a step can contain [token] placeholders that are filled in at run time — with two exceptions: a Run Script step's source and an If Expression step's expression are never rewritten, because brackets are syntax there (gem.zones[id], [a, b]). Scripts read the same values from the context global (context.args.level) and expressions from ctx.*. Every other field resolves in this order:

  1. Dynamic values first. A field whose value starts with [ is offered to the dynamic-value evaluator (see below). If it resolves — a variable, a cross-entity attribute, arithmetic — that result is used and nothing else runs on that field.
  2. Then local variables for the run — set by a Set Variable step in local scope, bound by a For Each step for the current item, stored by an earlier step's Output, or written by a local-scope Web Request or Camera Snapshot. These shadow context keys of the same name. A local (or a caller-supplied context key) holding an object can be addressed with a path: [item.name], [reply.status].
  3. Then the run context — the arguments the caller passed, plus whatever the trigger, schedule, AV source change or monitor tag supplied.

So a macro that declares an argument level uses it in a command step as [level], and a For Each over zones drives its sub-macro with [item_id]. Substitution recurses into nested settings, so tokens inside a Parallel step's child steps resolve too — and the two code-field exceptions hold there as well: a Run Script child inside a Parallel group keeps its source verbatim. A field ending up as an _id whose token fully resolved is converted to a number, so [zone_id] works as a real zone reference and not the text "12".

Argument names beginning with an underscore are reserved for the run's own bookkeeping (loop locals, nesting depth, who started the run) and are ignored when a caller passes them. Name your arguments without a leading underscore.

Dynamic values

Fields also accept dynamic expressions that read live state and apply arithmetic:

[level] the targeted entity's own level attribute
[$sunset]+30 30 minutes after the sunset variable
[zone.5.level]*2 double zone 5's level
[device.12.temperature]-5:60:80 device 12's temperature minus 5, clamped to 60-80

[$name] reads an automation variable. [zone.<id>.<attribute>] and [device.<id>.<attribute>] read any other entity. A bare [name] reads that attribute from the entity the step is already targeting — which is why it only makes sense on steps that name a target, such as Set Attribute. If a reference can't be resolved the field is left exactly as written rather than being blanked, so a typo shows up as a literal [typo] in the step's resolved data.

Secure attributes never resolve. A token naming a password, API key or other secure value is left as written — [device.12.password] stays literal text. Drivers hold the decrypted credential to connect with; substitution does not, or any step that sends a string somewhere (an email, an SMS, a web request) would read secrets back out. See Attributes — Dynamic Attribute Values.

The suffix grammar is an optional operator (+, -, *, /) with a number, and an optional :min:max clamp. On a date value such as [$sunset], +N / -N shifts by N minutes. Use the Dynamic Value Builder (the settings icon next to a dynamic parameter) to construct these visually. Full syntax reference: Attributes — Dynamic Attribute Values.

Step outputs

Every step can name an Output — a local that receives the step's result as soon as the step completes. The value is exactly what the run journal shows for that step: a Command step stores the driver's reply, Run Script stores whatever the script returns, Wait For Attribute stores {matched, value}, Web Request stores {data, status}, and a step that failed stores {error}. Nothing is stored when the field is blank.

Later steps read it three ways:

  • [name] in any text field, when the result is a plain value. When the result is an object, address into it with a path: [reply.status], [reply.data.items.0.name]. A path that doesn't exist, or one that lands on an object rather than a value, is left as written
  • ctx._locals.name in an If Expression
  • context._locals.name in a Run Script

Outputs are run-scoped, like a local Set Variable: they exist for the rest of this run and its sub-macros, and disappear when it ends. Names are letters, digits and underscores, starting with a letter; the editor refuses anything else, and so does the engine.

The Context available at this step panel above the step form lists every output of an earlier step, and typing [ in a text field offers them as completions — see Context panel.

Persistent state across runs — context._state

Each macro carries a small JSON object that survives between runs: the values a run leaves in context._state are loaded back as the next run's starting values. Use it to aggregate across consecutive runs — counters, debounce timestamps, "what was the level before I changed it" comparisons:

// Run Script step: count motion events, and act on every fifth one
context._state.count = (context._state.count || 0) + 1;
if (context._state.count % 5 === 0) {
await gem.command({zone_id: 12, command: 'on'});
}

An If Expression step reads the same object as ctx._state.count. The rules:

  • Macro-scoped. Every macro has its own state; a sub-macro run through Run Macro or For Each sees its own _state, never the parent's.
  • Saved only when a run changes it. A macro that never touches context._state stores nothing. State persists on every outcome — a run that errors after updating state keeps the update.
  • Plain JSON values only, and about 16 KB at most. An oversized state is kept in memory for the run but not saved, with a warning in the log.
  • Secret-named keys never persist. A key that looks like a credential (password, *_key, pin, ...) is nulled before saving — state is for aggregation values, not secrets.
  • Stored as the macro's state attribute, so it is visible (and resettable) in the macro's attribute editor.
  • Visible after a test run. When a live run finishes with non-empty state, the flow toolbar grows a state button — click it to see exactly what the next run will start with. The keys also appear as a Persistent state column group in the data rail.

Because _state starts with an underscore it lives in the reserved bookkeeping namespace — callers cannot pass it as an argument, and [token] substitution never rewrites it.

Context panel

Above the step form, Context available at this step lists every key the step being edited can read, grouped by where it comes from:

  • Arguments — the names declared in Edit macro details
  • Caller context — whatever the Context Simulator below is currently simulating: a trigger's entity attributes, a schedule's arguments, an AV source change, a monitor tag
  • From earlier steps — run-scoped values written before this step: an earlier step's Output, a local Set Variable, a local-scope Web Request or Camera Snapshot. Each chip says which step writes it
  • Variables written by earlier steps — automation variables an earlier step sets, offered as [$name]
  • Persistent state — the keys currently in the macro's context._state, for expressions and scripts

Each chip shows the token, its source and the simulated value when there is one, and its tooltip gives the expression and script forms. Click a chip to insert its token at the cursor of the field you were last typing in; with no field focused, the token is copied to the clipboard instead. Typing [ in any text field of the step form opens the same list as an autocomplete — keep typing to filter, Enter or Tab to accept, Escape to dismiss. Code fields (a Run Script's source, an If Expression's expression) are left alone, since brackets are syntax there.

Beneath the chips, This step reads lists every [token] and [$variable] found in the step's saved settings (and every ctx. / context. read in its code), green when something above provides it and red when nothing does. A red token is not always wrong — it may be an attribute of the step's own target, a variable another macro maintains, or a key a caller passes that you haven't simulated — but it is worth a look before the macro is wired to a trigger. Disabled steps never run, so they never count as providing anything.

Context Simulator

One run has one context: what the caller passes in — the macro's arguments, a trigger's event, a schedule's args, an AV source change, a monitor tag rollup — is what every step reads, and steps add to it as they go. The Context section at the top of the side panel holds the simulated version of that incoming context for the whole macro, and everything that reasons about the macro reads it: the Context panel, the data rail's START chips, the linter, the step previews, and the Test and Dry run modals, which pass it to the run.

Pick a context type and the automation that starts this macro — the simulator detects the triggers, schedules, AV zones and sources, and monitor tags that reference it — and it builds a realistic context from that record: the trigger's entity values, the schedule's args, the zone and source of an AV change, the tag's rollup. Clear empties it.

Below that, Written by steps as the run goes lists every key an earlier step writes — outputs, local Set Variables, Compute, Ask answers, local Web Requests and Camera Snapshots — with a box to give it a value. These values are for previews only, never passed to a Test run (the steps write them for real), and they let a later step's preview resolve [reply.status] before the request has ever been made. A number, true, false or JSON typed here keeps its type; anything else is text.

Previews. With a step open, Preview with context above the form shows the step's settings as they would run — every [token] substituted from the simulated context and the given step values, unknown tokens left as written. Selecting a step without opening it shows the same under Resolved with the simulated context in its summary. The simulated context and the given values are remembered per macro in the browser, so they behave as a fixture you set once.

Test runs with context

The diagram's Test button runs the whole macro end to end.

When the macro declares arguments, or a simulated context has been built, Test first opens a Test Macro dialog containing the Context Simulator and a form for the declared arguments. The values you set are passed to the run as the real context, resolving [token] placeholders exactly as a live trigger or schedule would.

  • Run Test — Execute with the assembled context. The view switches to Live so you can watch each step
  • Run Without Context — Fire the macro bare

A macro with no arguments and no simulated context runs immediately when you click Test.

Dry runs

Dry run in the Diagram toolbar rehearses the macro. It runs with the same arguments and simulated context a Test run would and switches to Live so you can watch it, but every step whose purpose is a side effect is journaled as skipped with its resolved settings instead of running: commands, attribute writes, UI commands, email, SMS, push and call notifications, account and notification-profile changes, site mode, camera snapshots, web requests, scripts, timers, delays and waits, For Each, Ramp, Restore State, Enable / Disable Automation, Random waits, and global-scope Set Variable, Compute and Snapshot State. Conditions, If / Else, Switch, Repeat and Parallel run for real, as do run-local writes, so the path through the macro is the real one and the inspector shows each skipped step with the values it would have used. Nothing persists: no last-run record, no last-run stamp, no persistent-state write, no expectation check. The dry run itself is kept among the Kept runs, flagged, so it can be inspected later. Scripts and the AI assistant start one by passing dry_run: true alongside macro_id.

Running Macros

Manual execution

  • From the Macros page — the Run Macro toolbox icon, or Test in the step editor
  • From a UI — a macro button or macro selector on a UI page
  • From the REST APIPOST /api/control/macro with {"macro_id": 42}, or {"macro_id": 42, "args": {"level": 80}} to pass arguments. The calling account needs the macro permission, and an elevated role if the macro is flagged Elevated

Automated execution

  • Schedules — time-based (see Macro Schedules)
  • Triggers — attribute-change based (see Triggers)
  • Access control — physical access events
  • Other macros — Run Macro and For Each steps
  • TimersStart Timer steps; the macro receives the timer's arguments plus timer_name, timer_started_at and timer_expired_at
  • AV zones — on/off macros
  • Monitor tags — down and recovery macros that fire on rollup state transitions (see Monitoring — Tags Tab). The macro receives monitor_tag_id, previous_state, new_state, trigger_monitor_id / name / ip, rollup_reason, and on recovery outage_duration_ms in context

When a macro is already running

When already running, in the macro's properties, decides what a new invocation does while a run is in flight:

  • Queue (the default) — the new run waits behind the in-flight one and runs after it, in arrival order. Every invocation runs, each with the context it was called with, and none are dropped. That is what lets one shared macro fan out across many targets: an AV all-off that runs a zone-off macro once per zone, or a For Each driving a sub-macro, produces one queued run per target and every target is acted on. A queue more than about 100 deep is logged as a warning, since a pileup that size usually means a trigger feedback loop rather than a fan-out.
  • Restart — the in-flight run is aborted, anything already queued is discarded, and the new run starts. The aborted run is journaled as aborted (restarted), the discarded ones as superseded. This is the policy a delayed-off macro wants: a Delay step followed by the off commands, restarted by every fresh trigger, turns the lights off a fixed time after the last event. (Timers are the other way to build that, and the better one when the on and off sides are separate macros.)
  • Drop — the new invocation is ignored while a run is in progress or queued. The caller gets dropped: true. Use it for a scene that must not stack when a button is pressed twice.
  • Parallel — the new run starts at once, alongside the in-flight one, each on its own copy of the steps. Avoid it for macros whose steps use the legacy stop / skip-next verbs, which act on the macro rather than on one run.

A run started from the REST API, a UI or the AI assistant reports which of these happened in its result.

What can end a run early:

Run History

The Run History panel is in the macro properties editor — reach it with the Edit Definition toolbox icon on the grid, or Edit macro details from the step editor.

Each row shows started-at, status (success / failed / aborted / skipped / running), duration, an optional reason, and the cause that started the chain (the upstream trigger, schedule or user). Click a row to open the Cause Chain modal, which walks the chain in both directions:

  • What caused this — the upstream trigger, schedule or user that started the run, with click-through to re-anchor on that record
  • What this caused — any downstream macros, triggers or schedule fires the run kicked off

Because the original cause is preserved across nested calls, a macro three levels deep still attributes back to the trigger that started everything.

Retention is governed by the Automation History settings under Data Retention.

Kept runs

Separately from the automation history, the last twenty runs of every macro are kept in full — the context the run started with, every journaled step with its resolved settings, result and timing, the status and reason, whether it was a dry run, and what caused it. They feed the Diagram tab's Earlier runs picker in Live mode, so a run from earlier today can be inspected step by step exactly as the last one can. The oldest is dropped as each new run is recorded. The system attribute macro_run_retention, read at startup, changes the count; 0 turns the history off.

Wider execution monitoring

For cross-macro analysis, build a Unified Activity report under Reports, or use Logging. Filter by macro name to review execution times, results and errors.

Worked Examples

Movie Time

1 Command Theater shades -> close
2 Command Theater lights -> level 5
3 Parallel Projector on / Receiver on / Apple TV on
4 Delay 5000 ms (projector warm-up)
5 Command Receiver -> source HDMI 2
6 Command Receiver -> volume 50

Goodnight

1 Command First floor lights (multi-zone select) -> off
2 Command Second floor lights -> off
3 Delay 30000 ms
4 Command Bedroom lights -> level 1
5 Command All door locks -> lock
6 Set Site Mode night
7 Command HVAC -> setpoint [$night_setpoint]

All Off

1 For Each zones in subsystem "lighting" -> run macro "zone_off"
2 For Each zones in subsystem "av" -> run macro "zone_off"
3 For Each zones in subsystem "climate" -> run macro "zone_off"

zone_off is a one-step macro containing a Command step with Dynamic zone targeting, so it acts on [zone_id] — whichever zone the For Each is currently on.

Motion light with a proper off timer

Two macros and two triggers, with no delay inside either:

motion_on (trigger: hall motion → on)
1 Cancel Timer hall_off
2 Command Hall lights -> level 60

motion_off (trigger: hall motion → off)
1 Start Timer hall_off · after 300000 ms · run macro "hall_lights_off" · restart

hall_lights_off
1 Command Hall lights -> off

Every time motion stops the five-minute timer starts over; motion resuming cancels it. The lights go off five minutes after the last motion and never while someone is still moving. With one macro per room the timer name can be off_[zone_id] and the off macro can take [zone_id] as an argument.

Motion light, vacation aware

1 If / Else expression: ctx.mode != 'vacation'
THEN Command Hall lights -> level 60
Delay 2000 ms
ELSE Web Push "Motion in the hall while away" to the household
2 Command Hall lamp -> on

Step 2 runs after either branch. Before If / Else this took an If Expression with skip next step plus a second macro for the other branch.

1 Repeat 3 times, pause 400 ms
Command Porch lamp -> on
Delay 400 ms
Command Porch lamp -> off

Gate intercom

1 Camera Snapshot Gate camera -> save to variable "gate_snap"
2 Call Notification Caller "Front Gate", answer URL /controls/gates, target users: household

Confirmed open

1 Command Garage -> open
2 Wait For Attribute zone "garage" attribute "state" equal "open", timeout 45000
3 Web Push "Garage open" to the household users

Step 2 stops the macro if the garage never reports open, so the notification only goes out when the door actually moved.

Best Practices

  1. Naming — Name is technical (good_morning_weekday); Label is what users see ("Good Morning — Weekday"). Set the Label by editing that cell in the grid.
  2. Organization — Group macros by subsystem and use consistent name prefixes so the selectors stay navigable on a large job.
  3. Modularity — Build small reusable macros (one zone, one device) and call them from parent macros with Run Macro or For Each. A dynamic-target macro plus a For Each replaces dozens of near-identical steps.
  4. Timing — Include warm-up delays where equipment needs them, but wherever a device reports state, prefer a Wait For Attribute over a guessed Delay — it is both faster and more reliable.
  5. Speed — Wrap independent actions in a Parallel step. Cap concurrency when the targets share one gateway.
  6. Error handling — Decide deliberately between Stop on Error (a scene that must be all-or-nothing) and continue (a best-effort sweep), and use a step's On error policy for the exceptions: retry a flaky network call, continue past a cosmetic step, hand a failure to a notify macro. Set a macro Timeout so a stuck device can't hold a macro open.
  7. Verification — Add Expectations to scenes that matter, and watch the macro's expectation_error attribute.
  8. Testing — Test with the Context Simulator before wiring a macro to a schedule or trigger, so [token] placeholders are proven to resolve.
  9. Security — Leave Elevated on for anything that unlocks, opens or arms.

Troubleshooting

Macro not executing

  1. Enabled — Confirm the Enabled column is on. Macros created without touching the switches are saved disabled
  2. Elevated — If it's flagged Elevated it won't run from a user UI or a non-elevated API session, only from an admin, schedule or trigger
  3. No steps — A macro with zero steps returns "macro contains no steps" and does nothing
  4. Upstream — If it should be fired by a schedule or trigger, verify that schedule or trigger is itself enabled and firing (its own run history will say)
  5. Queued behind a long run — If the macro was already running, this request waits its turn and runs when the in-flight run (and anything queued ahead of it) finishes — behind a long-running macro that can look like it never fired

Macro runs but steps fail

  1. Device online — Check every device the steps target
  2. Command valid — Confirm the command still exists on the device's driver; a driver update can retire a command
  3. Placeholders — Use the Context Simulator's Preview on the failing step: an unresolved [token] usually means the caller isn't supplying that key
  4. Timing — A step may be firing before the previous one has taken effect; add a Wait For Attribute
  5. Order — Check Sort Index, especially after copying steps in from another macro
  6. Live view — Switch the diagram to Live, run the macro, and read the per-step status and error inline. Last Failure keeps the failing run available after later successful runs
  7. Reads — Open the step and check This step reads in the Context panel: a red token has no argument, simulated key or earlier step providing it

Script step errors in the system log include the macro name, macro ID, step ID and a stack trace, so you don't have to search every macro to find the failing one.

Macro runs slowly

  • Too many fixed delays — replace guessed delays with Wait For Attribute
  • Serial fan-out — wrap independent actions in a Parallel step
  • Remember the 100 ms pause between steps: a 60-step serial macro spends six seconds on that alone, which is another reason to collapse fan-out into Parallel

not authorized when adding a step

The step type is one of the nine privileged types, or it's a Command step aimed at an elevated device or at a zone on no UI your roles reach — see Who can author which steps. The account's role needs Elevated, or the step needs a target inside your own UI scope. The existing macro still runs either way; only authoring is blocked.

A step's changes don't seem to take effect

Step edits — adds, updates and deletes alike — are filed as a pending change against the parent macro, so the pending-changes badge in the admin top bar picks them up and Apply All reloads the macro. Or use Reload in the diagram toolbar to re-read the macro and its steps into the running system immediately, then run it again.

  • Macro Schedules — Time-based macro execution
  • Triggers — Attribute-driven macro execution
  • Variables — Sunrise/sunset, weather and other values macros can read
  • Devices — Device commands used in macro steps
  • Zones — Zone control in macro steps
  • Attributes — Attribute values, types and dynamic value syntax
  • Roles — Who may run and author macros