Skip to content
GitHub

Recipes

A recipe is a set of values that go out to the plant together. Grade A’s zone temperatures, line speed and die gap. The changeover to the 500 ml bottle. The night setback. Every plant has these, and every plant has a laminated sheet somewhere with the numbers on it and an operator typing them in one at a time, and every plant has the story about the night one of them got typed in wrong.

So the numbers live here instead, and they go out in one go: checked whole first, sent in order, read back from the device afterwards, every one of them on the record. Every recipe is also an entity, scene.<id>. It’s built for equipment that can hurt somebody, which is why most of this page is about what happens when something goes wrong.

{
"id": "grade_a",
"name": "Grade A",
"description": "The 2pm run. Die at 210, line at whatever the order says.",
"equipment_id": 7,
"apply_permission": "recipes.apply",
"params": [
{"name": "speed", "label": "Line speed", "type": "number", "min": 0, "max": 400, "step": 10, "unit": "rpm", "required": true}
],
"precondition": "states['input_boolean.line_ready'] == 'on'",
"targets": [
{"entity_id": "loop.line_02_die_tic", "service": "set_mode", "data": {"mode": "auto"}},
{"entity_id": "loop.line_02_die_tic", "service": "set_setpoint", "data": {"value": 210}},
{"entity_id": "number.line_02_speed", "service": "set_value", "data_expressions": {"value": "vars.speed"}},
{"entity_id": "switch.line_02_heater", "service": "turn_on"},
{"entity_id": "input_select.product", "service": "select_option", "data": {"option": "Grade A"}}
],
"verify_sec": 10
}
  • id is lowercase letters, digits and underscores, starting with a letter. It is its entity’s object id, scene.grade_a, and never changes.
  • equipment_id is the node of the equipment tree the recipe belongs to, for finding it. The Recipes screen groups by it. Leave it out for a recipe of the whole plant.
  • params are what an application takes: the same typed fields a script has (model.Field: number with min, max, step and unit, text, boolean, select, entity, duration, alarm, tag). A target reads one as vars.<name> in a data_expressions entry.
  • precondition is an expression in the automation language that must be true for the recipe to start. Leave it out and there is none.
  • targets are the values, in the order they go out. Each is an entity, the service that sets it, and that service’s data, as a value (data) or computed from the parameters (data_expressions). Up to 200.
  • verify_sec is how long each written value is given to show up in a reading from the device. 1 to 600, default 10.

A recipe sets values. The services it takes are the ones that leave equipment holding a value:

Domain Services
switch, input_boolean turn_on, turn_off
number, input_number, input_counter set_value
text, input_text set_value
select, input_select select_option
valve set_position
motor set_speed
loop set_setpoint, set_mode

A button press, a motor start, a counter reset, a valve stop: refused when you save. Those are actions, not values, and they belong in a script. “Apply grade A” pressing a reset button every time somebody applies it is not what anybody means by a recipe, and the day it happens during a run is the day you find out. Toggle is refused too: a recipe that flips a switch does the opposite thing every other time.

A target’s entity has to exist when you save, its expressions have to compile, and the same entity cannot be set twice by the same service (two values for one setting, and which one wins would depend on an order nobody reads). A loop’s mode and its setpoint are two settings and both can be set; set the mode first, because a setpoint for auto means nothing to a loop left in manual.

From the Recipes screen, the recipe’s tile on the Entities screen, a rule’s or a script’s apply_recipe step, MCP’s apply_recipe, or POST /api/services/scene/turn_on. Every one of them does exactly this:

Before a single value goes anywhere:

  • The parameters are checked against their fields. A value is never moved to fit. 450 on a field whose maximum is 400 is refused, not clamped to 400. "250" for a number is refused, not parsed. 255 on a step of 10 is refused, not rounded. The number that goes to the plant is exactly the one somebody entered, or nothing goes.
  • The recipe’s own permission, when it names one (below), and the MCP write switches, for an agent.
  • The precondition is evaluated against the plant as it is now.
  • Every data expression is evaluated. One that fails refuses the recipe; a target is never sent without the value it was meant to have.
  • Nothing else in this process may be writing to one of its entities (below).
  • Every target is put to the write gate’s own checks, the same ones its write will meet: writes on, the tag writable, the value in the tag’s range, the entity enabled and happy with the value (its step, its options, a loop in manual for its output), the device connected. Services.Check, which for a tag is runtime.Manager.CheckWrite. Nothing is written or audited.

