Helpers
A helper is a value the plant’s people own rather than a PLC. “Line 2 is in changeover.” “Today’s batch target is 1,250 kg.” “B shift is on.” “Twelve parts scrapped since the last reset.” “The purge has nine minutes left.” None of that lives in a register, and all of it is exactly what rules and screens need to know.
HotLoop has seven kinds of them. A helper is an entity like any other: it
has an entity_id, a state, attributes and services, it sits on the
equipment tree, it shows up in /api/states, on the Entities screen, in list_entities over MCP, and in an
automation’s state trigger. What is different is where its value lives:
HotLoop keeps it, in the database, and no service of a helper ever writes a
tag.
The seven
Section titled “The seven”| Domain | State | Options | Services |
|---|---|---|---|
input_boolean |
on / off |
initial (true/false), restore |
turn_on, turn_off, toggle |
input_number |
the number, "1250" |
min, max (both required), step (default 1), mode (slider or box), initial, restore, and a unit_of_measurement |
set_value {value}, increment, decrement |
input_select |
the choice | choices (required, up to 100), initial, restore |
select_option {option}, select_next {cycle}, select_previous {cycle} |
input_text |
the text | max_length (default and ceiling 255), pattern, initial, restore |
set_value {value} |
input_counter |
a whole number | initial (default 0), step (default 1), min, max, restore |
increment, decrement, reset, set_value {value} |
timer |
idle / active / paused |
duration_sec (required, 1 second to 7 days), restore |
start {duration}, pause, cancel, finish |
schedule |
on / off |
schedule (required) |
none: it follows the clock |
The counter is input_counter, not counter. counter is already the PLC
composite whose reset writes a tag, and it stays exactly what it is.
Every option is checked when the helper is saved, and initial against all
the others: an input_number from 0 to 100 that starts at 150 is refused, not
started at 100. An option a domain does not take is an error, not a setting
quietly ignored. The helper options (initial, restore, choices,
duration_sec, mode, schedule) are refused on every other domain for the
same reason.
Making one
Section titled “Making one”An admin makes helpers (entities.write). On the Entities screen, New
helper, next to New equipment. Over the API:
POST /api/helpers{"name": "Batch target", "domain": "input_number", "unit_of_measurement": "kg", "options": {"min": 0, "max": 5000, "step": 50, "initial": 1000}}The entity_id is made from the name (input_number.batch_target) unless you
give one. PUT /api/helpers/{entity_id} changes its name, icon, unit or
options; DELETE /api/helpers/{entity_id} deletes it and its value. The whole
API is in API.md.
A change of options that would leave the helper’s current value outside them is refused: narrowing a range past the value, or removing the choice that is selected. Set the value first, or choose options that include it. A value is never moved to fit.
Setting one
Section titled “Setting one”Anybody with helpers.write sets helpers. Operators hold it: running the shift
selector and counting scrap is operation, not configuration. Viewers do not.
It is not values.write, because a helper writes no tag.
Setting a helper is an entity service call, the same call every other entity takes:
POST /api/services/input_counter/increment {"entity_id": "input_counter.scrap", "reason": "short shot"}POST /api/services/timer/start {"entity_id": "timer.purge", "duration": "0:15:00"}The answer says what the helper is now: {"call_id": "...", "state": "13", "writes": []}. ?dry_run=true works too and answers in the same words the
real call would, without changing anything.
On the Entities screen a helper gets its own tile, never folded into another
(it has no tags to fold by), with its controls for whoever holds
helpers.write. Every change is confirmed first, with a reason, like every
other command.
From MCP there is no new tool: list_entities finds helpers, get_states
reads them, call_service sets them. An agent changes a helper only on a
deployment with HOTLOOP_ALLOW_WRITES and HOTLOOP_ALLOW_MCP_WRITES both on,
the same switches a write to a tag needs. A helper moves no equipment, but it
is what rules act on, and an agent flipping the changeover switch is an agent
starting whatever the changeover rule does.
Refused, never clamped
Section titled “Refused, never clamped”A value a helper will not take is refused, and nothing changes:
- a number outside
min..max, or off itsstep(counted frommin); - an
incrementthat would passmax, adecrementthat would passmin(refused, not stopped at the limit: “stopped at the limit” is a value nobody asked for); - an option that is not one of the choices;
select_nextpast the last choice withcycle: false; - a text longer than
max_length, or not matchingpattern(the whole text must match); - a count that is not a whole number, or past the counter’s
minormax; - a timer paused when it is not running, cancelled or finished when it is idle, or started for less than a second or more than seven days;
- a disabled helper; a caller without
helpers.write; an agent with MCP writes off.
Every refusal is a warning in the system log (GET /api/events) against the
helper, with who asked and why it was refused, exactly like a refused alarm
acknowledgement. A mistake in the request itself (a field the service does not
take, "50" where a number goes) is the caller’s error, answered 400, and
changes and records nothing.
Where changes are recorded
Section titled “Where changes are recorded”Every change is one line in the system log against the helper: who, from what, to what, the call id, where it came from, and the reason if one was given.
otto input_counter.increment: 12 -> 13; reason: short shotsystem timer finished: active -> idle, due at 2026-09-27T21:10:00ZThe line is written in the same transaction as the value, so there is never a change without its line or a line without its change.
A change of state is also a line in the logbook, in that same
transaction, with the same who, source and call id. It is written by the
process that made the change, whichever one that is, so an agent’s change
through --mcp-stdio is logged too, and logged once. Helper lines are never
rate-limited: every one is somebody deciding something. A call that leaves the
state as it was (a number set to the value it already has) is in the system
log but not the logbook, because nothing changed.
Restarts
Section titled “Restarts”A helper keeps its value across a restart: the Gateway comes back with the
shift still B and the counter still 13. Set restore: false on one that should
start fresh every time (a “since the last restart” counter): the Gateway puts it
back to its initial value as it starts, and says so in the log. Only the
Gateway itself does that. An --mcp-stdio process an agent starts beside a
running Gateway holds the same helpers and hears every change, but never resets
anything, because resetting the plant’s values because an agent connected
would be absurd.
Timers
Section titled “Timers”A timer runs on the wall clock. Started for ten minutes, it finishes ten
minutes later whatever happens in between: a restart that took two minutes
leaves it eight to run. A paused timer stays paused, with the time it had left,
across any number of restarts, and start resumes it.
A timer that ran out while no HotLoop was running is put back to idle
when HotLoop next starts, with finished_late_at set to when it was due, and a
warning in the log. Nothing fires for it. A rule waiting for that timer to
go idle does not run, because the engine’s first look at it after a start is a
baseline, not a change. Whatever it was timing (a purge, a cure) happened long
ago or not at all, and doing it now, late and unasked, is worse than saying it
was missed. Look for finished_late_at and decide.
A timer is not an interlock. It runs on a process’s clock, through a database and a network, to the second and usually the millisecond. Anything that must stop on time belongs in the PLC.
Schedules
Section titled “Schedules”A schedule is on inside its blocks and off outside them:
{"schedule": { "mon": [{"from": "06:00", "to": "14:00", "data": {"crew": "A"}}, {"from": "14:00", "to": "22:00", "data": {"crew": "B"}}], "fri": [{"from": "22:00", "to": "06:00", "data": {"crew": "C"}}]}}- The days are
montosun. A time isHH:MMorHH:MM:SS;24:00is the end of a day. - A block whose
tois earlier than itsfromruns past midnight: Friday’s 22:00 to 06:00 is on until Saturday 06:00. - Blocks that touch are one stretch of on; blocks that overlap are refused, because which one’s data applies would be a guess.
- While a block is on, its
datais in the entity’s attributes (crew: "B"), andnext_eventalways says when the schedule next changes. - It is read on the wall clock in the site’s timezone
(
HOTLOOP_SITE_TIMEZONE), because a shift that starts at 06:00 starts at 06:00 on the day the clocks change too. On the night the clocks go forward, the hour that does not happen is not on; on the night they go back, the hour that happens twice is on both times.
More than one process
Section titled “More than one process”Every change is announced with Postgres NOTIFY, and every process holding the
helpers listens. A helper set in one process shows its new value in every
other within a round trip, a helper created or deleted in one appears or goes
in the others, and a change made through PUT /api/helpers/{entity_id} is
picked up the same way (one made through PUT /api/entities/{entity_id}
reaches the others on their next registry sync, as any entity’s does). Every write
is conditional on the row’s revision, so two processes counting the same
counter at once never lose a count, and a timer both hold is finished by exactly
one of them.
Backups
Section titled “Backups”Helpers are in every backup: their configuration with the entities, and their
values in helper_state, revisions included. A restored plant comes back with
the shift, the targets and the counts it had, and a timer that was running is
still running on the wall clock (or, if it ran out in the meantime, idle and
marked late, as above).
In automations
Section titled “In automations”A helper is what rules act on and set:
{"triggers": [{"type": "state", "entityId": "input_boolean.changeover", "to": "on"}], "actions": [{"type": "call_service", "params": {"entityId": "number.line_2_speed_sp", "service": "number.set_value", "data": {"value": 0}}}]}The rule’s write to the tag goes through the write gate like any other, with
the rule as the actor. states['input_select.shift'] == 'B' works in a
condition, and a rule can set a helper with call_service the same way it sets
a switch.