Skip to content
GitHub

Scripts

A script is a sequence of steps with a name. The CIP cycle. The line purge. The changeover to product B. Every plant has a handful of procedures it runs exactly the same way every time, and the day somebody copies one into a fourth automation and fixes a step in only three of them is the day it bites you. So you write it once, give it a name, and everything that needs it calls it by that name: an operator from the Scripts screen, a rule with a call_script step, an agent with run_script, anything at all through POST /api/services/script/turn_on.

A script speaks the automation language, runs on the same sequence runner a rule’s actions do, and every write it makes goes through the one write gate. No side door. It never had one to begin with.

{
"id": "cip_cycle",
"name": "CIP cycle",
"description": "Caustic wash, rinse, done. Tank must be empty.",
"fields": [
{"name": "minutes", "label": "Wash time", "type": "number", "min": 5, "max": 60, "step": 5, "unit": "min", "required": true},
{"name": "circuit", "type": "select", "options": ["tank_1", "tank_2"], "default": "tank_1"}
],
"sequence": [
{"type": "condition", "params": {"expression": "float(states['sensor.cip_tank_level']) < 5"}},
{"type": "call_service", "params": {"entityId": "switch.cip_pump", "service": "switch.turn_on"}},
{"type": "delay", "params": {"seconds": 5}},
{"type": "wait", "params": {"expression": "float(states['sensor.cip_return_temp']) > 70", "timeoutSec": 900}},
{"type": "log_event", "params": {"message": "CIP on {{ vars.circuit }} for {{ vars.minutes }} minutes"}},
{"type": "call_service", "params": {"entityId": "switch.cip_pump", "service": "switch.turn_off"}}
],
"mode": "skip",
"maxRuntimeSec": 1800
}
  • id is lowercase letters, digits and underscores, starting with a letter. It is also its entity’s object id, so this one is script.cip_cycle, and it never changes.
  • fields are what a run takes. Same typed inputs everywhere in HotLoop (model.Field): number with min, max, step and unit, text with max_length and pattern, boolean, select with options, entity with the domains it takes, duration (seconds, or 90s, 1h30m), alarm and tag. Each can be required or have a default, and a default its own field would refuse is refused when you save. A step reads a field as vars.<name>.
  • sequence is the automation language, the whole of it: call_service, write_tag, condition, wait, stop, call_script, set_variable, delay, log_event and the rest. AUTOMATIONS.md has every step.
  • mode and maxRuntimeSec are below.

A script is compiled when it is saved, like a rule: a step that does not decode, an expression that does not compile, a wait longer than a run may take, a field that makes no sense, all refused at save. You find out in the editor, not at 3am with the pump half way through a wash.

Before the first step runs, what the run was given is checked against the fields: every value must be one its field takes, every required one must be there, and a field the script does not have is an error rather than something quietly ignored. A value is never moved to fit. 70 minutes on a field whose maximum is 60 is refused, not trimmed to 60; "12" for a number is refused, not parsed; 12.5 on a step of 5 is refused, not rounded. The value that goes on to command equipment is exactly the one somebody entered, or nothing runs at all. A refused run leaves no run behind: nothing started.

From How Needs
The Scripts screen Run on its card, fill in the form scripts.run
Its entity Run on its tile, or POST /api/services/script/turn_on with the fields as the data scripts.run
The API POST /api/scripts/{id}/run with {"fields": {...}, "reason": "..."} scripts.run
A rule a call_script step the rule’s own
An agent run_script over MCP MCP writes on

Operators run scripts. Admins write them. Running the CIP cycle is what the button is for; deciding which valves the CIP cycle opens is an engineer’s call and it stays one.

A run answers as soon as it has started, with its id. Ask it to wait and it answers when it ends, with its outcome and the trace of every step. Stop (POST /api/scripts/{id}/stop, or script.turn_off) cancels every run of it where it is, in every process, a wait or a delay included. Whatever a step had already written stays written. Undoing is a write too, made without anybody asking for it, to equipment that has already moved, and that is the operator’s decision, not ours.

A script runs as script:<id>. Every write it makes, every service call, every log line carries that as its actor, and every write is audited against it in GET /api/writes, refused or not. The source is whoever started it: ui from the screen, automation from a rule, mcp from an agent. That matters because the gate decides on source. A script an agent starts writes as mcp, and with HOTLOOP_ALLOW_MCP_WRITES off every one of its writes is refused, exactly as the agent’s own write_tag would be. run_script and an agent’s script.turn_on refuse up front in that case rather than running a script whose every write is going to bounce.

The run records who asked (actor), how (source), and what called it (parent: automation_run:812, script_run:77), so you can walk from a write back to the rule, the script and the person or agent behind it.