If anything fails, the whole recipe is refused and nothing is written. Not the first ten targets and then a refusal. Nothing. And you get every reason, not just the first: an operator told one problem fixes it and is told the next, and a recipe of forty targets should be fixed in one go. Each target says which it was: would_write, refused with the gate’s words, or not_sent.

2. Written in order, one call id, never rolled back

Section titled “2. Written in order, one call id, never rolled back”

Then the targets go out in order, each through Services.Call and so through runtime.Manager.Write, the one write gate. There is no side door. Every write carries the application’s call id, so the whole application is GET /api/writes?call=<id>, in the audit trail, next to every other write the plant ever took.

A check can pass and the write still fail a moment later. A PLC answers a write with an exception. A cable gets kicked. Somebody disarms a tag between the check and the write. The first target that does not go through stops the application. The ones after it are not sent, and say skipped.

Nothing already written is undone. This is deliberate, and it is the rule every HotLoop write follows. Undoing is itself a write: made without anybody asking for it, to equipment that has already moved, based on what we think the value was before, at the exact moment something has just proved the plant is not behaving the way we expected. That is how a half-applied recipe becomes a fully confused line. Deciding what to do about a half-applied recipe is the operator’s job, and they get exactly the information to do it with:

PARTIAL v3: 2 of 5 written before one failed, nothing undone
1. set_mode loop.line_02_die_tic: written, verified
2. set_setpoint loop.line_02_die_tic: written, verified
3. set_value number.line_02_speed: failed: the device answered the write with an exception
4. turn_on switch.line_02_heater: skipped
5. select_option input_select.product: skipped

A target is written, failed (it went out and the device said no, or said nothing, so whether it took is not known), not_sent (the gate turned it away at the last moment), or skipped. The application is applied (every target written), partial (some were), or failed (the first one was not).

The Recipes screen then offers Apply the remaining targets. That is a new application, of the targets that were not written, with the same version and parameters, checked whole again first, audited under a call id of its own, with application:<id> as its parent so the two are found together. It is never automatic. Somebody looks at what happened, fixes whatever stopped it, and presses the button.

Written is not the same as taken. A PLC can accept a write and its own logic overwrite it on the next scan. A scaled register can take 12.34 and hold 12.3. So after the last write, each written value is read back:

  • for a target on a device, only from a reading taken after its write, so the cache remembering what the device said before cannot confirm anything;
  • from the entity’s state, or for a valve, a motor and a loop, the attribute the service sets (current_position, speed, setpoint, mode);
  • a number within a millionth of itself (a float register gives 0.1 back as 0.10000000149, which is the same setpoint), anything else exactly.

Each written target is verified once it reads back, or unconfirmed if it has not within verify_sec, with what was read instead:

3. set_value number.line_02_speed: written, unconfirmed (not confirmed within 10s: read back 240, not 250)

Unconfirmed is shown amber, never green, on the Recipes screen, and it is a warning in the logbook and the system log. It is not a failure: the value went out and the device accepted it, and the application is still applied. It is a flag that says go and look, because it may not have taken. A helper holds its own value, so a helper target is verified straight from it.

The answer comes back once the read-back is done, so applying a recipe takes up to verify_sec.

Two recipes interleaving their writes to the same setpoint leave it at whichever landed last, which is nobody’s recipe. So while an application is writing and reading back, it holds every entity it targets, and another application that targets any of them is refused, 409, with which recipe is in the way. Wait for it, then apply.

This is held in the process: an application from the Gateway and one from an --mcp-stdio process beside it are not held off each other. Run one writer, or keep the agent’s recipes and the operators’ on different equipment.

A recipe’s name, description, equipment node and permission are the recipe’s and change in place. What it writes is a version, and a version is never changed. Saving different targets, parameters, a different precondition or verify_sec stores the next version and makes it the current one. Version 3 is exactly what it was the day it was saved, forever, and every application records the version and the parameters it ran with. What went out to the plant last Tuesday is always answerable.

  • Changing only the name, or only the note, makes no version.
  • A save made to a version somebody else has since replaced is refused, 409, rather than quietly throwing their change away. The editor sends the version it was editing.
  • An older version can be applied on purpose: pick it in the apply drawer, or "version": 2. The application says it was version 2.
  • The Versions drawer shows every version with who saved it, their note, and what changed from the one before it, target by target.

Deleting a recipe deletes its versions. Its applications are kept: they are what was sent to the plant, and that history outlives the recipe.

