Automations
Step lists that run on triggers, from buttons, listeners or by hand - with conditions, control flow, variables, expressions, library functions, run modes and a run log.
An automation is a named list of steps. It runs when one of its triggers fires and all of its conditions hold, and also whenever something runs it: a dashboard button, a listener rule, another automation, the API or MCP. Automations are stored in the project (project.automations) and run inside tangle-core, on the primary node of a cluster or on a standalone node.
Since contract 0.2.30, automations also hold what macros used to (params, steps without triggers): macros in older projects are moved into automations when the project loads.
Example
At 08:00 on weekdays, if the projector is online, recall the presentation scene in Room A:
{
"id": "morning",
"name": "Morning",
"triggers": [{ "kind": "time", "at": "08:00", "days": ["mon", "tue", "wed", "thu", "fri"] }],
"conditions": [{ "kind": "status", "deviceId": "projector-1", "status": "online" }],
"steps": [
{
"kind": "intent",
"intent": { "name": "scene.recall", "target": { "kind": "room", "id": "room-a" }, "params": { "scene": "presentation" } }
}
],
"mode": "single"
}
Automation fields:
| Field | Required | Meaning |
|---|---|---|
id, name | yes | Identifier and display name. |
steps | no | What it does, in order. actions, the name before contract 0.2.30, is still read. |
triggers | no | Any one starts a run. Without triggers it runs only when something runs it. |
conditions | no | All must hold for the run to go ahead. None: always runs. |
params | no | Inputs, read as {{name}} in steps: name, optional label and default. |
mode | no | What a trigger does while a run is in progress. Default single. |
continueOnError | no | Keep running the next steps after one fails. The run ends as partial. |
priority | no | 0 to 100, applied to the intents the automation sends. Higher wins on conflict. Default 50, the same as timelines. |
enabled | no | Default true. false: its triggers do not start it, but it still runs by hand, from buttons and from other steps. |
folder | no | Folder in the automation list, for example Lobby/Evening. |
description | no | Free text. |
Editing in the designer
Right-click Automations in the project tree and choose New automation, or open one from the tree.
The editor has the step table on the left: one row per step with its number, an On checkbox (a step turned off is skipped, with the steps inside it), its type, target, value and what it waits for. + Add step opens the step picker, grouped as Device, Flow, Data, Run and Notes. Keyboard: Insert adds a step after the selected row, Alt+↑/↓ moves it, Space turns it off, Ctrl+D duplicates and Ctrl+Z undoes.
On the right:
- Step N edits the selected step.
- Automation holds the name, id, description, folder, Params, Run when (any of) (triggers), Only if (all of) (conditions) and Behaviour: the mode ("When it starts again while running"), Continue after a failed step and Priority (0–100).
- Runs has Run now (with Ignore the conditions when there are some), Stop, and the run log, which updates live.
The line above the table sums the automation up, for example "10:00 every day → Power on Gallery, then wait until done (at most 2 min), Media player: transport play". Click Create automation, then Save for later changes. Runs use the saved version: save first to try your changes.
Tutorial: a gallery installation builds two automations step by step.
Triggers (Run when)
| Kind | In the designer | Fields | Fires when |
|---|---|---|---|
time | Time of day | at (HH:MM, 24 h), optional days | The node's local clock reaches that minute. Every day when days is absent. |
deviceState | Device state changes | deviceId, capability, key, optional to, above, below, unit | A state value changes. With to, above or below, only when the new value starts matching. unit converts the thresholds (above: 26, unit: "°C"). |
deviceEvent | Device event (button…) | deviceId, name | The device emits the event, for example button.pressed. |
deviceStatus | Device goes online/offline | deviceId, status | The device's status becomes this one (online, offline, timeout, degraded, error, connecting). |
timeline | Timeline starts/stops/reaches a cue | timelineId, on, cueId for on: "cue" | started, stopped (end reached or stop) or cue (reaches cueId). |
variable | A project variable changes | optional variable, to, above, below | A project variable changes. Without variable: any of them. The run gets {{variable}}, {{value}} and {{previous}}. |
control | A dashboard control is clicked or changes | optional controlId, on, to, above, below | Someone operates a dashboard control, or a step or the API changes its value. on: change (default), click, press, release, hold or any. A button fires on click. The run gets {{control}}, {{value}}, {{previous}} and {{event}}. |
listener | A listener message matches | optional listener, rule | A rule of an inbound listener matches a message. The run gets the rule's captures as {{name}}. See Listeners. |
startup | Core starts | none | This node starts running automations: core starts, or a backup is promoted to primary. |
manual | Only by hand | none | Never fires by itself. Marks an automation meant to be run by hand. Any automation can be run by hand. |
Weekdays are mon, tue, wed, thu, fri, sat, sun. In the designer each trigger has a Triggers on checkbox to switch it off without deleting it.
Note: A minute that passes while core is down, or while the node is a backup, is not replayed. A
timetrigger at 08:00 does not fire at 08:05 because core restarted at 08:03.
Conditions (Only if)
| Kind | Fields | Holds when |
|---|---|---|
state | deviceId, capability, key, optional equals, above, below, unit | The current value passes every test given. |
status | deviceId, status | The device's current status is this one. |
time | optional after, before, days | Local time is in the window. after later than before spans midnight (for example 19:00 to 07:00). |
timeline | timelineId, status | The timeline's status is stopped, playing, paused or waiting. |
variable | variable, optional equals, notEquals, above, below | A project variable, param or run variable passes every test. A missing one is null. |
control | controlId, optional property, equals, notEquals, above, below | A dashboard control's value (or another property: label, visible, enabled, pressed or a prop name) passes every test. |
A state that core has not seen yet counts as not holding. A run whose conditions fail is logged as skipped with reason conditions not met. Conditions are also checked when the automation is run by hand, from a button or the API (unless forced), but not when another automation's step runs it.
Steps
| Kind | In the designer | Fields | What it does |
|---|---|---|---|
intent | Intent | intent, optional wait, timeoutMs, onTimeout | Sends an intent (scene recall, power, level and so on) to a device, a room, devices with a tag, or everything. |
action | Device action | deviceId, capability, action, optional params, wait, timeoutMs, onTimeout | Sends one device action. |
if | If | conditions, optional match (all or any), steps, otherwise | Runs steps when the conditions hold, otherwise when they do not. |
repeat | Repeat | count, steps | Runs the steps count times. {{index}} counts from 0. |
while | While | conditions, optional match, steps, maxIterations (default 1000) | Runs the steps again while the conditions hold, checked before each pass. Fails with loop limit reached past the limit. |
delay | Wait | ms | Waits, up to 86400000 ms (one day). |
waitUntil | Wait for a condition | conditions, optional match, timeoutMs (default 30000), onTimeout | Waits until the conditions hold. At the timeout: continue (default) or fail. |
stop | Stop | optional result (ok or fail), reason | Ends the run here, completed or failed. |
set | Set a variable, Expression | variable, value or add | Gives a variable a value, or adds to it (a missing one counts as 0). The designer's Expression is a set whose value is an expression. |
control | Set a control | controlId, optional value, add, label, visible, enabled, props | Sets a dashboard control on every screen showing it. null clears an override. |
automation | Run an automation | automationId, optional params, wait (default true) | Runs another automation, inside this run (wait: true, its failure fails the step) or as its own run (wait: false). Calls go at most 8 deep and an automation cannot call itself. |
call | Function | function, optional args, into, timeoutMs, onError | Calls a library function and keeps its result in into. See Function libraries. |
timeline | Timeline | timelineId, op, optional positionMs, cue | Transport: play, pause, stop, seek or go. cue jumps to a cue by id or {{name}}. See Timelines. |
comment | Comment | text | A note in the list. Does nothing. |
log | Log | message, optional level (info, warning, error), deviceId | Writes a line in the run's log and the core log, and in the device's log when deviceId is given. |
Every step takes disabled: true to be skipped (the designer's On checkbox).
Intents and device actions are sent with source automation and the automation's id, so the intent engine's priorities and holds apply. A delay in one automation never holds up another.
Waiting until devices are done
By default an intent or device action step waits until every device accepted it (wait: "sent"), then the next step runs. Tick Wait until the device reports it is done (wait: "done") to wait until each device reports that it finished: a projector is on, not warming up; a screen is down. The step waits at most timeoutMs (default 120000, At most in the designer), then goes on (onTimeout: "continue", the default) or fails the run ("fail").
{
"kind": "intent",
"intent": { "name": "power.on", "target": { "kind": "room", "id": "gallery" } },
"wait": "done",
"timeoutMs": 120000
}
Failures
When a step is rejected or fails, the run ends as failed, and later steps are not run. An action sent to an offline device fails at once. With continueOnError: true (Continue after a failed step), the remaining steps run and the run ends as partial.
The run records the failed step by its position (step, for example 2 or 3.1 inside an if) and a reason, for example power.on partial: right-projector device offline.
Variables and expressions
Variables
Three kinds of values are read as {{name}} in step values, templates and expressions:
- Params of the automation, filled by whatever runs it. A dashboard control passes its value as the param named
value. A listener rule passes its captures. - Run variables: any name a
setorcallstep writes that is not a project variable. They live for one run. - Project variables, declared under Variables in the project tree (
project.variables): values core keeps for the whole project, for examplehouseMode = "show"orvisitors = 12. Everyone sees them: dashboards show and change them, the API reads and writes them, andvariabletriggers start automations when they change. They are kept across restarts unless Keep is unticked (persist: false).
Triggers add their own values: {{value}} and {{previous}} for variable and control triggers, captures for listener triggers, {{index}} inside a repeat.
A value that is only "{{name}}" keeps the variable's type (a number stays a number). "Room {{room}} is ready" fills it in as text.
Expressions
Wherever a step takes a value, it can take an expression instead, computed when the step runs. In the designer, click ƒx next to a value.
{ "kind": "set", "variable": "level", "value": { "$expr": "clamp({{level}} * 1.2, 0, 100)" } }
An expression has:
{{name}}for params and variables, numbers,"strings",true,false,null.+ - * / %,== != < <= > >=,&& || !,a ? b : cand parentheses.state(device, capability, key)to read device state.- Built-in functions:
abs,min,max,round,floor,ceil,clamp,len,upper,lower,str,num,now. - Dashboard controls:
control.value(id),control.label(id),control.visible(id),control.enabled(id),control.pressed(id),control.min(id),control.max(id),control.prop(id, name).control.click(id),control.press(id),control.release(id)andcontrol.hold(id)are true when this run was started by that event of that control, andcontrol.event(id)gives the event name. - Device playheads:
device.timecode(id, playhead?),device.position(id, playhead?),device.duration(id, playhead?)anddevice.running(id, playhead?), for devices with thetangle.playheadcapability (media servers, players). Without a playhead name they read the main one. - Library functions such as
math.scale(...)ortime.format(now()).
Function libraries
Function libraries add named functions to expressions and to the Function step (call), written namespace.name(args). Core comes with five libraries:
| Library | Functions |
|---|---|
math | pow, sqrt, exp, log, log10, sin, cos, tan, asin, acos, atan, atan2, hypot, degrees, radians, sign, pi, mod, scale, lerp, clamp01, roundTo, dbToGain, gainToDb |
text | trim, pad, replace, split, join, contains, startsWith, endsWith, indexOf, slice, repeat, format, hex, fromHex |
time | format, parse, add, diff, ms, duration, hour, minute, weekday, date |
list | at, first, last, sum, avg, min, max, sort, reverse, unique, includes, slice, range |
color | hex, rgb, hsv, fromHsv, kelvin, mix, toRgbw, brightness |
For example math.scale(128, 0, 255, 0, 100) is 50.2, time.add(now(), "1h30m") is in an hour and a half, and color.kelvin(3200) is the colour of warm white light.
Modules can add libraries too. The Image processing library (tangle.image.library, namespace image: info, resize, thumbnail, crop, rotate, flip, grayscale, adjust, compose, convert, averageColor, dominantColors, pixel) needs the IMAGE entitlement of Pro and Ultimate licenses; the free designer and demo mode can call every library. Install it from Settings > Modules.
Only pure functions (same arguments, same result, no side effects) can be used inside expressions and conditions. Any function can be used in a Function step, which waits for it (default 5 s, at most 120 s) and fails the run on an error, or goes on with {{error}} set when On error is continue.
GET /api/v1/functions lists every function with its signature, and POST /api/v1/functions/{name}/call tries one. Writing a library module is covered in Module SDK.
Run modes
mode decides what happens when a trigger fires during a run:
| Mode | Behaviour |
|---|---|
single | Ignore the new trigger. The ignored run is logged as skipped (already running). |
restart | Cancel the run in progress (logged as cancelled, restarted) and start again. |
queued | Run again after the current run. Up to 10 runs can wait; more are skipped (queue full). |
parallel | Run alongside the current run, up to 10 at once; more are skipped (too many runs). |
Loop guard
An automation can trigger itself, for example by changing the state that its trigger watches. Core starts at most 10 runs of one automation per second. Further runs are skipped with reason rate_limited and a warning is logged. The show and other automations keep running.
Timing
From the triggering event to the first step takes under 20 ms on the primary. Time triggers fire within a second of the minute.
Running an automation by hand
Running by hand needs a token with the control scope. Conditions are checked unless you force the run.
REST:
curl -X POST http://127.0.0.1:7340/api/v1/automations/morning/run \
-H "Authorization: Bearer $TOKEN" -H "content-type: application/json" \
-d '{"force": true, "params": {}}'
force: true skips the conditions; params fills the automation's params. The answer is the run, with state started or skipped. POST /api/v1/automations/{id}/stop cancels its runs in progress.
WebSocket (for example from a dashboard button):
{ "v": 1, "id": "7", "type": "automation.run", "automationId": "close-room" }
The acknowledgement carries the run (runId, state). The run's trigger is recorded as manual.
MCP: run_automation.
A manual run is refused with 409 not_primary on a backup node.
Run history
Core keeps the last 50 runs of each automation in memory (they are not kept across restarts).
- Designer: the Runs tab of the automation.
- REST:
GET /api/v1/automations/{id}/runs, newest first. - MCP:
get_automation_runs. - Events: subscribe to
automation.run:<id>(orautomation.run:*). Core publishes an event when a run starts and when it ends.
Each run looks like this:
{
"runId": "4f0c6d1e-...",
"automationId": "morning",
"state": "completed",
"trigger": "time 08:00",
"params": {},
"log": [],
"startedAt": 1760356800012,
"endedAt": 1760356800190
}
Run states: started, completed, partial, failed, skipped, cancelled. reason explains a failed, partial, skipped or cancelled run, and step names the step in progress or the one that failed. log holds the lines of log steps (at most 100). trigger describes what started it, for example manual, startup, deviceEvent keypad-1 button.pressed or timeline show cue c4.
Automations and redundancy
Only the primary node (or a standalone node) runs automations, also in shared mode. A backup stays idle and starts running them when it is promoted, which also fires its startup triggers. Runs in progress on a failed primary are not resumed. When a node steps down to backup, its runs in progress are cancelled. See Redundancy.
More examples
Recall a scene when a keypad button is pressed:
{
"id": "present",
"name": "Presentation button",
"triggers": [{ "kind": "deviceEvent", "deviceId": "keypad-1", "name": "button.pressed" }],
"steps": [
{ "kind": "intent", "intent": { "name": "scene.recall", "target": { "kind": "room", "id": "room-a" }, "params": { "scene": "presentation" } } }
]
}
Start the attract loop at night only, when the show timeline has stopped:
{
"id": "attract",
"name": "Attract loop after the show",
"triggers": [{ "kind": "timeline", "timelineId": "show", "on": "stopped" }],
"conditions": [{ "kind": "time", "after": "19:00", "before": "23:00" }],
"steps": [
{ "kind": "delay", "ms": 5000 },
{ "kind": "timeline", "timelineId": "attract", "op": "play" }
]
}
Count visitors with a door contact on an Ethernet I/O module, and switch the house to "full" at 120:
{
"id": "count-visitors",
"name": "Count visitors",
"triggers": [{ "kind": "deviceEvent", "deviceId": "door-sensor", "name": "input.changed" }],
"mode": "queued",
"steps": [
{ "kind": "set", "variable": "visitors", "add": 1 },
{
"kind": "if",
"conditions": [{ "kind": "variable", "variable": "visitors", "above": 119 }],
"steps": [{ "kind": "set", "variable": "houseMode", "value": "full" }]
}
]
}
Set a room level from a dashboard slider (0 to 100) with a param. level.set takes a level from 0 to 1:
{
"id": "set-level",
"name": "Set the room level",
"params": [{ "name": "value", "label": "Level (%)", "default": 50 }],
"steps": [
{
"kind": "intent",
"intent": { "name": "level.set", "target": { "kind": "room", "id": "room-a" }, "params": { "level": { "$expr": "clamp({{value}} / 100, 0, 1)" } } }
}
]
}
Shut a room down by hand from a dashboard, continuing past devices that do not answer:
{
"id": "close-room",
"name": "Close room",
"triggers": [{ "kind": "manual" }],
"steps": [
{ "kind": "timeline", "timelineId": "show", "op": "stop" },
{ "kind": "intent", "intent": { "name": "power.off", "target": { "kind": "room", "id": "room-a" } } }
],
"continueOnError": true,
"mode": "single"
}