POST /api/scripts/{id}/run?dry_run=true, Check first in the run drawer, or run_script with dryRun. The fields are checked the same way, then every step is walked against the plant as it is right now:

  • every write_tag and call_service is put to the write gate’s own checks (CheckWrite, Services.Check), the same code the real write runs, so a dry run cannot say yes to something the real write would refuse for a reason the dry run did not check. Nothing is written, nothing is audited;
  • a condition is evaluated now, and a false one ends the walk where it would end the run;
  • a set_variable is bound, so the steps after it see what they would;
  • a call_script walks the script it calls, its steps numbered under the call (3.1, 3.2);
  • a wait, a delay, and anything that writes to no equipment (a message, an HTTP call, an alarm raised) is not_checked. A dry run does not wait fifteen minutes to tell you what the tank will read then.

The first write that would be refused ends the walk, because it would end the run, and the answer says so in the gate’s words: step 2 (call_service): cip/pump: this tag is not marked writable. A dry run that comes back clean is advice, not a promise. A device can drop, somebody can disarm a tag, and a wait gives the plant all the time it needs to change its mind between your check and the step.

What a run asked for while another run of the same script is going does. The same words a rule uses, with one difference.

Mode A second run
skip refused, 409, and a skipped run in the history saying why. The default, and the right answer for anything that commands equipment
queue waits its turn, and starts when the one ahead of it has ended. One deep: a third is refused
restart cancels the one in progress where it is, then starts once it has stopped. Two runs never overlap
parallel runs alongside

The difference: a rule’s queue drops the older pending run in silence, and a script’s queue refuses the newer one out loud. A rule is fired by a trigger that does not care. A script is asked for by somebody, or something, that may be waiting on the answer, and “your run quietly never happened” is not an answer.

A call_script step that waits for a busy script fails (script busy); one that does not wait notes it in the trace and carries on, because all it was ever going to do was start it.

maxRuntimeSec bounds one run, five minutes when it is zero. A run that runs out of it is timed_out, whether it was between steps or in the middle of a wait.

A call_script step runs a script from a rule or from another script. With wait the step takes the script’s outcome as its own, and the script is part of the run that called it: cancel the caller, or let it run out of time, and the script it is waiting on stops too. Without wait the script is started and left to finish on its own.

Two limits, both there because the alternative is a script that loops:

  • A script that would call itself is refused when it is saved, directly or through any chain of other scripts: script b would call itself: b calls a calls b. The same goes for an edit that closes a loop.
  • Five deep, at run time. A sixth script in one chain fails the step that would have called it, and the chain with it. And if a loop ever gets past the save (two processes saving two halves of it at the same moment), the run catches it: a script already running in its own chain is refused, not run a second time.

Every script is an entity, script.<id>, platform script. Its state is on while a run of it is going, in any process, and off when none is; its attributes carry its mode, how many runs are going (current), its fields, and when it last started and how that ended. It is on the Entities screen with a Run button, in list_entities over MCP, in /api/states, and a rule can have a state trigger on it: “when the CIP cycle goes off, log the batch”. Deleting a script orphans its entity, the way deleting an alarm definition does: an admin deletes the orphan when nothing refers to it any more.

Every run is a row in script_runs: who, how, the fields it was given after they were checked, the trace of every step (resolved inputs, not templates), how it ended and why. GET /api/scripts/{id}/runs, the run drawer, and the logbook, where script runs are kind script, found by the script’s entity or by anything a run wrote to. The history keeps the newest 500 runs of each script, the same bound automation runs have.

A run’s row is opened before its first step and finished after its last. The process stopping cancels every run in progress where it is and records it as cancelled: interrupted by restart. A run a crash cut off, with nobody left to finish its row, is marked the same on the Gateway’s next start. Nothing resumes a run. Carrying on from step four of a CIP cycle an hour later, unasked, against a plant that has moved on, is worse than saying it stopped.

The Gateway and an --mcp-stdio process beside it (or two pods mid-rollout) both run scripts. A run’s state lives in the database, and every process listens for changes on hotloop_script, so:

  • a script started in one process shows on in every process’s entity;
  • a script saved on one pod is the script on the other, and its runs there are cancelled if its steps changed;
  • Stop stops every run of it, in every process.

A run mode is kept per process. Two processes can each start one run of a skip script at the same instant. A plant running the same procedure from an agent’s helper process and the operator’s screen in the same second has a conversation to have that no lock of ours would settle.

scripts and script_runs are in every backup and come back on a restore, with the history. A run the backup caught in the middle comes back running and is marked interrupted by restart on the first start, like any other. See BACKUP.md.

Every route, its permission and its answers are in API.md.

Permission viewer operator admin
scripts.read: list them, their runs yes yes yes
scripts.run: run, dry run, stop, script.turn_on/turn_off no yes yes
scripts.write: create, edit, delete no no yes