Nobody writes a forty-target recipe by hand when the line is already running grade A. Capture takes the entities you pick and drafts a recipe that sets each back to what it holds right now, in the order you picked them: a number’s value, a switch’s on or off, a select’s option, a valve’s position, a motor’s speed, a loop’s mode and then its setpoint. You name it, trim it, turn the numbers that change per order into parameters, and save.

An entity whose value is not known, or not trusted, is refused, not captured: a reading of quality other than good, unknown, unavailable, a disabled entity. A recipe built from a bad reading writes the bad reading back to the plant, every time somebody applies it.

Operators apply recipes (recipes.apply); admins write them (recipes.write); everybody reads them (recipes.read). Loading grade A is what the operator is there to do. Deciding what grade A is, is an engineer’s call.

A recipe can name a narrower permission of its own: "apply_permission": "recipes.apply.night_setback". Then applying it takes that as well, which the operator role does not hold. Give it to the people who may apply that recipe through their own permission list:

["recipes.read", "recipes.apply", "recipes.apply.night_setback"]

recipes.apply.* grants every recipe’s, and an admin holds everything. A user without it is refused, and the refusal is on the record: a refused application in the recipe’s history, with their name on it, like any other. The same goes through the scene entity.

An agent holds no user’s permissions, so an agent never applies a recipe that names its own permission. It can read it and ask whether it would apply; the answer says why not.

"precondition": "states['input_boolean.line_ready'] == 'on' and float(states['sensor.line_02_die_temp']) < 250"

The automation language, evaluated against the plant as it is when somebody presses Apply, with the parameters as vars. False refuses the recipe, saying so. It is a guard against applying the right numbers at the wrong moment, not an interlock: an interlock lives in the PLC, where it still works when the network does not.

A rule or a script applies one with an apply_recipe step (see AUTOMATIONS.md):

{"type": "apply_recipe", "params": {"recipeId": "grade_a", "params": {"speed": 250}}}

It applies it as the rule, automation:<id>, or the script, script:<id>, with that run’s source on every write, and the application’s parent is the run. The step fails unless every target was written; an unconfirmed value does not fail it, and the trace says NOT confirmed by read-back. A script’s dry run checks the recipe too.

Over MCP, list_recipes shows every recipe with its targets and parameters, validate_recipe asks whether one would apply right now, and apply_recipe applies it, with a required reason. An agent’s writes carry the source mcp, so the MCP write switch applies to every one of them in the gate, and with HOTLOOP_ALLOW_MCP_WRITES off apply_recipe is refused before anything is checked. See MCP.md.

Every recipe is a scene entity, scene.<id>. Its state is when it was last applied (an application that wrote something), as a timestamp, and unknown until it ever has been. Its attributes say how the newest application went: last_result, last_version, last_applied_by, last_application, and unconfirmed when that many values of an applied one were not read back. Its tile has an Apply button. scene.turn_on applies it, with params and version in the data. A rule can trigger on its state, which moves once per application that wrote something.

Deleting a recipe orphans its entity, the way deleting a script does.

Every application, refused ones included, is a row of recipe_applications: the recipe, the version, the parameters, who, from where, why, the call id, what became of every target and whether it was read back. It is opened before the first write and finished after the read-back, so one the process dies in the middle of still says it started. The Gateway’s next start marks it failed, interrupted by restart, pointing at its call id in the write audit. Nothing resumes it.

Each application is one line in the system log under the scene, and one entry in the logbook, kind recipe, next to the writes it made. A recipe’s history keeps its newest 5000 applications.

Every change to a recipe and every application is announced to every process on the database, so a recipe saved on one pod is the one the other applies, and an application an --mcp-stdio process makes moves the Gateway’s scene entity.

Recipes, every version, and every application are in backups, and come back exactly as they were. An application the backup caught in the middle comes back applying and is marked failed on the first start, like a crash. See BACKUP.md.

API.md has every route. In short:

Details Details
GET /api/recipes every recipe, its current content and last application
POST /api/recipes create one, as version 1
POST /api/recipes/capture a draft from what entities hold now
PUT /api/recipes/{id} save; new content is a new version
GET /api/recipes/{id}/versions every version, as saved
POST /api/recipes/{id}/validate would it apply now? nothing written
POST /api/recipes/{id}/apply apply it: 200 applied, 409 refused, 502 partial or failed
GET /api/recipes/{id}/applications every application, refusals included