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.
What one looks like
Section titled “What one looks like”{ "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}idis lowercase letters, digits and underscores, starting with a letter. It is also its entity’s object id, so this one isscript.cip_cycle, and it never changes.fieldsare what a run takes. Same typed inputs everywhere in HotLoop (model.Field):numberwithmin,max,stepandunit,textwithmax_lengthandpattern,boolean,selectwithoptions,entitywith thedomains it takes,duration(seconds, or90s,1h30m),alarmandtag. Each can berequiredor have adefault, and a default its own field would refuse is refused when you save. A step reads a field asvars.<name>.sequenceis the automation language, the whole of it:call_service,write_tag,condition,wait,stop,call_script,set_variable,delay,log_eventand the rest. AUTOMATIONS.md has every step.modeandmaxRuntimeSecare 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.
Fields are checked, never fixed
Section titled “Fields are checked, never fixed”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.
Running one
Section titled “Running one”| 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.
Every write goes through the gate
Section titled “Every write goes through the gate”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.
Ask first: the dry run
Section titled “Ask first: the dry run”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_tagandcall_serviceis 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
conditionis evaluated now, and a false one ends the walk where it would end the run; - a
set_variableis bound, so the steps after it see what they would; - a
call_scriptwalks the script it calls, its steps numbered under the call (3.1,3.2); - a
wait, adelay, and anything that writes to no equipment (a message, an HTTP call, an alarm raised) isnot_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.
Run modes
Section titled “Run modes”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.
Scripts calling scripts
Section titled “Scripts calling scripts”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.
Its entity
Section titled “Its entity”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.
The run history
Section titled “The run history”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.
More than one process
Section titled “More than one process”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
onin 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.
Backups
Section titled “Backups”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.
The API
Section titled “The API”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 |