The HTTP API
Every call the Gateway answers over HTTP, what it needs, what it takes and what it gives back. The web UI uses nothing else: there is no private API behind the screens, so anything the UI can do, a script can do with the same calls and the same permissions.
This file is checked against the code. TestAPIReferenceMatchesRoutes in
internal/api reads every route the server registers and fails the build if one
is missing from the endpoint index, if the index lists one
that does not exist, or if the permission written here is not the one the code
checks. TestAPIReferencePermissionsTable does the same for the
permissions table against internal/auth. So the list of routes
and who may call them is not a description of the API as it once was. The
request and response shapes below were taken from a running Gateway; the tests
do not check those, so if you change a handler, change its section here too.
Deeper background lives with each feature: AUTH.md, ENTITIES.md, EQUIPMENT.md, AUTOMATIONS.md, REPORTS.md, FLEET.md, NOTIFY.md, BACKUP.md and MCP.md. This file is the list; those are the reasons.
Conventions
Section titled “Conventions”Where it lives
Section titled “Where it lives”Everything is on the app’s one HTTP port (HOTLOOP_LISTEN_ADDR, :8080 by
default). Behind a proxy that serves the app under a path, set
HOTLOOP_BASE_PATH and the Gateway strips it, so /hotloop/api/devices and
/api/devices are the same call. The paths below are written without it.
Logging in: a cookie or a bearer token
Section titled “Logging in: a cookie or a bearer token”Every /api/ route needs a session, except logging in, inbound webhooks and
alarm acknowledge links, each of which carries its own proof in the request.
POST /api/auth/login answers with a token and also sets it as the
iiot_session cookie (HttpOnly, SameSite=Lax, Secure when the request came in
over TLS or with X-Forwarded-Proto: https). A browser uses the cookie and
never sees the token. A script sends the token instead:
Authorization: Bearer 3f9c...e1Both are the same session, checked the same way. A session lasts
HOTLOOP_AUTH_SESSION_TTL (12 hours by default). Without one, every /api/
route answers:
HTTP/1.1 401 UnauthorizedWWW-Authenticate: Bearer realm="hotloop"
{"error":"not logged in"}or "the session is invalid or has expired" for a token that was revoked or ran
out, or "this account no longer has access" for an account that was disabled.
With HOTLOOP_AUTH_INSECURE=true there is no login at all: nothing is checked,
every permission below is granted to everyone, and the name recorded against a
write comes from the proxy’s X-EmberNET-User, X-Forwarded-User,
X-Auth-Request-User, X-Remote-User or X-Forwarded-Email header, or
operator if none is set. That is only reasonable behind a NetworkPolicy that
lets nothing but the dashboard proxy reach the pod. See
AUTH.md.
MCP is separate: it has its own bearer token (HOTLOOP_MCP_TOKEN) and never
looks at a session. See MCP.
Permissions
Section titled “Permissions”A logged-in user holds a list of permissions, from their role or set on the account explicitly. A route that needs one the user does not hold answers:
HTTP/1.1 403 Forbidden
{"error":"viewer1 does not have the \"values.write\" permission"}The permissions table says which role holds what.
Errors
Section titled “Errors”Every failure the API itself produces is JSON of one shape:
{"error": "value is outside the tag's configured range: 120 is outside 0..95"}/readyz adds a detail, and a failed service call adds the writes it made.
The message is written for the person reading it: it says what was wrong and,
where there is one, what to do instead. Show it as it is.
| Status | When |
|---|---|
| 400 | The request is wrong: a malformed body, a field that fails validation, an unknown field, a missing parameter, an unknown service |
| 401 | No session, an expired one, or a wrong password on login |
| 403 | The user lacks the permission, or the write gate refused the write on policy: writes switched off, the tag not armed, the entity disabled |
| 404 | The thing named in the path or body does not exist |
| 409 | It already exists, or it is in the wrong state for this: an alarm that is not shelved, a node that still has children, an entity with no known state to toggle, a script that is already running, a recipe that was refused or is already being applied |
| 422 | A value the write gate refused on its merits: out of the tag’s range, off an entity’s step |
| 502 | The device answered, with an error of its own, and the body is its words; or a recipe started and not every target landed, and the body says which went out and which did not |
| 503 | The device is not connected, or the alarm engine is not running |
| 500 | A fault inside the server. The body is internal error (ref …); the server log has the detail under that ref and nothing more, and the log has the whole error under that ref. The one 500 with a real message is a button press whose release did not go out, because that message says exactly what to check |
Anything the API can’t place is a 500, never a 400. A database outage answered as 400 tells the operator they typed something wrong, and they go hunting for a typo that isn’t there.
Two failures do not come from the API and are plain text, not JSON: a path that
matches no route answers 404 page not found, and a known path called with the
wrong method answers 405 method not allowed.
A refused write is never a 500. A plant screen that shows a policy refusal as a server fault teaches operators to ignore errors.
Bodies
Section titled “Bodies”Request bodies are JSON, at most 4 MiB (an inbound webhook, 1 MiB). Unknown fields are refused, not ignored:
{"error":"request body: json: unknown field \"x\""}A misspelled setting that is silently dropped looks exactly like one that worked, so the decoder says no instead.
POST and PUT on devices, tags, alarms, automations, fleet members and
reports replace the whole object: a field left out takes its zero value, and a
boolean left out is false. Read, change, send back. The exceptions are the
entity and equipment PUTs, which change only the fields you send, and device
credentials, which are kept when left out (see below).
Secrets are write-only
Section titled “Secrets are write-only”A credential stored in a device (a broker password, an HTTP header, a password
in the address) is never sent back, to any role, admin included. It shows as
[redacted]:
"config": {"clientId": "docs-api-tankfarm", "password": "[redacted]", "username": "tank"}When you save the device, send the placeholder (or leave the key out) to keep
the stored value, send a new value to replace it, and send "" or null to
clear it. A new device cannot be saved with the placeholder, and a save that
moves the device to a different host with a kept credential is refused, so a
stored password cannot be walked off to a listener of somebody else’s. The full
rules are in AUTH.md. The same goes
for every other credential the Gateway holds: settings show set or
not set, diagnostics scrub credentials out of outbound MCP targets, and nothing in this API
returns a password hash or a session token except the one login hands you.
camelCase, except where it is snake_case
Section titled “camelCase, except where it is snake_case”Most of the API is camelCase (deviceId, pollIntervalMs, createdAt), as it
has been since the first version and as the EmberNET dashboard’s TagValue
contract requires.
The entity, state, service, equipment and label endpoints are snake_case
(entity_id, last_changed, parent_id, label_id). That is on purpose: the entity API uses the common entity, state and service shape, so that anything which already speaks it speaks this, and the equipment tree is the entity model’s area registry, shaped
for the same consumers. Where the two meet, each side keeps its own case: a
device has equipmentId, an entity has equipment_id.
Times, durations and tag keys
Section titled “Times, durations and tag keys”- Timestamps are RFC 3339 in UTC, with fractional seconds when there are
any:
2026-09-27T20:21:58.255869Z. The display timezone setting changes what the UI shows, never what the API returns. - A time that hasn’t happened is left out. A rule that has never run has
no
lastRun, a device never polled has nolastPollorlastGood, an alarm nobody acknowledged has noackedAt, a fleet member never seen has nolastSeen. A missing time field means never. You will never see Go’s zero time,0001-01-01T00:00:00Z, in a response or a stream event. A dozen fields used to send it, and the automations screen showed a rule that had never run as last run 739887 days ago. A test reads every route and every time field in the code to keep it that way. - Sparklines are the exception:
timestampsthere is Unix milliseconds, because it goes straight into a chart. - Durations say their unit in the name:
pollIntervalMs,onDelaySec,minutes,durationMs.uptimein status is a Go duration string (11m13s). - A tag key is
device/tag, the form every?tag=parameter andtagfield takes. Tag names may contain slashes (OPC UA paths, MQTT topics), so only the first slash separates:plc-1/Line/Zone 1is tagLine/Zone 1on deviceplc-1. - A numeric query parameter that is not a number is ignored and the default
is used, so
?limit=abcis?limitunset. - Empty lists are
[], nevernull, in every response and every stream event, at any depth. You don’t need a null check before.length. Anullyou do see is never a list: it’s one of the documented nullable fields, like an entity’sequipment_idor a root node’sparent_id. A list field marked optional can still be left out entirely.
Optional features
Section titled “Optional features”Fleet, notifications, backups and scheduled reports are off unless their
HOTLOOP_*_ENABLED variable is set. With the feature off, its list route
still answers 200, with "configured": false and an empty list, so a screen
can say “not enabled” instead of “nothing here”. Every other route of that
feature answers 400 with an error naming the variable:
{"error":"backups are not enabled (HOTLOOP_BACKUP_ENABLED)"}GET /api/sites does the same when no outbound MCP servers are configured.
Not in this product
Section titled “Not in this product”HotLoop ships as IoT, Edge and Gateway out of one codebase, and a feature a product doesn’t have isn’t switched off, it isn’t in the binary. Its routes still answer, after the same permission check, with a 404 that says so and names both ends, so a script can tell “wrong box” from “wrong URL”:
{"error":"scheduled reports are part of HotLoop Gateway; this is HotLoop IoT","product":"iot","feature":"reports"}Not a 400, because there’s no variable to set that fixes it, and not a 5xx,
because nothing went wrong. GET /api/status says up front which product
answered and which features it has, so a client can ask instead of probing.
Everything that changes something is logged
Section titled “Everything that changes something is logged”Every create, change, delete, write, acknowledgement and login lands in the
system log (GET /api/events) with the user’s name on it, and every write to a
tag, refused or not, lands in the write audit (GET /api/writes). The API does
not have a quiet path.
Permissions
Section titled “Permissions”Who holds what. An account with an explicit permission list holds exactly what
it names instead of its role’s set. * grants everything and resource.*
grants everything under a resource (reports.*). The admin role is *.
| Permission | viewer | operator | admin |
|---|---|---|---|
status.read |
yes | yes | yes |
devices.read |
yes | yes | yes |
devices.write |
no | no | yes |
tags.read |
yes | yes | yes |
tags.write |
no | no | yes |
values.read |
yes | yes | yes |
values.write |
no | yes | yes |
board.write |
no | yes | yes |
entities.read |
yes | yes | yes |
entities.write |
no | no | yes |
helpers.write |
no | yes | yes |
equipment.read |
yes | yes | yes |
equipment.write |
no | no | yes |
alarms.read |
yes | yes | yes |
alarms.write |
no | no | yes |
alarms.ack |
no | yes | yes |
automations.read |
yes | yes | yes |
automations.write |
no | no | yes |
automations.trigger |
no | yes | yes |
scripts.read |
yes | yes | yes |
scripts.run |
no | yes | yes |
scripts.write |
no | no | yes |
recipes.read |
yes | yes | yes |
recipes.apply |
no | yes | yes |
recipes.write |
no | no | yes |
blueprints.read |
yes | yes | yes |
blueprints.apply |
no | yes | yes |
blueprints.write |
no | no | yes |
dashboards.read |
yes | yes | yes |
dashboards.write |
no | no | yes |
fleet.read |
yes | yes | yes |
fleet.write |
no | yes | yes |
fleet.rebirth |
no | yes | yes |
fleet.restart |
no | no | yes |
fleet.update |
no | no | yes |
fleet.config |
no | no | yes |
notify.test |
no | no | yes |
backup.read |
no | no | yes |
backup.run |
no | no | yes |
reports.read |
yes | yes | yes |
reports.run |
no | yes | yes |
reports.write |
no | no | yes |
sites.read |
yes | yes | yes |
settings.read |
yes | yes | yes |
settings.write |
no | no | yes |
events.read |
yes | yes | yes |
logbook.read |
yes | yes | yes |
users.manage |
no | no | yes |
The line is the same everywhere: an operator runs the plant (writes values,
acknowledges alarms, triggers automations, nudges a relay), and only an admin
changes what the plant is configured to be, or does something to the outside
world on the Gateway’s behalf (restarting a relay, pushing its config, sending
a test page, defining a report that emails plant data somewhere). Why each one
sits where it does is in the route comments in internal/api/api.go and in
AUTH.md.
Endpoint index
Section titled “Endpoint index”The permission column says what the route checks. session means any
logged-in user, with no permission beyond that. none means no session either,
and the section says why. (in handler) means the check is made inside the
handler rather than in front of it, and the section says why.
The product column says the smallest HotLoop that has the route: all is IoT,
Edge and Gateway, edge is Edge and Gateway, gateway is the Gateway alone.
Every product registers every route, and checks the same permission on it. On
a product that doesn’t have it, the route answers 404 and says where it lives;
see Not in this product.
| Method | Path | Permission | Product |
|---|---|---|---|
GET |
/healthz |
none | all |
GET |
/readyz |
none | all |
POST |
/api/auth/login |
none | all |
POST |
/api/auth/logout |
session | all |
GET |
/api/auth/session |
session | all |
POST |
/api/auth/password |
session | all |
GET |
/api/users |
users.manage |
all |
POST |
/api/users |
users.manage |
all |
PUT |
/api/users/{username} |
users.manage |
all |
DELETE |
/api/users/{username} |
users.manage |
all |
POST |
/api/users/{username}/password |
users.manage |
all |
GET |
/api/status |
status.read |
all |
GET |
/api/protocols |
status.read |
all |
GET |
/api/diagnostics |
status.read |
all |
GET |
/api/devices |
devices.read |
all |
POST |
/api/devices |
devices.write |
all |
GET |
/api/devices/{id} |
devices.read |
all |
DELETE |
/api/devices/{id} |
devices.write |
all |
POST |
/api/devices/{id}/enabled |
devices.write |
all |
POST |
/api/devices/{id}/browse |
devices.read |
all |
POST |
/api/devices/{id}/import |
tags.write |
all |
GET |
/api/devices/{id}/network |
devices.read |
all |
PUT |
/api/devices/{id}/network |
devices.write |
all |
DELETE |
/api/devices/{id}/network |
devices.write |
all |
GET |
/api/unifi/{id}/networks |
devices.read |
all |
PUT |
/api/unifi/{id}/networks/{network} |
devices.write |
all |
POST |
/api/unifi/{id}/networks/{network}/accept-all |
devices.write |
all |
GET |
/api/unifi/{id}/known |
devices.read |
all |
PUT |
/api/unifi/{id}/networks/{network}/known/{mac} |
devices.write |
all |
DELETE |
/api/unifi/{id}/networks/{network}/known/{mac} |
devices.write |
all |
GET |
/api/unifi/{id}/clients |
devices.read |
all |
GET |
/api/unifi/consoles |
devices.read |
all |
GET |
/api/unifi/{id}/overview |
devices.read |
all |
GET |
/api/unifi/{id}/topology |
devices.read |
all |
GET |
/api/unifi/{id}/devices |
devices.read |
all |
GET |
/api/unifi/{id}/wifi |
devices.read |
all |
GET |
/api/unifi/{id}/events |
devices.read |
all |
POST |
/api/unifi/{id}/starter-alarms |
alarms.write |
all |
GET |
/api/discovery |
devices.read |
all |
GET |
/api/discovery/{source}/{key} |
devices.read |
all |
POST |
/api/discovery/{source}/{key}/accept |
devices.write |
all |
POST |
/api/discovery/{source}/{key}/ignore |
devices.write |
all |
POST |
/api/discovery/{source}/{key}/unignore |
devices.write |
all |
POST |
/api/discovery/{source}/{key}/review/apply |
devices.write |
all |
POST |
/api/discovery/{source}/{key}/review/reject |
devices.write |
all |
GET |
/api/network-discovery |
devices.read |
all |
GET |
/api/network-discovery/{key} |
devices.read |
all |
POST |
/api/network-discovery/{key}/accept |
devices.write |
all |
POST |
/api/network-discovery/{key}/ignore |
devices.write |
all |
POST |
/api/network-discovery/{key}/unignore |
devices.write |
all |
POST |
/api/network-discovery/probe |
devices.write |
all |
GET |
/api/broker/logins |
users.manage |
all |
POST |
/api/broker/logins |
users.manage |
all |
PUT |
/api/broker/logins/{name} |
users.manage |
all |
DELETE |
/api/broker/logins/{name} |
users.manage |
all |
GET |
/api/mqtt/brokers |
devices.read |
all |
POST |
/api/mqtt/brokers |
devices.write |
all |
PUT |
/api/mqtt/brokers/{id} |
devices.write |
all |
DELETE |
/api/mqtt/brokers/{id} |
devices.write |
all |
GET |
/api/tags |
tags.read |
all |
POST |
/api/tags |
tags.write |
all |
DELETE |
/api/tags/{device}/{name} |
tags.write |
all |
GET |
/api/values |
values.read |
all |
GET |
/api/sparkline |
values.read |
all |
GET |
/api/board |
values.read |
all |
PUT |
/api/board |
board.write |
all |
POST |
/api/write |
values.write |
all |
GET |
/api/history |
values.read |
all |
GET |
/api/writes |
values.read |
all |
GET |
/api/entities |
entities.read |
all |
GET |
/api/entities/{entity_id} |
entities.read |
all |
GET |
/api/entities/{entity_id}/image |
entities.read |
all |
PUT |
/api/entities/{entity_id} |
entities.write |
all |
POST |
/api/entities |
entities.write |
all |
DELETE |
/api/entities/{entity_id} |
entities.write |
all |
GET |
/api/states |
entities.read |
all |
GET |
/api/states/{entity_id} |
entities.read |
all |
POST |
/api/services/{domain}/{service} |
values.write, alarms.ack, helpers.write, scripts.run, recipes.apply (in handler) |
all |
GET |
/api/helpers |
entities.read |
all |
POST |
/api/helpers |
entities.write |
all |
GET |
/api/helpers/{entity_id} |
entities.read |
all |
PUT |
/api/helpers/{entity_id} |
entities.write |
all |
DELETE |
/api/helpers/{entity_id} |
entities.write |
all |
GET |
/api/equipment |
equipment.read |
all |
POST |
/api/equipment |
equipment.write |
all |
GET |
/api/equipment/{id} |
equipment.read |
all |
PUT |
/api/equipment/{id} |
equipment.write |
all |
DELETE |
/api/equipment/{id} |
equipment.write |
all |
GET |
/api/labels |
equipment.read |
all |
POST |
/api/labels |
equipment.write |
all |
GET |
/api/labels/{label_id} |
equipment.read |
all |
PUT |
/api/labels/{label_id} |
equipment.write |
all |
DELETE |
/api/labels/{label_id} |
equipment.write |
all |
PUT |
/api/devices/{id}/labels |
equipment.write |
all |
GET |
/api/alarms |
alarms.read |
all |
POST |
/api/alarms |
alarms.write |
all |
DELETE |
/api/alarms/{id} |
alarms.write |
all |
POST |
/api/alarms/{id}/ack |
alarms.ack |
all |
POST |
/api/alarms/{id}/shelve |
alarms.ack |
all |
POST |
/api/alarms/{id}/unshelve |
alarms.ack |
all |
GET |
/api/alarms/events |
alarms.read |
all |
GET |
/api/automations |
automations.read |
all |
POST |
/api/automations |
automations.write |
all |
POST |
/api/automations/validate |
automations.write |
all |
DELETE |
/api/automations/{id} |
automations.write |
all |
POST |
/api/automations/{id}/enabled |
automations.write |
all |
POST |
/api/automations/{id}/trigger |
automations.trigger |
all |
GET |
/api/runs |
automations.read |
all |
POST |
/api/automations/{id}/blueprint-upgrade |
blueprints.apply |
all |
GET |
/api/blueprints |
blueprints.read |
all |
POST |
/api/blueprints |
blueprints.write |
all |
GET |
/api/blueprints/{id} |
blueprints.read |
all |
PUT |
/api/blueprints/{id} |
blueprints.write |
all |
DELETE |
/api/blueprints/{id} |
blueprints.write |
all |
POST |
/api/blueprints/{id}/instantiate |
blueprints.apply |
all |
GET |
/api/dashboards |
dashboards.read |
all |
POST |
/api/dashboards |
dashboards.write |
all |
GET |
/api/dashboards/{id} |
dashboards.read |
all |
PUT |
/api/dashboards/{id} |
dashboards.write |
all |
DELETE |
/api/dashboards/{id} |
dashboards.write |
all |
GET |
/api/scripts |
scripts.read |
all |
POST |
/api/scripts |
scripts.write |
all |
POST |
/api/scripts/validate |
scripts.write |
all |
GET |
/api/scripts/{id} |
scripts.read |
all |
PUT |
/api/scripts/{id} |
scripts.write |
all |
DELETE |
/api/scripts/{id} |
scripts.write |
all |
POST |
/api/scripts/{id}/run |
scripts.run |
all |
POST |
/api/scripts/{id}/stop |
scripts.run |
all |
GET |
/api/scripts/{id}/runs |
scripts.read |
all |
GET |
/api/recipes |
recipes.read |
all |
POST |
/api/recipes |
recipes.write |
all |
POST |
/api/recipes/capture |
recipes.write |
all |
GET |
/api/recipes/{id} |
recipes.read |
all |
PUT |
/api/recipes/{id} |
recipes.write |
all |
DELETE |
/api/recipes/{id} |
recipes.write |
all |
GET |
/api/recipes/{id}/versions |
recipes.read |
all |
GET |
/api/recipes/{id}/versions/{version} |
recipes.read |
all |
POST |
/api/recipes/{id}/validate |
recipes.apply |
all |
POST |
/api/recipes/{id}/apply |
recipes.apply |
all |
GET |
/api/recipes/{id}/applications |
recipes.read |
all |
POST |
/api/webhook/{path} |
none | all |
GET |
/api/fleet |
fleet.read |
gateway |
POST |
/api/fleet |
fleet.write |
gateway |
DELETE |
/api/fleet/{id} |
fleet.write |
gateway |
POST |
/api/fleet/{id}/rebirth |
fleet.rebirth |
gateway |
POST |
/api/fleet/{id}/restart |
fleet.restart |
gateway |
POST |
/api/fleet/{id}/update |
fleet.update |
gateway |
POST |
/api/fleet/{id}/config |
fleet.config |
gateway |
DELETE |
/api/fleet/{id}/config |
fleet.config |
gateway |
POST |
/api/notify/test |
notify.test |
all |
POST |
/api/notify/ack/{token} |
none | all |
GET |
/api/backup |
backup.read |
all |
POST |
/api/backup |
backup.run |
all |
GET |
/api/reports |
reports.read |
gateway |
POST |
/api/reports |
reports.write |
gateway |
GET |
/api/reports/{id} |
reports.read |
gateway |
PUT |
/api/reports/{id} |
reports.write |
gateway |
DELETE |
/api/reports/{id} |
reports.write |
gateway |
POST |
/api/reports/{id}/run |
reports.run |
gateway |
GET |
/api/reports/{id}/runs |
reports.read |
gateway |
GET |
/api/reports/{id}/runs/{run}/download |
reports.read |
gateway |
GET |
/api/sites |
sites.read |
gateway |
GET |
/api/settings |
settings.read |
all |
PUT |
/api/settings |
settings.write |
all |
GET |
/api/historian/targets |
settings.read |
all |
POST |
/api/historian/targets |
settings.write |
all |
PUT |
/api/historian/targets/{id} |
settings.write |
all |
DELETE |
/api/historian/targets/{id} |
settings.write |
all |
GET |
/api/events |
events.read |
all |
GET |
/api/logbook |
logbook.read |
all |
GET |
/api/stream |
status.read |
all |
| any | /mcp |
MCP token | all |
| any | /mcp/ |
MCP token | all |
GET |
/static/ |
none | all |
| any | / |
none | all |
Health
Section titled “Health”For the kubelet and load balancers. Neither needs a session: a probe has no account, and neither says anything about the plant.
GET /healthz
Section titled “GET /healthz”Liveness. 200 with the text ok as long as the process is serving HTTP.
It does not touch the database, on purpose: a database outage should take the
pod out of rotation, not get it restarted in a loop.
GET /readyz
Section titled “GET /readyz”Readiness. Pings the database with a 5 second budget.
{"status":"ready"}503 when it cannot reach it, with the reason in detail (the shape, from the
handler; the reason text is whatever the database driver said):
{"error":"not ready","detail":"<why the database did not answer>"}An app that cannot reach its historian should not be taking traffic.
Authentication
Section titled “Authentication”See AUTH.md for accounts, roles and the first admin.
POST /api/auth/login
Section titled “POST /api/auth/login”No session needed, by construction.
| Field | Type | Details |
|---|---|---|
username |
string | Required. Case and surrounding spaces are ignored |
password |
string | Required |
{"expiresAt":"2026-09-28T08:21:00.571794Z","permissions":["*"],"role":"admin","token":"<token>","username":"admin"}Also sets the iiot_session cookie to the same token. 401
"invalid username or password" for a wrong name, a wrong password or a
disabled account alike, and it takes as long either way: which of the three it
was is not something to tell a stranger.
POST /api/auth/logout
Section titled “POST /api/auth/logout”Ends the session the request came with (cookie or bearer) and clears the
cookie. No body. {"status":"logged out"}.
GET /api/auth/session
Section titled “GET /api/auth/session”Who am I. For the UI’s boot sequence.
{"authenticated":true,"permissions":["*"],"role":"admin","username":"admin"}With auth switched off (HOTLOOP_AUTH_INSECURE), it answers
{"authenticated":false,"insecure":true} instead of a 401, so the UI can say
why there is no login rather than guess.
POST /api/auth/password
Section titled “POST /api/auth/password”Change your own password. No permission: knowing your current password is the credential.
| Field | Type | Details |
|---|---|---|
currentPassword |
string | Required |
newPassword |
string | Required, at least 8 characters |
{"status":"password changed; log in again"}. Every session the account
holds ends, including this one, and the cookie is cleared: a password change
that leaves the old sessions working is a change in name only. 401 if the
current password is wrong, 400 if the new one is too short.
All admin-only (users.manage). Password hashes never appear in a response;
the field has no JSON name.
GET /api/users
Section titled “GET /api/users”[{"username":"admin","role":"admin","disabled":false,"createdAt":"2026-09-27T20:19:28.036228Z","updatedAt":"2026-09-27T20:19:28.036228Z","lastLogin":"2026-09-27T20:21:00.575455Z"}]permissions appears only on an account that has an explicit list.
POST /api/users
Section titled “POST /api/users”| Field | Type | Details |
|---|---|---|
username |
string | Required. Lowercase letters, digits, dot, underscore, hyphen, starting with a letter or digit, 2 to 64 characters |
password |
string | Required, at least 8 characters |
role |
string | admin, operator or viewer. Defaults to viewer |
permissions |
string[] | Optional. Replaces the role’s set entirely |
Answers the new user. 400 for a bad name, role or password, 409 if the name
is taken.
PUT /api/users/{username}
Section titled “PUT /api/users/{username}”| Field | Type | Details |
|---|---|---|
role |
string | Required unless permissions is set |
permissions |
string[] | Optional. Empty means “use the role” |
disabled |
bool | true disables the account and ends every session it holds |
Answers the updated user. 400 for an unknown role, or for disabling the last
enabled admin: that is a lockout with nobody left to undo it. 404 for an
unknown user.
DELETE /api/users/{username}
Section titled “DELETE /api/users/{username}”{"status":"deleted"}. 400 for your own account or the last enabled admin,
404 for an unknown user.
POST /api/users/{username}/password
Section titled “POST /api/users/{username}/password”An admin sets somebody else’s password without knowing the old one.
{"password": "..."}, at least 8 characters. Answers
{"status":"password reset"} and ends every session that account holds.
Status and diagnostics
Section titled “Status and diagnostics”GET /api/status
Section titled “GET /api/status”The header bar: runtime counters, the alarm summary, the plant’s name, and
which product this is. product is iot, edge or gateway, the same as
hotloop --product. features is which of the features that differ between
products this one has: edge-publish, fleet, opcua-server, reports and
sites in a Gateway, the first and third in Edge, none in IoT. version is the
build, and imageDigest is the digest of the image it was started from, which
a version number can’t tell you on a box that follows :stable. It is there
only when the supervisor said (the Quadlet units do, before every start; a bare
binary and a Helm install can’t) and is left out otherwise.
{ "alarms": {"critical":0,"high":1,"medium":0,"low":0,"unacked":0,"total":1}, "features": ["edge-publish","fleet","opcua-server","reports","sites"], "plantName": "Demo Plant", "product": "gateway", "runtime": { "startedAt": "2026-09-27T20:19:28.06635851Z", "uptime": "11m13s", "devices": 2, "connected": 2, "degraded": 0, "disconnected": 0, "tags": 5, "cachedTags": 5, "writesOk": 4, "writesRefused": 3, "bus": {"subscribers":4,"published":653,"dropped":0}, "historian": {"queued":1085,"written":1085,"filtered":329,"dropped":0,"failed":0,"queueLen":0,"queueCap":50000}, "writePolicy": {"allowWrites":true,"allowMcpWrites":false,"confirmWrites":true}, "protocols": ["ethernet-ip","http","modbus","mqtt","mtconnect","opcua","s7comm"] }, "siteId": "docs", "version": "4.18.10"}GET /api/protocols
Section titled “GET /api/protocols”Every protocol the Gateway knows, and whether this build has its driver.
[{"id":"opcua","name":"OPC UA","description":"The universal one. ...","transport":"opc.tcp binary","defaultPort":4840,"writable":true,"browsable":true,"deviceTypes":["plc","cnc","robot","ipc","hmi","historian","gateway"],"library":"github.com/gopcua/opcua","available":true}]GET /api/diagnostics
Section titled “GET /api/diagnostics”What the diagnostics screen shows: database pool, historian queue and history size, the tag bus, the entity engine, the logbook’s recorder, the runtime, MCP (server and outbound connections) and the automation engine’s armed rules, webhook paths and state-change queue.
{ "automations": {"armed":[],"stateChangesDropped":0,"stateChangesPending":0,"webhooks":[]}, "bus": {"subscribers":4,"published":654,"dropped":0}, "database": {"type":"postgres","totalConns":3,"idleConns":3,"acquiredConns":0,"maxConns":10,"historyMode":"timescale","timescaleVersion":"2.30.1"}, "entities": {"entities":8,"droppedBatches":0,"pendingChanges":0,"droppedChanges":0}, "historian": {"queued":1087,"written":1085,"filtered":329,"dropped":0,"failed":0,"queueLen":0,"queueCap":50000}, "historyStats": {"mode":"timescale","rows":996,"distinctTags":5,"oldest":"2026-09-27T20:21:38.609853Z","newest":"2026-09-27T20:30:39.507987Z","sizeBytes":73728}, "logbook": {"recording":true,"retentionDays":90,"queued":0,"dropped":0,"written":212,"suppressed":36,"writeErrors":0}, "mcp": {"outbound":[],"path":"/mcp","serverEnabled":true,"tokenSet":true}, "runtime": {"...": "the same block as /api/status"}}With outbound MCP servers configured, outbound lists each one:
{"name":"plant-a","target":"http://localhost:8080/mcp","connected":true,"tools":["ack_alarm","call_service","...","write_tag"],"connectedSince":"2026-09-27T20:45:50Z"}Any credential in an outbound server’s target or lastError is scrubbed,
because a URL or a command line can carry a token and every role can read
this. The MCP token itself is only ever tokenSet.
Devices
Section titled “Devices”A device is one connection over one protocol. What goes in config and
address depends on the protocol: see PROTOCOLS.md.
GET /api/devices
Section titled “GET /api/devices”Every device, with its live connection state. Credentials are [redacted].
[{"id":"tank-farm","name":"Tank farm","protocol":"mqtt","address":"base-mqtt:1883","enabled":true, "site":"Demo Plant","area":"Utilities","equipmentId":4, "config":{"clientId":"docs-api-tankfarm","password":"[redacted]","topics":["docs-api-example/#"],"username":"tank","writeTopic":"docs-api-example/{tag}/set"}, "createdAt":"2026-09-27T20:21:01.637319Z","updatedAt":"2026-09-27T20:21:01.637319Z", "state":"connected","lastPoll":"2026-09-27T20:21:39.412Z", "latencyMs":0.002,"tagCount":3,"pollCount":1}]state is connected, connecting, degraded (up, but polls failing),
disconnected or disabled. lastError, errorCount and the rest appear
when there is something to say.
POST /api/devices
Section titled “POST /api/devices”Creates the device if the id is new, replaces it if not. There is no PUT.
| Field | Type | Details |
|---|---|---|
id |
string | Required. 1 to 64 letters, digits, ., _, -, starting alphanumeric. It ends up in URLs, MQTT topics and MCP arguments |
name |
string | Required |
protocol |
string | Required. One this build has a driver for (GET /api/protocols) |
address |
string | Required. Host and port, an opc.tcp:// or http:// URL: the protocol decides |
enabled |
bool | Left out is false |
description |
string | |
pollIntervalMs, timeoutMs |
int | 0 takes the runtime default. The poll interval is floored at HOTLOOP_MIN_POLL_INTERVAL |
site, area, line |
string | Places the device on the equipment tree by name, creating nodes as needed |
equipmentId |
int | Places it on a tree node instead; site, area and line then come from the node. See EQUIPMENT.md |
config |
object | The protocol’s own options. Credentials follow the write-only rules |
Runtime fields (state, lastPoll, tagCount and so on) are read-only;
send them back and they are ignored. Answers the saved device, redacted. 400
for a protocol with no driver, a failed validation or a credential rule; the
runtime reloads with it straight away.
{"error":"no driver for protocol \"profibus\"; this build speaks: ethernet-ip, http, modbus, mqtt, mtconnect, opcua, s7comm"}GET /api/devices/{id}
Section titled “GET /api/devices/{id}”The device, its tags and its labels.
{"device":{"id":"tank-farm","name":"Tank farm","...":"..."},"labels":["critical"],"tags":[{"id":3,"deviceId":"tank-farm","name":"level","...":"..."}]}tags is [] for a device with none. 404 for an unknown id.
DELETE /api/devices/{id}
Section titled “DELETE /api/devices/{id}”Deletes the device, its tags and their entities. Its history is kept.
{"deleted":"tank-farm"}, or 404.
POST /api/devices/{id}/enabled
Section titled “POST /api/devices/{id}/enabled”{"enabled": false} stops polling without deleting anything. Answers
{"enabled":false,"id":"tank-farm"}. 404 for an unknown id.
POST /api/devices/{id}/browse
Section titled “POST /api/devices/{id}/browse”Asks the device what points it has. Needs only devices.read, because it
reads. No body.
{"supported":true,"tags":[{"name":"Plant_Boiler_Temperature","address":"nsu=http://plant/;s=Temperature","dataType":"float","description":"Temperature","writable":true}]}A protocol that cannot enumerate its points (Modbus, S7comm, HTTP) answers
200 with {"supported":false,"reason":"..."}: that is a fact about the
protocol, not an error. 503 if the device is not connected. writable here
is what the device says about the point, not whether the Gateway will write
it.
POST /api/devices/{id}/import
Section titled “POST /api/devices/{id}/import”Turns browse results into configured tags. Needs tags.write, because it
creates tags.
{"tags":[{"name":"Temperature","address":"nsu=http://emberburn/;s=Temperature","dataType":"float","unit":"C"}]}Each entry takes name, address, dataType, unit, description,
deviceClass and writable (the browse result as it came). Answers
{"inserted":2,"updated":0}. Every imported tag is read-only, whatever
the device said: a discovered point is not a point anybody has decided may be
commanded, and defaulting the other way would arm a few thousand of them at
once. Arm the ones you mean with POST /api/tags. 404 for an unknown device.
UniFi: OT networks, clients, and where a device is on the network
Section titled “UniFi: OT networks, clients, and where a device is on the network”HotLoop’s own record about UniFi sites. See UNIFI.md. {id} under
/api/unifi is a UniFi device (one site on one console); {network} is the
console’s own ID for a network, as GET .../networks gives it; {mac} takes
any usual spelling and is kept lower case with colons.
None of these ask the console anything. They read the driver’s last read
of it, so a screen left open costs the console nothing. That also means they
answer 503 until the device has been read once, and always in an
--mcp-stdio helper, which never logs in to a console. None of them writes to
a console either.
A change that makes or removes an alarm says so in change:
{"alarmsCreated":["unifi-unknown-3f2a9c01b7de"]}. Those are ordinary alarm
definitions, under /api/alarms.
GET /api/devices/{id}/network
Section titled “GET /api/devices/{id}/network”Where a HotLoop device (a PLC, a meter) is on the network. Linked:
{"deviceId":"plc-07","link":{"deviceId":"plc-07","consoleId":"plant-net","mac":"02:48:4c:00:00:07","confirmedBy":"hana","confirmedAt":"2026-09-28T18:02:05Z"}, "consoleName":"Plant network","client":{"mac":"02:48:4c:00:00:07","ip":"10.77.0.7","switchMac":"00:27:22:e2:1d:6f","switchName":"USW Lite 8 PoE","port":5,"portUp":false,"online":false,"lastSeen":"2026-09-28T18:01:40Z","lastSeenSec":25,"why":"its switch port 5 has no link","...":"..."}, "readAt":"2026-09-28T18:02:05Z","summary":"on USW Lite 8 PoE port 5, link down; the console last saw it 25s ago; off the network: its switch port 5 has no link","proposals":[]}Not linked, link is null and proposals lists the clients any console
HotLoop reads has at the device’s IP, newest first. A proposal is only that:
the console’s IPs lag, so nothing is linked until an admin confirms one.
PUT /api/devices/{id}/network
Section titled “PUT /api/devices/{id}/network”{"consoleId":"plant-net","mac":"02:48:4c:00:00:07"} confirms which client the
device is. From then on the MAC is the key and the IP is never trusted again.
The client gets tags (c.<mac>.* on the console) and an alarm, “PLC-07 off the
network”, priority 1 with a 120 s on-delay so a reboot doesn’t page anybody.
Answers {"link":{...},"change":{...}}. 400 if consoleId isn’t a UniFi
device or the MAC isn’t one, 404 for an unknown device.
DELETE /api/devices/{id}/network
Section titled “DELETE /api/devices/{id}/network”Removes the link, and the alarm HotLoop made for it unless the client is still
watched from a known list. 404 if there was no link.
GET /api/unifi/{id}/networks
Section titled “GET /api/unifi/{id}/networks”The site’s networks as the console has them, with HotLoop’s record: ot,
known (how many devices are on its list), clients (on it now) and
unknown (on it now and not on the list). A network HotLoop has a record of
that the console no longer has comes back with "onConsole":false.
[{"networkId":"6abb23d25a95f2636a5df29c","name":"SCADA","purpose":"corporate","vlan":122,"subnet":"10.122.0.1/24","onConsole":true,"ot":true,"clients":2,"known":1,"unknown":[{"mac":"02:9b:b1:69:88:f2","ip":"10.122.0.66","switchName":"USW Lite 8 PoE","port":6,"...":"..."}],"changedBy":"hana"}]PUT /api/unifi/{id}/networks/{network}
Section titled “PUT /api/unifi/{id}/networks/{network}”{"ot": true} marks a network OT. Anything on it that isn’t on its known list
counts in n.<network>.unknown and names itself in .unknown_last, and HotLoop
makes the alarm “Unknown device on SCADA”, priority 1. {"ot": false} unmarks
it and removes that alarm; the known list is kept. 400 for a WAN or a network
the console doesn’t have.
POST /api/unifi/{id}/networks/{network}/accept-all
Section titled “POST /api/unifi/{id}/networks/{network}/accept-all”Accepts everything on the network now, as it is: the one click after marking
it. Devices already on the list keep their name and watch. No body. Answers
{"accepted":[{known device}...]}, the ones it added.
GET /api/unifi/{id}/known
Section titled “GET /api/unifi/{id}/known”Every known device on the site’s networks:
[{"deviceId":"plant-net","networkId":"...","mac":"...","name":"PLC-07","watch":true,"acceptedBy":"hana","acceptedAt":"..."}].
PUT /api/unifi/{id}/networks/{network}/known/{mac}
Section titled “PUT /api/unifi/{id}/networks/{network}/known/{mac}”{"name":"PLC-07","watch":true} accepts a device onto the network, or changes
its name or watch. watch gives it tags and the offline alarm; a device that’s
allowed on but comes and goes (a maintenance laptop) is accepted without it.
400 if the network was never marked OT.
DELETE /api/unifi/{id}/networks/{network}/known/{mac}
Section titled “DELETE /api/unifi/{id}/networks/{network}/known/{mac}”Takes a device off the list. If it’s on the network, it’s unknown again on the next read.
GET /api/unifi/{id}/clients
Section titled “GET /api/unifi/{id}/clients”Every client the console lists, plus every watched one it doesn’t, each with
where it is, whether it’s online, and ot, known, watched, linkedTo
and a one-line summary. ?q= narrows it to a MAC, IP or name.
GET /api/unifi/consoles
Section titled “GET /api/unifi/consoles”Every UniFi device HotLoop has, running or not, for the UniFi screen’s picker:
[{"id":"plant-net","name":"Plant network","address":"https://...","enabled":true,"running":true,"site":"default","version":"10.6.106","readAt":"...","devices":4,"online":4,"clients":12,"failover":false,"eventsLive":true}].
A console this process hasn’t read says why in why.
GET /api/unifi/{id}/overview
Section titled “GET /api/unifi/{id}/overview”The Overview card: device and client counts (a client counts when HotLoop
believes it is on the network, the test the n.<id>.clients tags use, not
merely when the console lists it), clients per network, the console’s subsystem
health, and for each gateway its WAN strip, gateways[]: one wans[] entry
per WAN with alive (the gateway’s own health checks, never the WAN block’s
up), known, active, backup, latency and availability. failover is
traffic on a WAN that isn’t the first, down is no WAN alive, and since is
the first read HotLoop saw traffic on the WAN it is on now, which is when
HotLoop noticed, not when the gateway moved. events is the state of the
events websocket (live, error, reconnects) and the newest ten events.
GET /api/unifi/{id}/topology
Section titled “GET /api/unifi/{id}/topology”The map. nodes[] are the adopted devices with their place on the grid (x,
y, layer: left to right from the gateway, laid out the same way for the
same read), edges[] the cables between them from the console’s own
downlink_table, wired[] and wifi[] the clients hanging off each switch
port and each AP and SSID. A device nothing lists as plugged into anything is
drawn on its own with noUplink set, not hung off the gateway on a guess. A
device of another UniFi console HotLoop reads that this console lists as a
client is stitched in as a node with external set and an edge with
stitched. feeds is, by device MAC, the HotLoop devices linked to a client
downstream of it, with their equipmentId for the Plant tree, and links
those same devices by client MAC.
GET /api/unifi/{id}/devices
Section titled “GET /api/unifi/{id}/devices”Every adopted device with its numbers and its ports[] for the faceplate:
link, speed, PoE mode and draw, the clients learned on the port, the device
plugged into it, and dropped for a port that was up at an earlier read and is
down now. A device the console has lost reports no numbers and no port links,
because what it said last isn’t current.
GET /api/unifi/{id}/wifi
Section titled “GET /api/unifi/{id}/wifi”The site’s SSIDs with enabled, the network they sit on and how many clients
are on each. When the console won’t show this account its wireless networks,
error says why and wlans is empty.
GET /api/unifi/{id}/events
Section titled “GET /api/unifi/{id}/events”The newest events the console has reported since HotLoop connected, newest
first (?limit= up to 200, default 50), and the state of the events websocket.
Each event has the controller’s key (EVT_SW_Lost_Contact), its own message,
severity (warning when the controller flags it as something going wrong) and
the device, client and port it is about. They are also in the logbook, filed
under the switch or access point they are about (see
UNIFI.md).
POST /api/unifi/{id}/starter-alarms
Section titled “POST /api/unifi/{id}/starter-alarms”Makes the usual alarms for the site from what the console has now, as ordinary
alarm definitions under /api/alarms: every adopted device offline (priority 2,
60 s on-delay), every uplink port down (priority 1, 10 s), a gateway with two
or more WANs running on a backup one (priority 2, 30 s). The body is optional.
{"poeLimitW": 25} adds a priority 3 alarm on every PoE port’s poe_w draw
over that many watts, 30 s on-delay; the console doesn’t say what a port’s
budget is, so HotLoop doesn’t guess one. A negative limit is a 400.
{"created":[{"id":"unifi-devoffline-9c1e2a77b3d0","name":"USW Lite 8 PoE offline","kind":"offline","tag":"d.002722e2e633.online","priority":2}], "kept":[]}kept are alarms the set calls for that were already there: its own from an
earlier press, or someone’s own alarm on the same tag. So it is safe to press
again after adopting more gear, and it only adds the new gear. The OT-network
and off-the-network alarms are not part of it; they come with marking a
network OT and watching a device. 503 until the console has been read once.
MQTT discovery
Section titled “MQTT discovery”Devices that announced themselves over MQTT, and what a person decided about
each. See DISCOVERY.md. Discovery listens on the embedded
broker when it runs, and on every broker set up under /api/mqtt/brokers. With
neither, sources is empty. In an --mcp-stdio helper, which never listens,
GET /api/discovery answers {"running":false,"sources":[],"devices":[]} and
every other route 503.
{source} is the broker a device announced on (embedded for HotLoop’s own,
otherwise the broker’s id);
{key} is what tied its announcements together: its first identifier, its MAC
as mac:aa:bb:cc:dd:ee:ff, or its node id. URL-encode it; identifiers carry
characters a path doesn’t.
None of these arm anything. Accepting a device makes its points armable,
one at a time, through POST /api/tags like every other point.
GET /api/discovery
Section titled “GET /api/discovery”Every source, with what it has seen and refused, and every discovered device:
{"running":true,"sources":[{"id":"embedded","name":"the embedded broker","prefix":"homeassistant","listening":true,"connected":true, "received":42,"dropped":0,"refused":0,"problems":[],"autoCreate":true,"anonymousAllowed":false, "external":false,"listenOnly":false,"enabled":true}], "devices":[{"source":"embedded","deviceKey":"plug-0001","deviceId":"kitchen-plug","status":"discovered", "device":{"identifiers":["plug-0001"],"name":"Kitchen Plug","manufacturer":"Acme","model":"P1","swVersion":"1.0.0"}, "origin":{"name":"test-publisher","swVersion":"0.1"},"firstSeen":"2026-10-02T09:12:04Z","lastSeen":"2026-10-02T09:12:04Z", "components":[{"key":"plug-0001-relay","component":"switch","name":"Relay","configTopic":"homeassistant/switch/plug-0001/relay/config", "stateTopic":"plug/relay","commandTopic":"plug/relay/set","commandTopics":["plug/relay/set"],"tag":"relay", "unsupported":[],"unknownKeys":[],"flags":[]}]}]}A source with external true is a broker set up under /api/mqtt/brokers,
and broker says where it is. autoCreate false is a source whose devices
wait in the inbox: listed discovered with no deviceId, nothing made and
nothing reading until somebody accepts one. listenOnly is a broker HotLoop
never publishes on. lastError says why a source isn’t connected, a password
that can’t be opened included.
status is discovered, accepted or ignored. Per component,
unsupported lists what HotLoop can’t use and why (“value template not
supported: filter regex_findall_index is not supported at column 12”),
unknownKeys the config keys the format doesn’t define, commandTopics every
topic its commands can go to (a fan’s speed topic as well as its main one),
and flags what a person should look at before accepting: a command topic
outside the topics the component reads, inside the discovery prefix, or
claimed by another device,
and a config that asks for retained commands. withdrawnAt is set on a
component the publisher withdrew. suggestedEquipmentId is the tree node the
device’s suggested_area names, when exactly one node has that name; it only
pre-fills the accept dialog. On the embedded broker, announcedBy and
announcedVia are the broker login and client id that published its latest
config, and acceptedLogin the login it was accepted from. An accepted device
whose announcement changed, or that a different login announced, has review:
{"hash":"9a1c...","summary":["plug-0001-power: unit changed"], "changes":[{"key":"plug-0001-power","kind":"changed","fields":[{"field":"unit","running":"W","announced":"kW"}]}]}GET /api/discovery/{source}/{key}
Section titled “GET /api/discovery/{source}/{key}”One device, the same shape. 404 for one HotLoop hasn’t seen.
POST /api/discovery/{source}/{key}/accept
Section titled “POST /api/discovery/{source}/{key}/accept”Accepts a device. Optional body {"equipmentId": 12} places it on the tree in
the same step. Arms nothing. Answers the device. 400 for an ignored one.
POST /api/discovery/{source}/{key}/ignore
Section titled “POST /api/discovery/{source}/{key}/ignore”Drops the device: its HotLoop device, tags and entities go (its history
stays), and it stays ignored when it announces again. Answers the device.
DELETE /api/devices/{id} on a discovered device does the same, because a
device that keeps announcing would just come back.
POST /api/discovery/{source}/{key}/unignore
Section titled “POST /api/discovery/{source}/{key}/unignore”Brings an ignored device back as discovered, from what it last announced.
400 if it isn’t ignored.
POST /api/discovery/{source}/{key}/review/apply
Section titled “POST /api/discovery/{source}/{key}/review/apply”Makes an accepted device run what it announced. Whether each point is armed
stays as it was. 400 when nothing is waiting for review.
POST /api/discovery/{source}/{key}/review/reject
Section titled “POST /api/discovery/{source}/{key}/review/reject”Keeps the accepted config running and stops offering this change. A
different change later is offered again. 400 when nothing is waiting.
A review can be about who announced it: a change with "kind":"publisher"
and the summary line “announced by login garage-node; accepted from
kitchen-node”. Applying it makes that login the one the device is accepted
from.
Broker logins
Section titled “Broker logins”The embedded broker’s logins made in HotLoop, each with the topics it may
publish and subscribe to. See BROKER.md. A login is a key to the
whole plant’s broker, so these are users.manage, an admin’s. Nothing here
ever answers with a password or its hash. Each change goes on the log and holds
from the client’s next publish; nobody has to reconnect.
GET /api/broker/logins
Section titled “GET /api/broker/logins”{"running":true,"anonymous":false,"prefix":"homeassistant","envLogins":["plant"], "profiles":[{"name":"shelly","about":"a Shelly Gen2 or later; name the login after its MQTT topic prefix", "publish":["%u/#","shellies/announce"],"subscribe":["%u/#","shellies/command","%p/status"]}], "logins":[{"name":"kitchen-node","profile":"esphome","publish":["%u/#","%p/+/%u/+/config","esphome/discover/%u"], "subscribe":["%u/#","%p/status","esphome/discover"],"createdBy":"admin","createdAt":"2026-10-02T09:12:04Z", "updatedAt":"2026-10-02T09:12:04Z","resolvedPublish":["kitchen-node/#","homeassistant/+/kitchen-node/+/config", "esphome/discover/kitchen-node"],"resolvedSubscribe":["kitchen-node/#","homeassistant/status","esphome/discover"]}]}running is false where this process doesn’t run the broker; the logins are
still kept, and the broker reads them when it starts. envLogins are the names
from HOTLOOP_BROKER_USERS, which can do anything. %u in a rule is the
login’s own name and %p the discovery prefix; resolved* are the rules with
both filled in.
POST /api/broker/logins
Section titled “POST /api/broker/logins”{"name":"kitchen-node","password":"a-long-random-password","profile":"esphome"}name is 1 to 64 letters, digits, - and _ (it stands for %u in topics).
password is at least 12 characters. profile fills in the rules; publish
and subscribe, when given, replace them. One of the two is needed. A rule is
an MQTT topic filter: + and # only as whole levels, # only last, never a
$ topic. 201 with the login; 400 for any of the above or a name
HOTLOOP_BROKER_USERS already has; 409 for a name that’s taken.
PUT /api/broker/logins/{name}
Section titled “PUT /api/broker/logins/{name}”Any of profile, publish, subscribe and password; what isn’t sent stays.
200 with the login, 404 for one that doesn’t exist.
DELETE /api/broker/logins/{name}
Section titled “DELETE /api/broker/logins/{name}”200 {"deleted":"kitchen-node"}. A client still connected with it can do
nothing from then on, and it can’t connect again. 404 for one that doesn’t
exist.
Network discovery
Section titled “Network discovery”Devices heard on the plant network over mDNS, SSDP and DHCP, and what a
person decided about each. See NETWORK-DISCOVERY.md.
In an --mcp-stdio helper, which never listens, GET /api/network-discovery
answers {"running":false,"finds":[]} and every other route 503.
{key} is how a find was heard and who by: mdns: and its mDNS host name,
ssdp: and its UPnP uuid, dhcp: and its MAC. URL-encode it.
None of these arm anything, and nothing here is added until somebody accepts it.
GET /api/network-discovery
Section titled “GET /api/network-discovery”The listeners, probing, the interfaces they’re on, and every find:
{"running":true, "status":{"leading":true,"interfaces":["eth0 10.20.0.15/24"], "listeners":[{"name":"mdns","enabled":true,"listening":true,"heard":120,"dropped":0,"refused":0}, {"name":"ssdp","enabled":true,"listening":true,"heard":14,"dropped":0,"refused":0}, {"name":"dhcp","enabled":false,"listening":false,"heard":0,"dropped":0,"refused":0}], "probe":{"networks":[]}}, "finds":[{"key":"mdns:line3-ua.local","via":"mdns","kind":"opcua","name":"line3-ua","status":"discovered", "firstSeen":"2026-10-11T14:02:11Z","lastSeen":"2026-10-11T14:07:40Z", "find":{"key":"mdns:line3-ua.local","via":"mdns","kind":"opcua","name":"line3-ua", "summary":"OPC UA server, capabilities DA","host":"line3-ua.local","addresses":["10.20.0.31"], "services":[{"type":"_opcua-tcp._tcp.local","instance":"line3-ua._opcua-tcp._tcp.local","port":50000, "txt":{"caps":"DA","path":"/"}}], "proposal":{"protocol":"opcua","address":"opc.tcp://10.20.0.31:50000/","how":"Accepting makes an OPC UA device..."}, "heard":"2026-10-11T14:07:40Z"}}]}kind is esphome, shelly, opcua, mqtt-broker, web, mdns (it
announced something HotLoop has no word for), upnp or dhcp. A find whose
proposal has a protocol becomes a device of that protocol at address
when accepted; one without says in how what would bring it in. also lists
the other finds and MQTT-discovered devices that are the same box, by MAC or
address. status is discovered, accepted or ignored, and deviceId the
device accepting made. A listener’s error says why it isn’t listening (port
67 taken or not allowed, no multicast interface); refused counts new finds
dropped because the record is full.
GET /api/network-discovery/{key}
Section titled “GET /api/network-discovery/{key}”One find, the same shape. 404 for one HotLoop hasn’t heard.
POST /api/network-discovery/{key}/accept
Section titled “POST /api/network-discovery/{key}/accept”Accepts a find. Optional body {"equipmentId": 12} places the device it makes
on the tree. A find HotLoop has a driver for becomes a device at the proposed
address, enabled, with no tags and nothing armed; any other is recorded as
known and makes nothing. Answers the find. 400 for one that’s accepted or
ignored already.
POST /api/network-discovery/{key}/ignore
Section titled “POST /api/network-discovery/{key}/ignore”Hides a find nobody has decided about, and keeps it hidden when it’s heard
again. 400 for one that’s been decided.
POST /api/network-discovery/{key}/unignore
Section titled “POST /api/network-discovery/{key}/unignore”Brings an ignored find back to be decided about. 400 if it isn’t ignored.
POST /api/network-discovery/probe
Section titled “POST /api/network-discovery/probe”Probes now: an mDNS query and an SSDP search on every interface whose network
is listed in HOTLOOP_NETDISC_PROBE_NETWORKS, and nowhere else. Answers
{"status": ...}, with what didn’t go out in status.probe.lastError. 400
when no network has probing switched on: this button can’t switch it on, only
the deployment can.
MQTT brokers
Section titled “MQTT brokers”Brokers other than HotLoop’s own that discovery listens to: a plant’s
Mosquitto, the broker a Zigbee2MQTT box brought along. See
DISCOVERY.md. Their passwords are sealed with the
device credential key (HOTLOOP_SECRET_KEY_FILE) and never answered with;
passwordSet says whether one is stored, and passwordProblem why a stored
one can’t be opened. Every change goes on the log and takes effect at once:
the listener and the devices from that broker connect again.
GET /api/mqtt/brokers
Section titled “GET /api/mqtt/brokers”{"secretKey":true, "brokers":[{"id":"plant-uns","name":"Plant UNS","broker":"tcp://10.20.0.5:1883","username":"hotloop","useTls":false, "insecureSkipVerify":false,"prefix":"homeassistant","listenOnly":true,"autoCreate":false,"tasmota":true,"shelly":true,"enabled":true, "createdBy":"admin","passwordSet":true,"createdAt":"2026-10-03T09:12:04Z","updatedAt":"2026-10-03T09:12:04Z"}]}secretKey is whether this HotLoop has a key to seal a password with.
POST /api/mqtt/brokers
Section titled “POST /api/mqtt/brokers”{"id":"plant-uns","name":"Plant UNS","broker":"tcp://10.20.0.5:1883","username":"hotloop", "password":"...","listenOnly":true}id is 1 to 63 lowercase letters, digits and -, not embedded: it names the
broker in every device that came from it, and can’t change. broker is
host:port or a tcp://, ssl://, tls://, mqtt:// or mqtts://
URL, with no login in it. prefix defaults to homeassistant; autoCreate
to false (devices wait in the inbox); listenOnly to false; tasmota (hear
Tasmota’s own discovery, tasmota/discovery/#, too) to true; shelly (learn
Shelly Gen2 and later devices from the <prefix>/status/<component> topics they
publish) to true; enabled to true.
A password needs a username, and a key: without HOTLOOP_SECRET_KEY_FILE it’s
a 400, never stored in the clear. 201 with the broker; 409 for an id
that’s taken.
PUT /api/mqtt/brokers/{id}
Section titled “PUT /api/mqtt/brokers/{id}”Any of the fields above but id; what isn’t sent stays. "password":""
clears the password. 200 with the broker.
DELETE /api/mqtt/brokers/{id}
Section titled “DELETE /api/mqtt/brokers/{id}”Removes the broker and what it announced that nobody accepted: the inbox, and
any device it made on its own. 409 naming them while devices accepted from
it still exist: ignore or delete those first, or switch the broker off with
"enabled":false instead. 200 {"deleted":"plant-uns"}.
GET /api/tags
Section titled “GET /api/tags”| Query | Details |
|---|---|
device |
Every tag on one device. When set, q and limit are ignored |
q |
Search: a case-insensitive substring of the device id, tag name or description |
limit |
Default 500, at most 1000 |
[{"id":5,"deviceId":"tank-farm","name":"level_sp","address":"mqtt:docs-api-example/level_sp","dataType":"float","unit":"%","enabled":true,"writable":true,"maxValue":95,"hasRange":true,"scale":1,"createdAt":"2026-09-27T20:21:39.405164Z","updatedAt":"2026-09-27T20:21:39.405164Z"}]POST /api/tags
Section titled “POST /api/tags”Creates the tag if deviceId and name are new, replaces it if not.
| Field | Type | Details |
|---|---|---|
deviceId |
string | Required. The device must exist (404 if not) |
name |
string | Required. No control characters |
address |
string | Required. The protocol’s own address |
dataType |
string | bool, int, float or string (and the usual aliases: int16, word, coil…). Decides how a write is encoded |
unit, description |
string | |
enabled |
bool | Left out is false |
writable |
bool | Arms the tag for writing, from every path: UI, automations, entities, MCP. Left out is false |
hasRange, minValue, maxValue |
bool, number, number | With hasRange, a write outside the range is refused (never clamped) |
scale, offset |
number | engineering = raw x scale + offset. A scale of 0 is 1 |
noData |
list | Raw values that mean “no measurement”, each {"raw", "reason"}. raw is a string ("0x8005", "-32763") or a bare number, and comes back as a string. A reading whose raw value is on the list is bad quality with that reason, checked before scale and offset. Up to 32 codes. A code that is not a whole number, is listed twice, or can’t be the point’s type is 400. See PROTOCOLS.md for what to write for each type and the Yokogawa DX example |
deadband, deadbandPercent |
number | Changes smaller than this are not written to history |
deviceClass |
string | The class the tag’s entities start with (power, connectivity, temperature…), when it means something for their domain. Taken when the tag is created and ignored on a replace: from then on the entity owns its class, so change it there |
Answers the saved tag. Saving one with writable: true is logged as such: arming
a tag is a safety decision and gets a name against it. The tag’s entities are
created or updated before the answer, so a client can ask for them straight
away.
A tag on a discovered device nobody has accepted can’t be armed: 400,
“accept the device first”. See DISCOVERY.md.
DELETE /api/tags/{device}/{name}
Section titled “DELETE /api/tags/{device}/{name}”{"deleted":"tank-farm/level"}, or 404. The history stays. The tag’s
entities become orphans (see ENTITIES.md).
Values, the board and writes
Section titled “Values, the board and writes”GET /api/values
Section titled “GET /api/values”The latest reading of every tag, from memory. ?device= narrows it to one
device.
[{"deviceId":"extruder-1","protocol":"opcua","tagName":"Temperature","tagValue":20.645294745260895,"unit":"C","quality":"good","timestamp":"2026-09-27T20:21:58.255722Z"}]This shape is the EmberNET dashboard’s TagValue and is a contract. quality
is good, uncertain or bad; a bad reading means the value is not known,
not that it is zero. A string reading carries stringVal, and its
tagValue means nothing.
A reading that isn’t good carries reason when the driver knows why, so a
screen can say more than “bad”:
{"deviceId":"meter-01","protocol":"http","tagName":"power","tagValue":0,"quality":"bad","timestamp":"2026-09-30T09:12:04.51Z","reason":"no field at ENERGY.Power"}A good reading never has it, so the contract above is byte for byte what it
was. The historian doesn’t keep it: /api/history has the quality, not the
reason.
GET /api/sparkline
Section titled “GET /api/sparkline”The last readings of one tag kept in memory (HOTLOOP_RING_SIZE, 30 by
default), for the little charts. ?tag=device/tag, required.
{"tag":"extruder-1/Temperature","timestamps":[1790540498609,1790540499246,1790540500246],"values":[0,20.06524581530632,19.933308705395785]}timestamps are Unix milliseconds. Both are [] for a tag with no readings.
400 for a malformed key.
GET /api/board
Section titled “GET /api/board”Which tags are pinned to the board, and in how many columns. It is stored in the database, not the browser, because a board belongs to the plant: the panel PC on the floor and the laptop in the office show the same one.
{"columns":2,"tags":["extruder-1/Temperature","tank-farm/level"]}PUT /api/board
Section titled “PUT /api/board”The same shape. columns outside 1 to 4 becomes 3; duplicates are dropped.
Every tag must exist (404 naming the one that does not): a stale entry is a
gauge that never moves. Answers the board as stored. Operators hold
board.write: arranging the board is running the plant, not configuring it.
POST /api/write
Section titled “POST /api/write”Writes one value to one tag, through the write gate.
| Field | Type | Details |
|---|---|---|
tag |
string | Required. device/tag |
value |
bool, number or string | Required. true is a bool, 42 an integer, 42.5 a float, "42" a string: the JSON type is the type written, because a coil, a word and a real are three different things on the wire |
reason |
string | Optional. Logged with the write |
{"tag":"tank-farm/level_sp","written":"72.5"}The gate checks, in order, that writes are switched on
(HOTLOOP_ALLOW_WRITES), that the tag is armed, and that the value is in
range. Every attempt, refused or not, is in GET /api/writes.
| Status | When |
|---|---|
| 400 | No value, a null value, a malformed key |
| 403 | Writes are off, or "tank-farm/level: this tag is not marked writable" |
| 404 | No such tag |
| 422 | "value is outside the tag's configured range: 120 is outside 0..95" |
| 503 | The device is not connected |
History and audit
Section titled “History and audit”GET /api/history
Section titled “GET /api/history”| Query | Details |
|---|---|
tag |
Required, repeatable, up to 50: ?tag=a/x&tag=b/y |
minutes |
How far back from now. Default 60 |
bucket |
Bucket size in seconds. Default: picked so the window is about 400 buckets, on a 1 s to 1 day ladder |
raw |
true for the readings themselves instead of buckets |
limit |
Default 5000, at most 100000 |
carry |
true to also say what each tag was holding when the window began (buckets only) |
Bucketed, oldest first per tag. badCount is how many readings in the bucket
were not good quality; they are counted, not averaged in. from and to are
the window the answer is for, exactly minutes apart and ending when the
question was asked:
{"buckets":[{"deviceId":"extruder-1","tagName":"Temperature","bucket":"2026-09-27T20:21:00Z","avg":18.986210570239944,"min":0,"max":20.83008050535897,"first":0,"last":20.83008050535897,"count":22,"badCount":1}],"from":"2026-09-27T19:22:00Z","raw":false,"to":"2026-09-27T20:22:00Z"}The historian keeps a reading when a value moves past its deadband, not on every
poll, so a tag that has held still has nothing in the window to draw. With
carry=true the answer also has "carry":[{"deviceId":"extruder-1","tagName":"Temperature","ts":"2026-09-27T16:40:12Z","value":21.4}]:
the last reading before from, for each tag whose last reading before it was
good and had a number in it. A tag whose last reading was bad or uncertain, that
has no earlier reading, or that is text, has no entry, because what it held isn’t
known and I won’t hand a chart a guess. The trend card draws from this.
Raw, newest first:
{"raw":true,"readings":[{"deviceId":"extruder-1","protocol":"","tagName":"Temperature","tagValue":21.021559879601448,"quality":"good","timestamp":"2026-09-27T20:22:00.267463Z"}]}400 with no tag, more than 50, or a malformed key.
GET /api/writes
Section titled “GET /api/writes”The write audit, newest first. Every attempt is here, including the refused ones, because “we do not know whether that command went out” is the worst answer a plant can get.
| Query | Details |
|---|---|
tag |
One tag, device/tag |
device |
Every tag on one device (ignored if tag is set) |
entity |
Every write a service call on this entity made or had refused, instead of the above |
call |
Every write of one call, instead of all of the above: a button’s press and release, or every target of a recipe’s application, which share its call_id |
limit |
Default 100, at most 5000 |
[{"id":2,"at":"2026-09-27T20:22:01.04226Z","deviceId":"tank-farm","tagName":"level_sp","value":"120","prior":"0 %","actor":"admin","source":"ui","succeeded":false,"error":"refused: value is outside the tag's configured range: 120 is outside 0..95"}, {"id":4,"at":"2026-09-27T20:23:03.276962Z","deviceId":"tank-farm","tagName":"pump","value":"true","prior":"0","actor":"admin","source":"ui","succeeded":true,"latencyMs":0.317,"entityId":"switch.tank_farm_pump","service":"switch.turn_on","callId":"5c9f8545a93b5443"}]source is ui, api, automation or mcp. entityId, service and
callId are only on writes a service call made.
Entities and states
Section titled “Entities and states”Every tag gets entities without anybody asking; composites (motors, valves, loops, counters, selects, buttons) are built from tags by an admin. Snake_case, like every entity endpoint. The whole model is in ENTITIES.md.
GET /api/entities
Section titled “GET /api/entities”Every entity, orphans included.
| Query | Details |
|---|---|
domain |
sensor, switch, motor… |
device |
Entities on one device |
orphaned |
true for only orphans, false for none |
[{"id":5,"entity_id":"switch.tank_farm_pump","unique_id":"tag-control:tank-farm/pump","domain":"switch","platform":"tag","name":"pump","device_id":"tank-farm","options":{},"disabled":false,"hidden":false,"created_at":"2026-09-27T20:21:39.153197Z","updated_at":"2026-09-27T20:21:39.153197Z","bindings":[{"role":"command","device_id":"tank-farm","tag_name":"pump"},{"role":"state","device_id":"tank-farm","tag_name":"pump"}],"equipment_id":null,"area_id":4,"labels":[]}]GET /api/entities/{entity_id}
Section titled “GET /api/entities/{entity_id}”{"entity": {...}, "state": {...}}. state is null for a disabled entity.
404 for an unknown one.
GET /api/entities/{entity_id}/image
Section titled “GET /api/entities/{entity_id}/image”A camera’s or an image’s latest picture, as the bytes HotLoop holds:
Content-Type is what they are (image/jpeg, image/png, image/gif or
image/webp), with X-Content-Type-Options: nosniff, a sandboxing
Content-Security-Policy and an ETag (If-None-Match gets a 304). 404
for an entity that isn’t one, or before a picture has arrived. The state’s
entity_picture attribute is this path with the picture’s hash on it.
PUT /api/entities/{entity_id}
Section titled “PUT /api/entities/{entity_id}”Changes only the fields you send. Admin-only: the entity_id is the name every automation, dashboard and agent refers to it by, and changing it is configuration.
| Field | Type | Details |
|---|---|---|
entity_id |
string | A rename. The unique_id does not move |
name, icon, device_class, state_class, unit_of_measurement, entity_category |
string | |
options |
object | Per domain; see ENTITIES.md |
disabled, hidden |
bool | A disabled entity has no state and cannot be commanded |
equipment_id |
int or null | Places it on the tree; null follows its device again |
labels |
string[] | The whole set |
unique_id, domain, platform, the device and the bindings cannot be
changed here, and sending them is a 400.
{"entity":{"id":3,"entity_id":"sensor.tank_1_level","name":"Tank 1 level","device_class":"moisture","icon":"mdi:water-percent","...":"..."},"warnings":[]}warnings says when something is legal but odd (a unit the device class does
not usually have). 400 for an empty body ("the update names no field to change") or a bad value, 404 for an unknown entity, 409 for an entity_id
that is taken.
POST /api/entities
Section titled “POST /api/entities”Creates a composite. The body names the domain and binds tags to its roles:
{"name":"Transfer pump","domain":"motor","bindings":[{"role":"run_command","device_id":"tank-farm","tag_name":"pump"},{"role":"running","device_id":"tank-farm","tag_name":"pump"}]}Also entity_id (else generated from the name), device_class,
unit_of_measurement, icon, entity_category and options. Which roles and
options each domain takes is in
ENTITIES.md. Answers 201
{"entity": {...}, "warnings": [...]}. 400 for a domain that is not
created here (a sensor comes from its tag, an alarm from its definition),
a missing role or a tag that cannot fill it, 409 for a taken entity_id.
DELETE /api/entities/{entity_id}
Section titled “DELETE /api/entities/{entity_id}”Deletes a composite, or an orphan whose tag or alarm definition is gone.
{"deleted":"motor.transfer_pump","entity":{...}}. Anything else answers
409 saying how it does go away:
{"error":"this entity cannot be deleted: sensor.tank_1_level is kept in step with its tag; delete the tag, and then this orphan, or hide or disable it: already exists"}GET /api/states
Section titled “GET /api/states”Every enabled entity’s state, ordered by entity_id. ?domain= filters.
[{"entity_id":"number.tank_farm_level_sp","state":"unknown","attributes":{"armed":true,"friendly_name":"level_sp","max":95,"min":0,"source":["tank-farm/level_sp"],"unit_of_measurement":"%"},"quality":"bad","last_changed":"2026-09-27T20:21:39.417193755Z","last_updated":"2026-09-27T20:21:39.417193755Z","last_reported":"2026-09-27T20:22:04.423849496Z"}]state is always a string, even for a number; unknown and unavailable
mean what they say. last_reported is the device’s timestamp of the reading
behind it.
GET /api/states/{entity_id}
Section titled “GET /api/states/{entity_id}”One state. 404 for an unknown entity, and a different 404 for a disabled
one, which says how to enable it: a disabled entity is not missing, and
reporting it as missing sends somebody hunting for a typo.
Services
Section titled “Services”POST /api/services/{domain}/{service}
Section titled “POST /api/services/{domain}/{service}”Commands one entity: switch/turn_on, number/set_value, motor/start,
alarm/acknowledge. One entity, one service, one call. Underneath it is nothing but
tag writes through the same write gate as POST /api/write.
{"entity_id":"switch.tank_farm_pump","reason":"start of shift"}entity_id is required and is one entity, not a list; reason is optional;
everything else is the service’s data (value, option, position, mode,
minutes, note). Numbers keep the type they were typed as. The domain in the
path must be the entity’s. Every service and its data is in
ENTITIES.md.
{"call_id":"5c9f8545a93b5443","entity_id":"switch.tank_farm_pump","service":"switch.turn_on","writes":[{"tag":"tank-farm/pump","value":"true","status":"written"}]}Each write is written, not_sent (refused before it reached the device),
failed (it went out and the device reported an error, so whether it took is
not known) or skipped (an earlier write in the call did not succeed). Writes
already made are never rolled back. An alarm entity’s services write no tag
and answer with the alarm as the engine left it. A helper’s write no tag
either and answer with the state they left it in:
{"call_id":"0f3c2a1b9d8e7f60","entity_id":"input_number.batch_target","service":"input_number.set_value","writes":[],"state":"1250"}A script’s turn_on runs it, with the data as its fields, and answers with the
script_run it started; its steps’ writes are audited one by one as
script:<id>, not here. turn_off stops every run of it. See
Scripts.
A scene’s turn_on applies its recipe, with params and version from the
data, and answers with the whole recipe_application and, in writes, every
write its targets made, in order. It fails unless every target was written.
See Recipes.
A failed call answers with the error’s status and the same body plus error,
so you can see exactly where it stopped:
{"error":"value is outside the tag's configured range: 150 is outside 0..95","call_id":"3cd42f8275e5765e","entity_id":"number.tank_farm_level_sp","service":"number.set_value","writes":[{"tag":"tank-farm/level_sp","value":"150","status":"not_sent","error":"value is outside the tag's configured range: 150 is outside 0..95"}]}| Status | When |
|---|---|
| 400 | No entity_id, the wrong domain, an unknown service, data the service does not take |
| 403 | The permission, or the gate refused on policy |
| 404 | No such entity |
| 409 | A toggle with no known state, equipment in the wrong state for the service |
| 422 | A value refused on its merits |
| 503 | The device is not connected; the alarm engine is not running |
| 500 | A button press went out and its release did not. The tag may still hold the press value: check it and release it by hand |
?dry_run=true asks what the call would do and does none of it: nothing is
written, audited or logged. The call is planned as the real one is and every
write is put to the gate’s own checks, in the same order, right now. A verdict
answers 200, refused or not: each write would_write, or the first that would
be refused not_sent with the reason and the rest skipped, and error is
the error the real call would fail with, word for word.
{"dry_run":true,"error":"value is outside the tag's configured range: 150 is outside 0..95","entity_id":"number.tank_farm_level_sp","service":"number.set_value","writes":[{"tag":"tank-farm/level_sp","value":"150","status":"not_sent","error":"value is outside the tag's configured range: 150 is outside 0..95"}]}A request the real call would turn away before any check (no such entity, an
unknown service, data the service does not take, a toggle with no known state)
answers with the real call’s status and error; so does a missing permission.
dry_run is true or false and nothing else: yes is a 400, not a guess,
because guessing wrong turns a question into a command. See
ENTITIES.md.
Why the permission is checked in the handler. It needs values.write, the
same as writing the tag directly, because that is all it is; an alarm
entity’s services need alarms.ack instead, the same as the alarm screen, a
helper’s need helpers.write, which an operator holds, and a script’s need
scripts.run, the same as POST /api/scripts/{id}/run, and a scene’s need
recipes.apply and the recipe’s own permission when it names one, the same as
POST /api/recipes/{id}/apply. The
check is made inside the handler rather than in front of it so that a refusal
for want of it is sent to the write gate and audited against the entity
like every other refusal, with the value that was asked for. A user without the
permission gets a 403 and a line in GET /api/writes?entity=..., not a 403 and
nothing. A helper writes no tag, so a helper’s refusal, this one included, is a
warning in the system log (GET /api/events) against the helper instead, and
so is a script’s. A scene’s refusal is a refused application in the recipe’s
history, with the user on it.
Helpers
Section titled “Helpers”Values the plant’s people own rather than a PLC: input_boolean,
input_number, input_select, input_text, input_counter, timer and
schedule. A helper is an entity (platform helper) and shows up in
/api/entities and /api/states like any other; these routes are its
configuration. Its value is set through its services above, with
helpers.write. Every domain, option and service is in
HELPERS.md.
GET /api/helpers
Section titled “GET /api/helpers”Every helper, each with its entity’s fields and value, the row stored for it:
[{"id":41,"entity_id":"timer.purge","unique_id":"helper:6f1d...","domain":"timer","platform":"helper","name":"Purge","options":{"duration_sec":600},"...":"...","value":{"state":"active","finishes_at":"2026-09-27T21:10:00Z","duration_ms":600000,"changed_at":"2026-09-27T21:00:00Z","changed_by":"otto","source":"ui","call_id":"9a1b...","rev":4}}]POST /api/helpers
Section titled “POST /api/helpers”Creates a helper at its initial value. Admin-only.
{"name":"Batch target","domain":"input_number","unit_of_measurement":"kg","options":{"min":0,"max":5000,"step":50,"initial":1000}}Also entity_id (else generated from the name) and icon. Answers 201
{"helper": {...}}. 400 for a domain that is not a helper’s, an option the
domain does not take, or an initial its own options would refuse; 409 for
a taken entity_id.
GET /api/helpers/{entity_id}
Section titled “GET /api/helpers/{entity_id}”One helper, as in the list. 404 for an unknown entity or one that is not a
helper.
PUT /api/helpers/{entity_id}
Section titled “PUT /api/helpers/{entity_id}”Changes its name, icon, unit_of_measurement, options, disabled,
hidden, entity_id, equipment_id or labels, the same fields as
PUT /api/entities/{entity_id}. Admin-only. New options that would not allow
the value it holds now (a range narrowed past it, the selected choice
removed) are a 400: a value is never moved to fit. Answers
{"helper": {...}, "warnings": []}.
DELETE /api/helpers/{entity_id}
Section titled “DELETE /api/helpers/{entity_id}”Deletes the helper and its value. Admin-only. {"deleted":"timer.purge"}.
DELETE /api/entities/{entity_id} refuses a helper with a 409 pointing
here.
Equipment and labels
Section titled “Equipment and labels”The ISA-95 tree (enterprise, site, area, line, cell, unit, equipment) and free-form labels. Snake_case. Every role reads; only an admin changes, because renaming or moving a node rewrites the site, area and line of every device under it. See EQUIPMENT.md.
GET /api/equipment
Section titled “GET /api/equipment”Every node, ordered by id, each with its path of names from the root.
?level=area filters.
[{"id":4,"parent_id":1,"level":"area","name":"Utilities","created_at":"2026-09-27T20:21:01.637319Z","updated_at":"2026-09-27T20:21:01.637319Z","path":["Demo Plant","Utilities"]}]POST /api/equipment
Section titled “POST /api/equipment”{"parent_id":3,"level":"cell","name":"Die head","description":"Die and screen changer"}parent_id null or left out makes a root. A child must be a lower level than
its parent (equipment may sit in equipment). Answers
{"equipment": {...}, "devices_rewritten": []}. 400 for a bad level or
placement, 409 for a sibling with the same level and name.
GET /api/equipment/{id}
Section titled “GET /api/equipment/{id}”The node, the nodes directly under it, and the devices and entities placed directly on it:
{"children":[],"devices":["extruder-1"],"entities":[],"equipment":{"id":3,"parent_id":2,"level":"line","name":"Line 1","...":"...","path":["Demo Plant","Extrusion","Line 1"]}}400 for an id that is not a positive number, 404 for an unknown one.
PUT /api/equipment/{id}
Section titled “PUT /api/equipment/{id}”Any of name, description, level, parent_id (null moves it to the
root). Only the fields sent change; an empty body is a 400. Answers the node and
every device whose site, area or line text changed as a result, because
that text is what older clients and the MCP tools read:
{"devices_rewritten":["tank-farm"],"equipment":{"id":4,"parent_id":1,"level":"area","name":"Utilities East","...":"..."}}DELETE /api/equipment/{id}
Section titled “DELETE /api/equipment/{id}”{"deleted":5}. 409 while anything is under it or placed on it, saying what:
deleting a node out from under a device is not a decision to make by
accident.
GET /api/labels
Section titled “GET /api/labels”[{"label_id":"critical","name":"Critical","color":"red","icon":"mdi:alert","description":"Stops the line if it fails","created_at":"2026-09-27T20:24:29.659126Z","updated_at":"2026-09-27T20:24:29.659126Z"}]POST /api/labels
Section titled “POST /api/labels”name is required; label_id (1 to 64 lowercase letters, digits, underscores)
is generated from the name if left out, and never changes afterwards. color,
icon (mdi:...) and description are optional. Answers the label. 409 for
a name or id that is taken.
GET /api/labels/{label_id}
Section titled “GET /api/labels/{label_id}”{"label": {...}, "devices": ["tank-farm"], "entities": []}: the label and what
carries it. 404 for an unknown one.
PUT /api/labels/{label_id}
Section titled “PUT /api/labels/{label_id}”Replaces name, color, icon and description; a field left out is
cleared. A label_id in the body must match the path: 400 otherwise, because
a label_id never changes.
DELETE /api/labels/{label_id}
Section titled “DELETE /api/labels/{label_id}”Removes it from everything carrying it. {"deleted":"critical"}, or 404.
PUT /api/devices/{id}/labels
Section titled “PUT /api/devices/{id}/labels”{"labels": ["critical"]} replaces the whole set on a device. Answers
{"id":"tank-farm","labels":["critical"]}. Every label must exist (400
naming the missing ones), and the device too (404). Labels are not part of
POST /api/devices on purpose: every client that saves a device sends the
whole device without them, and would wipe them on every save.
Alarms
Section titled “Alarms”ISA-18.2: raising, acknowledging and returning to normal are separate things, and the log can tell which happened.
GET /api/alarms
Section titled “GET /api/alarms”Every definition, the live alarms and the summary. ?outstanding=true leaves
out the normal and silenced ones.
{ "alarms": [{"definitionId":"extruder-temp-hi","name":"Extruder temperature high","deviceId":"extruder-1","tagName":"Temperature","priority":2,"state":"unack-active","message":"extruder-1/Temperature at 22.80197037650447 C, limit 5","tripValue":22.80197037650447,"raisedAt":"2026-09-27T20:25:19.67042816Z","count":1,"updatedAt":"2026-09-27T20:25:19.67042816Z"}], "definitions": [{"id":"extruder-temp-hi","name":"Extruder temperature high","deviceId":"extruder-1","tagName":"Temperature","condition":"hi","limit":5,"priority":2,"enabled":true,"deadband":0.5,"message":"{device}/{tag} at {value}, limit {limit}","createdAt":"2026-09-27T20:25:19.055658Z","updatedAt":"2026-09-27T20:25:19.055658Z"}], "summary": {"critical":0,"high":1,"medium":0,"low":0,"unacked":1,"total":1}}state is normal, unack-active, ack-active, rtn-unack (cleared before
anyone acknowledged it, and kept on the list so a 3am trip cannot vanish before
the morning shift sees it), shelved or suppressed. tripValue is the
reading that raised it, not the current one.
POST /api/alarms
Section titled “POST /api/alarms”Creates or replaces a definition.
| Field | Type | Details |
|---|---|---|
id, name |
string | Required. id follows the device id rules |
deviceId, tagName |
string | Required |
condition |
string | hihi, hi, lo, lolo, eq, ne, true, false, bad-quality, stale (no reading for limit seconds), roc (rate of change above limit per second) |
limit |
number | |
priority |
int | 1 (critical) to 4 (low) |
enabled |
bool | Left out is false |
deadband |
number | How far back the value must come before it clears. Without one, a value sitting on the limit chatters |
onDelaySec, offDelaySec |
int | How long the condition must hold before it raises, or be clear before it returns to normal |
message, description |
string | message takes {value}, {limit}, {tag}, {device}, {unit}, {condition} and {reason} (why the reading is bad, empty when it is good) |
{value} is written the way the value card writes it, unit included: whole
numbers as they are, two places from 1 up, one from 100 up, four below 1, and
scientific past ten million or under 0.01 (8.20 pH, 212.5 F, 1.235e+7).
I did that because a tag with scale 0.1 and a raw 82 is 8.200000000000001 as a
float, and the banner used to say so. A text reading is said as it is. The tag
itself, the writes and the automations still see the exact number; only the
sentence is rounded. {limit} is the limit as you typed it.
Answers the definition; the engine reloads and the alarm entity is made before
the answer. 400 on a failed validation ("alarm x: priority must be 1-4, got 9").
DELETE /api/alarms/{id}
Section titled “DELETE /api/alarms/{id}”{"deleted":"extruder-temp-hi"}, or 404.
POST /api/alarms/{id}/ack
Section titled “POST /api/alarms/{id}/ack”Optional body {"note": "checking the heater bands"}. Answers the alarm as it
now is, with ackedBy and ackNote. 404 for an unknown alarm.
POST /api/alarms/{id}/shelve
Section titled “POST /api/alarms/{id}/shelve”{"minutes": 30}, required and positive: an indefinite silence is a
suppression, which is a different thing and not an operator’s to make. Answers
the alarm, state shelved, with shelvedUntil and shelvedBy.
POST /api/alarms/{id}/unshelve
Section titled “POST /api/alarms/{id}/unshelve”No body. Answers the alarm back in whatever state its condition puts it. 409
if it was not shelved.
GET /api/alarms/events
Section titled “GET /api/alarms/events”The alarm audit trail, newest first. ?alarm= one definition, ?limit=
(default 200, at most 5000).
[{"id":2,"definitionId":"extruder-temp-hi","type":"acked","fromState":"unack-active","toState":"ack-active","value":23.192472931395088,"message":"extruder-1/Temperature at 22.80197037650447 C, limit 5","actor":"admin","note":"checking the heater bands","at":"2026-09-27T20:25:22.920251Z"}]type is raised, acked, cleared, shelved, unshelved, suppressed or
unsuppressed.
Automations
Section titled “Automations”Triggers, a condition, actions. The rule language is in AUTOMATIONS.md.
GET /api/automations
Section titled “GET /api/automations”{"armed":{"fill-tank":true},"automations":[{"id":"fill-tank","name":"Fill the tank","enabled":true,"triggers":[{"type":"threshold","deviceId":"tank-farm","tagName":"level","threshold":20,"direction":"falling","hysteresis":5},{"type":"webhook","webhookPath":"fill-now"}],"actions":[{"type":"call_service","name":"start the pump","params":{"service":"switch.turn_on","entityId":"switch.tank_farm_pump"}}],"mode":"skip","minIntervalSec":60,"createdAt":"2026-09-27T20:26:49.770826Z","updatedAt":"2026-09-27T20:26:49.770826Z"}],"webhooks":["fill-now"]}armed is which rules are actually running, which is not the same as
enabled: an enabled rule that stopped compiling is not armed, and this is
where you find out.
POST /api/automations
Section titled “POST /api/automations”Creates or replaces a rule: id, name, description, enabled,
triggers (at least one), condition, actions (at least one), mode
(skip, the default, queue, restart or parallel), minIntervalSec,
maxRuntimeSec. The rule is compiled before it is stored, and one that does not
compile is a 400 and never reaches the database: a stored rule that cannot be
armed is a rule its author believes is running.
{"error":"automation fill-tank action 1 (call_service): params: json: unknown field \"entity_id\""}POST /api/automations/validate
Section titled “POST /api/automations/validate”Compiles a rule without saving it, for the editor’s live check. Always 200;
the answer says whether it compiled:
{"valid":true}{"error":"automation bad: at least one trigger is required","valid":false}It needs automations.write because it exists to help somebody write one.
DELETE /api/automations/{id}
Section titled “DELETE /api/automations/{id}”{"deleted":"fill-tank"}, or 404.
POST /api/automations/{id}/enabled
Section titled “POST /api/automations/{id}/enabled”{"enabled": false} disarms it without deleting it. Answers
{"enabled":false,"id":"fill-tank"}, or 404.
POST /api/automations/{id}/trigger
Section titled “POST /api/automations/{id}/trigger”Runs it now, as if a trigger fired. The condition still applies (a false one
records a skipped run) and so does the run mode; minIntervalSec does not,
because a person asking for a run is not a chattering tag. No body. 202 {"triggered":"fill-tank"}: the run happens in the
background, and GET /api/runs has the result. 400 if the rule is not armed.
Operators hold automations.trigger; running a rule somebody else wrote is
operating, writing one is not.
GET /api/runs
Section titled “GET /api/runs”Run history, newest first. ?automation= one rule, ?limit= (default 100, at
most 1000). [] when there are none.
[{"id":1,"automationId":"fill-tank","status":"succeeded","triggerType":"manual","triggerDesc":"triggered by admin","actor":"admin","startedAt":"2026-09-27T20:26:50.269792Z","finishedAt":"2026-09-27T20:26:50.282129Z","durationMs":12.337,"steps":[{"index":1,"type":"call_service","name":"start the pump","input":"switch.turn_on switch.tank_farm_pump {}","output":"true to tank-farm/pump: written; call 8fb9538c82d532be","startedAt":"2026-09-27T20:26:50.272532641Z","durationMs":9.595}]}]status is running, succeeded, failed, skipped, timed_out or
cancelled. The steps keep the resolved inputs, not the templates, because
that is what you need to read at 3am.
A rule made from a blueprint carries three more fields in GET /api/automations: blueprintId, blueprintVersion (the version it was made
from, which is the version it stays at) and blueprintInputs. A rule written
by hand has none of them. They are the server’s: POST /api/automations
accepts them and ignores them, and an edit by hand keeps whatever the rule had.
POST /api/automations/{id}/blueprint-upgrade
Section titled “POST /api/automations/{id}/blueprint-upgrade”Moves a rule made from a blueprint to the blueprint’s newest version, keeping
its id, its name and whether it is armed. It is never done for you. Operators
hold blueprints.apply, the same as for making one. Look first:
?dry_run=true changes nothing and answers what would change, part by
part, and what the rule would then do if it fired now, every write put to the
gate’s checks:
{"dry_run":true,"id":"pump_1_watch","blueprint":"pump_watch","from":1,"to":2,"changes":[{"field":"actions","before":[{"params":{"message":"switch.pump_1 started, v1"},"type":"log_event"}],"after":[{"params":{"message":"switch.pump_1 started, v2"},"type":"log_event"}]}],"automation":{"id":"pump_1_watch","...":"..."},"check":{"automation_id":"pump_1_watch","steps":[{"step":"1","type":"log_event","verdict":"not_checked","detail":"a dry run checks writes to equipment; this step writes to none and runs only for real"}]}}An edit made by hand since the rule was made shows up in changes as being
replaced. When the new version needs an input the rule has no value for, or
refuses a value it was made with, the dry run says so in error and nothing
else.
The real one takes {"version": 2}, the version the dry run showed, and
inputs for anything the new version needs. A blueprint that moved on since,
or a rule somebody upgraded in the meantime, is a 409: what was reviewed is
what goes in, or nothing does. A missing input is a 400. 200 with the
same shape, automation being the rule as stored. It is logged as
automation upgraded from blueprint pump_watch version 1 to 2. With the same
version and new inputs, it is how a rule’s inputs are changed. 400 for a
rule written by hand; 404 if the blueprint it came from is gone.
Scripts
Section titled “Scripts”Named sequences of actions, run on demand: from here, from a rule’s
call_script step, from MCP’s run_script, or through the script’s entity
(script.turn_on). The sequence is the automation language. Every write a
step makes goes through the write gate as script:<id>, with the source of
whoever started it. Everything is in SCRIPTS.md.
GET /api/scripts
Section titled “GET /api/scripts”Every script, with its run counters and how many runs of it are going now, in any process:
[{"id":"cip_cycle","name":"CIP cycle","fields":[{"name":"minutes","type":"number","required":true,"min":1,"max":60,"unit":"min"}],"sequence":[{"type":"call_service","params":{"entityId":"switch.cip_pump","service":"switch.turn_on"}}],"mode":"skip","createdAt":"2026-09-28T10:00:00Z","updatedAt":"2026-09-28T10:00:00Z","lastRun":"2026-09-28T10:05:00Z","lastStatus":"succeeded","runCount":3,"running":0}]POST /api/scripts
Section titled “POST /api/scripts”Creates one. Admin-only. id (lowercase letters, digits and underscores,
starting with a letter: it is the entity’s object id, script.<id>), name,
description, icon, fields (typed inputs), sequence (at least one
action), mode (skip, the default, queue, restart or parallel) and
maxRuntimeSec. It is compiled before it is stored, like a rule, and a script
that would call itself, directly or through other scripts, is refused. Answers
201 with the script. 400 for anything that does not compile; 409 for an id
that is taken.
POST /api/scripts/validate
Section titled “POST /api/scripts/validate”Checks a script without saving it, for the editor’s live check. 200
{"valid":true} or {"valid":false,"error":"..."}, in the words a save would
be refused with.
GET /api/scripts/{id}
Section titled “GET /api/scripts/{id}”One script, as in the list. 404 if there is none.
PUT /api/scripts/{id}
Section titled “PUT /api/scripts/{id}”Replaces its definition; the body is the same as for creating one, and its id
may be left out. Admin-only. Runs of it in progress are cancelled when its
steps, fields or mode change: they were started from the old definition.
DELETE /api/scripts/{id}
Section titled “DELETE /api/scripts/{id}”Deletes it and its run history, cancelling its runs in progress. Admin-only.
{"deleted":"cip_cycle"}. Its entity is orphaned, not deleted.
POST /api/scripts/{id}/run
Section titled “POST /api/scripts/{id}/run”Runs it. Operators hold scripts.run.
{"fields":{"minutes":20},"reason":"end of shift clean","wait":false}Every field is optional in the body, and so is the body. The fields are checked
against the script’s before anything runs: a value out of range, off its step,
not one of the options or of the wrong type is a 400, and is never moved to
fit. 202 with the run as soon as it has started; with "wait":true, 200
with the run once it has ended, its trace included.
{"run":{"id":77,"scriptId":"cip_cycle","status":"running","actor":"otto","source":"ui","fields":{"minutes":20},"startedAt":"2026-09-28T10:05:00Z","durationMs":0}}409 when it is busy: running already and its mode is skip, or running with
one queued and its mode is queue. Nothing ran, and a skipped run is in the
history saying why:
{"error":"script busy: script cip_cycle was not started: it is already running, and its mode is skip","run":{"id":78,"scriptId":"cip_cycle","status":"skipped","...":"..."}}?dry_run=true asks what a run would do now and does none of it. The fields
are checked the same way; then each step is walked against the plant as it
is: every write and service call is put to the gate’s own checks, a condition
is evaluated, a variable bound. A wait, a delay and anything that writes to
no equipment are not_checked. The first write that would be refused ends
it, as it would end the run, and error says so in the gate’s words. 200
with the verdict, refused or not:
{"dry_run":true,"check":{"script_id":"cip_cycle","fields":{"minutes":20},"steps":[{"step":"1","type":"call_service","input":"switch.turn_on switch.cip_pump {}","verdict":"refused","detail":"cip/pump: this tag is not marked writable"}],"error":"step 1 (call_service): cip/pump: this tag is not marked writable"}}Each step’s verdict is would_write, refused, ends_here (a condition
false now, or a stop), not_checked or ok. A step of a script it calls is
numbered under the call: 3.1.
POST /api/scripts/{id}/stop
Section titled “POST /api/scripts/{id}/stop”Cancels every run of it in progress, in every process, where it is.
{"cancelled":1,"stopped":"cip_cycle"}, cancelled being the runs this process
had. Operators hold scripts.run: stopping what you may start is part of
starting it.
GET /api/scripts/{id}/runs
Section titled “GET /api/scripts/{id}/runs”Its runs, newest first, with their traces. ?limit= (default 50, at most
1000). parent is what called a run, when something did:
automation_run:812, script_run:77.
[{"id":77,"scriptId":"cip_cycle","status":"succeeded","actor":"automation:shift-end","source":"automation","parent":"automation_run:812","fields":{"minutes":20},"startedAt":"2026-09-28T10:05:00Z","finishedAt":"2026-09-28T10:25:00Z","durationMs":1200012,"steps":[{"index":1,"type":"call_service","input":"switch.turn_on switch.cip_pump {}","output":"true to cip/pump: written; call 8fb9538c82d532be","startedAt":"2026-09-28T10:05:00Z","durationMs":9.6}]}]Recipes
Section titled “Recipes”Sets of values applied together: a product grade’s setpoints, a changeover.
Each one is also an entity, scene.<id>.
A recipe is checked whole before anything goes out and refused whole on any
reason; its targets then go out in order under one call id, the first that
fails stops the rest, and nothing is undone; every written value is read back
from the device. Everything is in RECIPES.md.
A recipe’s name, description, equipment node and permission change in place.
What it writes (targets, params, precondition, verify_sec) is a
version, and versions are never changed: saving new content stores the next
one.
GET /api/recipes
Section titled “GET /api/recipes”Every recipe, with its current version’s content and its newest application:
[{"id":"grade_a","name":"Grade A","equipment_id":7,"apply_permission":"recipes.apply","current_version":3,"params":[{"name":"speed","type":"number","min":0,"max":400,"step":10,"unit":"rpm","required":true}],"precondition":"states['input_boolean.line_ready'] == 'on'","targets":[{"entity_id":"number.line_02_zone1_sp","service":"set_value","data":{"value":210}},{"entity_id":"number.line_02_speed","service":"set_value","data_expressions":{"value":"vars.speed"}}],"verify_sec":10,"note":"zone 1 up 5","created_at":"2026-09-28T10:00:00Z","updated_at":"2026-09-28T11:00:00Z","last_application":{"id":41,"recipe_id":"grade_a","version":3,"call_id":"7c1f0e2d9a8b6c54","actor":"otto","source":"ui","params":{"speed":250},"status":"applied","targets":[],"started_at":"2026-09-28T11:05:00Z","finished_at":"2026-09-28T11:05:01Z"}}]equipment_id is null for a recipe of the whole plant.
POST /api/recipes
Section titled “POST /api/recipes”Creates one, as version 1. Admin-only. id (lowercase letters, digits and
underscores, starting with a letter: it is the entity’s object id,
scene.<id>), name, description, equipment_id, apply_permission
(recipes.apply, the default, or recipes.apply.<name>), params (typed
inputs), precondition (an expression that must be true to start),
targets (at least one, at most 200, each entity_id, service and data
or data_expressions), verify_sec (1 to 600, default 10) and note. A
target sets a value: set_value, select_option, set_position,
set_speed, set_setpoint, set_mode, turn_on, turn_off, on the domains
that take them. A pulse or a start is refused: it belongs in a script. Every
target’s entity must exist, and every expression must compile. 201 with the
recipe; 400 for anything wrong, 409 for an id that is taken.
POST /api/recipes/capture
Section titled “POST /api/recipes/capture”Drafts a recipe from what entities hold now, admin-only, and saves nothing:
{"entity_ids":["number.line_02_zone1_sp","switch.line_02_heater","loop.line_02_tic"]}Each becomes a target that sets it back to its current value, in the order
given; a loop becomes its mode and then its setpoint. The answer is a recipe
without an id or a name, for the editor. An entity that does not exist, is
disabled, reads with a quality other than good, is unknown or
unavailable, or is not something a recipe sets is a 400 naming every one
of them: a recipe built from a bad reading writes the bad reading back.
GET /api/recipes/{id}
Section titled “GET /api/recipes/{id}”One recipe, as in the list. 404 if there is none.
PUT /api/recipes/{id}
Section titled “PUT /api/recipes/{id}”Saves it. Admin-only. The body is the recipe as GET answered it, edited; its
id may be left out. current_version is the version the edit was made to:
if somebody has saved another since, the save is a 409 rather than a quiet
overwrite of theirs (0 skips the check). New content becomes the next
version, with note saying why; a change to only the name, description,
equipment or permission makes none.
DELETE /api/recipes/{id}
Section titled “DELETE /api/recipes/{id}”Deletes it and its versions. Admin-only. {"deleted":"grade_a"}. Its
applications are kept, because they are what was sent to the plant, and its
entity is orphaned.
GET /api/recipes/{id}/versions
Section titled “GET /api/recipes/{id}/versions”Every version, newest first, each exactly as it was saved, with who saved it and the note:
[{"recipe_id":"grade_a","version":3,"params":[],"targets":[{"entity_id":"number.line_02_zone1_sp","service":"set_value","data":{"value":210}}],"verify_sec":10,"note":"zone 1 up 5","created_by":"ada","created_at":"2026-09-28T11:00:00Z"}]GET /api/recipes/{id}/versions/{version}
Section titled “GET /api/recipes/{id}/versions/{version}”One version. 404 if the recipe has no such version, 400 if {version} is
not a number.
POST /api/recipes/{id}/validate
Section titled “POST /api/recipes/{id}/validate”Would it apply right now? Operators hold recipes.apply. The body is an
apply’s, and nothing is written, recorded or logged. Every check an
application makes before it writes is made: the parameters, the recipe’s own
permission, the precondition, the data expressions, whether another
application is writing to one of its entities, and every target put to the
write gate’s checks. 200 with the verdict, would_apply or refused, and
every target’s own: would_write, refused with the reason, or not_sent.
{"dry_run":true,"application":{"recipe_id":"grade_a","version":3,"params":{"speed":250},"status":"refused","targets":[{"index":1,"entity_id":"number.line_02_zone1_sp","service":"set_value","data":{"value":210},"status":"would_write"},{"index":2,"entity_id":"number.line_02_speed","service":"set_value","data":{"value":250},"status":"refused","error":"line-02/Speed: this tag is not marked writable"}],"started_at":"2026-09-28T11:04:00Z","error":"target 2 (set_value number.line_02_speed): line-02/Speed: this tag is not marked writable"}}Parameters it does not take are a 400, as they would be for the real thing.
POST /api/recipes/{id}/apply
Section titled “POST /api/recipes/{id}/apply”Applies it. Operators hold recipes.apply. A recipe that names a permission
of its own, recipes.apply.<name>, takes that as well, checked here against
the recipe; a user without it gets a 403 and a refused application in the
history with their name on it.
{"params":{"speed":250},"reason":"grade A for the 2pm run","version":0,"resume":0}Everything is optional, and so is the body. version applies an older
version on purpose; left out, the current one. resume is the id of an
earlier application of this recipe that stopped halfway: its targets that
were not written go out as a new application, with its version and
parameters, and parent says which it finishes.
It answers once every value has been written and read back, which takes up to
the recipe’s verify_sec:
{"application":{"id":42,"recipe_id":"grade_a","version":3,"call_id":"9d2e4f6a8b0c1e3f","actor":"otto","source":"ui","reason":"grade A for the 2pm run","params":{"speed":250},"status":"applied","targets":[{"index":1,"entity_id":"number.line_02_zone1_sp","service":"set_value","data":{"value":210},"status":"written","writes":[{"tag":"line-02/Zone1_SP","value":"210","status":"written"}],"verification":"verified","verify_detail":"read back 210"},{"index":2,"entity_id":"number.line_02_speed","service":"set_value","data":{"value":250},"status":"written","writes":[{"tag":"line-02/Speed","value":"250","status":"written"}],"verification":"unconfirmed","verify_detail":"not confirmed within 10s: read back 240, not 250"}],"started_at":"2026-09-28T11:05:00Z","finished_at":"2026-09-28T11:05:10Z"}}A target is written, failed (it went out and the device said no, or said
nothing), not_sent, skipped (one before it did not go through) or
refused (pre-validation). A written one is verified when the device read
back the value it was sent after it was sent, and unconfirmed when it did
not within verify_sec: it went out and it may not have taken. Every write
carries the application’s call_id, so GET /api/writes?call=<call_id> is
all of them.
| Status | When |
|---|---|
| 200 | applied: every target written. Check each one’s verification |
| 400 | A parameter the recipe does not take, out of range or of the wrong type; a resume of nothing left to apply |
| 403 | The recipe’s own permission. Recorded as a refused application |
| 404 | No such recipe, or no such version |
| 409 | refused: any reason not to start, every one in error and on its target. Or another application is writing to one of its entities right now. Nothing was written |
| 502 | partial or failed: it started, and not every target went out. targets says exactly which did. Nothing is undone |
Every answer but a 400 for a malformed body or a 404 carries the
application, and every application but a dry run is in
GET /api/recipes/{id}/applications, refusals included.
GET /api/recipes/{id}/applications
Section titled “GET /api/recipes/{id}/applications”Its applications, newest first, refused ones included. ?limit= (default 50,
at most 1000). parent is what applied it, when something did:
automation_run:812, script_run:77, application:41. A deleted recipe’s
applications are still answered.
Blueprints
Section titled “Blueprints”An automation written once with typed blanks in it, filled in to make an
ordinary rule. The rule keeps the version it was made from; editing the
blueprint changes no rule, and moving one to a newer version is
an upgrade somebody asks for. Every
write the rule makes goes through the write gate as automation:<id>.
Everything is in BLUEPRINTS.md.
GET /api/blueprints
Section titled “GET /api/blueprints”Every blueprint, the built-in ones first, each with how many rules were made from it:
[{"id":"motor_running_too_long","name":"Motor running too long","description":"Says so when a motor has been running longer than it should...","source":"builtin","version":1,"inputs":[{"name":"motor","label":"Motor","type":"entity","required":true,"domain":["motor"]},{"name":"max_run","label":"Longest it may run","type":"duration","default":3600},{"name":"stop_it","label":"Stop it as well","type":"boolean","default":false}],"template":{"triggers":[{"type":"state","entityId":{"!input":"motor"},"to":"running","forSec":{"!input":"max_run"}}],"...":"..."},"instances":2}]source is builtin (ships in the binary, read-only) or user.
POST /api/blueprints
Section titled “POST /api/blueprints”Writes one. Admin-only. id (lowercase letters, digits and underscores,
starting with a letter), name, description, inputs (typed, the same as a
script’s fields) and template: the rule, with its description,
triggers, condition, actions, mode, minIntervalSec and
maxRuntimeSec, and the blanks in it. {"!input": "motor"} as a value is
replaced by the input; ${motor} in a string is too, as a literal where the
string is an expression. It is checked before it is stored: every blank names
an input, every input is used, and filled in with sample values it makes a
rule that compiles. 201 at version 1. 400 for anything wrong with it,
409 for an id that is taken, a built-in one’s included.
GET /api/blueprints/{id}
Section titled “GET /api/blueprints/{id}”One, as in the list. 404 if there is none.
PUT /api/blueprints/{id}
Section titled “PUT /api/blueprints/{id}”Replaces its definition; the body is the same as for writing one, and its id
may be left out. Admin-only. version goes up by one when inputs or
template changed, and not for a new name or description. No rule made from
it changes. Each keeps the version it was made from, and
GET /api/automations shows which. A built-in one is a 400: copy it under an
id of your own.
DELETE /api/blueprints/{id}
Section titled “DELETE /api/blueprints/{id}”Admin-only. {"deleted":"pump_watch"}. 409 while any rule made from it
exists, naming them: delete those first. A built-in one is a 400.
POST /api/blueprints/{id}/instantiate
Section titled “POST /api/blueprints/{id}/instantiate”Makes a rule from it and arms it. Operators hold blueprints.apply: which
pump and how long is theirs to pick, and what the rule does was decided when
the blueprint was written.
{"automationId":"infeed_too_long","name":"Infeed pump ran too long","enabled":true,"inputs":{"motor":"motor.infeed_pump","max_run":"90m","stop_it":true}}name defaults to the blueprint’s and enabled to true. Every input is
checked before anything is made: a value out of range, off its step, not one
of its options, an entity of another domain, or of the wrong type is a 400
naming every one that is wrong, and is never moved to fit. An automationId
that is taken is a 409: making a rule never replaces one. 201 with the
rule, its blueprintId, blueprintVersion and blueprintInputs set. It is
logged as automation made from blueprint motor_running_too_long version 1.
?dry_run=true makes nothing. It answers the rule it would make and what
that rule would do if it fired now: the condition evaluated, every write and
service call put to the gate’s checks as automation:<id>, the first refusal
in check.error in the gate’s words. Nothing is stored, written or audited.
{"dry_run":true,"automation":{"id":"infeed_too_long","...":"..."},"check":{"automation_id":"infeed_too_long","steps":[{"step":"1","type":"log_event","verdict":"not_checked"},{"step":"2","type":"condition","input":"true","verdict":"ok","detail":"true now"},{"step":"3","type":"call_service","input":"motor.stop motor.infeed_pump {}","verdict":"would_write","detail":"would write false to line-02/M1_Run"}]}}Dashboards
Section titled “Dashboards”Pages of cards, for the roles and the screens that need them. A dashboard
commands nothing: a card names a tag, an entity, a script or a recipe, and its
button writes through POST /api/write as the person who
pressed it. The wall board is one of them. Everything is in
DASHBOARDS.md.
GET /api/dashboards
Section titled “GET /api/dashboards”The dashboards the caller is offered, in the order the switcher shows them,
the wall board included. Whoever holds dashboards.write is offered all of
them.
[{"id":"wall","name":"Wall board","description":"The tags pinned on the Board screen, as gauges.","kind":"wall","roles":[],"homeFor":[],"screens":["wall-tv"],"position":0,"cards":[],"version":2,"updatedBy":"ada"}, {"id":"line-2","name":"Line 2","kind":"cards","roles":["operator","admin"],"homeFor":["operator"],"screens":["line-2-panel"],"position":1,"version":3,"updatedBy":"ada","cards":[{"id":"temp","type":"gauge","title":"Zone 1","w":3,"h":3,"config":{"tag":"press-01/Zone1_Temp"}}]}]POST /api/dashboards
Section titled “POST /api/dashboards”Builds one. Admin-only. id (lowercase letters, digits, hyphens and
underscores, starting with a letter), name, and optionally description,
roles, homeFor, screens, position and cards. 201 at version 1,
with every card’s size and config in the form that was stored.
A card is id, type, title, w (1 to 12 columns), h (1 to 8 rows) and a
config. The types and their configs are in
DASHBOARDS.md. It is checked before it is stored:
- a config key the type does not have, a number out of range, a camera or
floor-plan address with a login or token in it, or more than 60 cards is a
400; - a card that names a tag, entity, script or recipe that does not exist is a
400naming it; - a
buttonon a tag that is not writable, or with a number outside the tag’s range, is a400: the gate would refuse every press; - a role that does not exist is a
400; - an
idthat is taken, a screen another dashboard already has, or a role’s home another dashboard already is, is a409.
GET /api/dashboards/{id}
Section titled “GET /api/dashboards/{id}”One, as in the list. 404 if there is none, and also if the caller is not
offered it.
PUT /api/dashboards/{id}
Section titled “PUT /api/dashboards/{id}”Replaces it. Admin-only. The body is the dashboard as GET answered it, with
version set to the version it was read at: it is required, and a save
that names a version that is no longer current is a 409 saying what the
current one is, because the alternative is quietly deleting what the last
person to save just built. The id may be left out of the body, kind cannot
change, and createdAt, updatedAt and updatedBy are the server’s and
ignored. 200 with the dashboard at the next version.
The wall board takes a PUT for its assignments (roles, homeFor,
screens, position) and nothing else: it has no cards, so one with cards is
a 400. Its gauges are the board’s tags, GET and PUT /api/board.
DELETE /api/dashboards/{id}
Section titled “DELETE /api/dashboards/{id}”Admin-only. {"deleted":"line-2"}. Cards point at things and own nothing, so
nothing else is touched. The wall board is a 400: it is always there.
Builds, saves and deletes are logged as config events on subject
dashboard:<id>.
Webhooks
Section titled “Webhooks”POST /api/webhook/{path}
Section titled “POST /api/webhook/{path}”Fires every automation with a webhook trigger on that path. No session and
no permission: the path is the secret a third party is given, the same trust
model this endpoint has always had, and a SCADA system or a SaaS callback has
no account to log in with. Pick a path nobody could guess.
The body, up to 1 MiB, is handed to the rule. 202
{"accepted":"fill-now"}. 404 "no automation is listening on this webhook path" when nothing is: a webhook posting into nothing looks identical to one
that works, so it says so.
Registered edge relays, their live presence over Sparkplug, and commands to
them. Needs HOTLOOP_FLEET_ENABLED. See FLEET.md.
GET /api/fleet
Section titled “GET /api/fleet”{"configured":true,"members":[{"id":"press-relay-01","name":"Press line relay","group":"plant-a","nodeId":"press-relay-01","statusUrl":"http://press-relay-01:8081","enabled":true,"createdAt":"2026-09-27T20:28:49.448323Z","updatedAt":"2026-09-27T20:28:49.448323Z","online":false,"polled":true,"error":"Get \"http://press-relay-01:8081/status\": dial tcp: lookup press-relay-01 on 10.89.0.1:53: no such host"}]}online is from NBIRTH and NDEATH. release is the build the relay said it
runs in its last birth: version, imageDigest (when its supervisor says; the
Helm chart doesn’t) and since, when the Gateway first heard that build. A
relay too old to report one has none. For a member with a statusUrl, polled
is true and reachable, forwardQueueLen, devices, connected, config and
updateMode (auto or approve) come from its /status; they mean nothing
when it was not reachable. With the feature off: {"members":[],"configured":false}.
POST /api/fleet
Section titled “POST /api/fleet”Creates or replaces a member: id, name, group and nodeId (its Sparkplug
identity) required; description, statusUrl (http:// or https://) and
enabled optional. Answers the member. This one works with the feature off,
so the fleet can be declared before it is switched on.
DELETE /api/fleet/{id}
Section titled “DELETE /api/fleet/{id}”{"deleted":"press-relay-01"}, or 404.
POST /api/fleet/{id}/rebirth
Section titled “POST /api/fleet/{id}/rebirth”Publishes a Node Control/Rebirth to the relay. No body.
{"status":"rebirth requested"}. 404 for an unknown member.
POST /api/fleet/{id}/restart
Section titled “POST /api/fleet/{id}/restart”Publishes Node Control/Reboot. {"status":"restart requested"}. Admin-only:
restarting a relay drops its data until it is back.
POST /api/fleet/{id}/update
Section titled “POST /api/fleet/{id}/update”Approves the image update a relay is holding for. Publishes Node Control/Update.
{"status":"update approved"}. Admin-only: the relay exits and its supervisor
starts whatever image it follows, so it drops its data for the moment that takes.
It only does something to a relay set to HOTLOOP_EDGE_RELAY_UPDATE_MODE=approve;
any other relay logs that it ignored it. The request names no image: there is
nothing to put one in, which is the point. See
EDGE-RELAY.md.
POST /api/fleet/{id}/config
Section titled “POST /api/fleet/{id}/config”Pushes a new device file to the relay, which applies it live: no restart, its
broker session and forward queue kept, and every device the file didn’t change
left connected. The body is the device file itself, raw JSON, not wrapped
in anything. It is validated before anything is published (400 "invalid device file: ..." if not). {"status":"config pushed"} means the broker took
it; whether the relay is running it is config.digest on that member in
GET /api/fleet, and a file the relay refused shows there as
config.rejected. Admin-only: it changes what the relay polls. See
FLEET.md.
DELETE /api/fleet/{id}/config
Section titled “DELETE /api/fleet/{id}/config”Takes the push back. Clears the retained config on the broker so no later boot
applies it, and a relay connected right now drops its pushed file and goes back
to its local device file live. No body. {"status":"config push cleared"},
404 for an unknown member, 503 when the Gateway isn’t connected to the
broker. Admin-only, like pushing. It doesn’t restart anything. Send it while
the relay is online: an offline relay never hears it. See
EDGE-RELAY.md.
Notifications
Section titled “Notifications”Paging a phone when an alarm is raised, over a webhook, SMTP, ntfy or Gotify.
Needs HOTLOOP_NOTIFY_ENABLED.
POST /api/notify/test
Section titled “POST /api/notify/test”Sends a synthetic alarm notification through every configured channel, now, so
you find out the SMTP relay rejects you before a real alarm does. No body. Needs
HOTLOOP_NOTIFY_ENABLED. Admin-only: it sends a real message and spends
whatever quota the provider allows.
{"results":[{"channel":"smtp","success":true}]}200 whether or not each channel worked; each result says. A failed one
carries error. A failed webhook names its scheme and host and nothing else,
because for most paging services the URL is the credential. See
NOTIFY.md.
POST /api/notify/ack/{token}
Section titled “POST /api/notify/ack/{token}”The Acknowledge button on an ntfy notification. The phone pressing it has
no session to send, so there is no session and no permission: the signed
token in the path is the authorization, and all it authorizes is
acknowledging one occurrence of one alarm, once, before it expires
(HOTLOOP_NOTIFY_ACK_TTL, 12 hours by default). A button from yesterday’s trip
cannot acknowledge today’s. It is a POST on purpose: link previewers and
security scanners open links on their own, and an alarm must never be
acknowledged by a robot. No body. It answers with the alarm as acknowledged (the
shape, from the handler; the other examples in this document were captured from
a running Gateway, this one wasn’t, because that Gateway had no ntfy):
{"acknowledged":true,"alarmId":"extruder-temp-hi","name":"Extruder temperature high","state":"ack-active","ackedBy":"notify:ntfy"}With acknowledge links switched off:
{"error":"acknowledge links are not enabled on this HotLoop (they need HOTLOOP_PUBLIC_URL and a channel that carries a button)"}It is an ordinary acknowledgement through the alarm engine, audited under
notify:<channel>. Every refusal is answered and logged, because a button that
silently did nothing looks exactly like one nobody pressed:
| Status | When |
|---|---|
| 403 | Forged, altered, or signed with a different secret |
| 404 | The alarm no longer exists, or acknowledge links are off (they need HOTLOOP_PUBLIC_URL and a channel with a button) |
| 409 | Already used, the alarm was raised again since, or there is nothing left to acknowledge |
| 410 | Genuine, but expired |
| 503 | The acknowledgement could not be saved; the link is given back so a second press can still work |
The answer carries Access-Control-Allow-Origin: * so the ntfy web app can
press it from its own origin; nothing on this route reads a cookie. The token
is never logged or returned anywhere. See
NOTIFY.md.
Backups
Section titled “Backups”Logical, table-data-only database snapshots. Needs HOTLOOP_BACKUP_ENABLED.
Both admin-only: a listing shows every table’s row count, users and sessions
included. There is no restore endpoint, on purpose: restoring is a flag on
the binary, run once by somebody who knows the database is meant to receive it,
never a network call against a live one. See
BACKUP.md.
GET /api/backup
Section titled “GET /api/backup”The backups on the volume, each with its manifest.
{"backups":[{"name":"20260927T202912Z","bytes":98567,"manifest":{"hotloopVersion":"4.16.0","schemaMigrations":["0001_core.sql","...","0007_service_audit.sql"],"createdAt":"2026-09-27T20:29:12.970721945Z","tables":[{"name":"devices","rows":2,"columns":[{"name":"id","type":"text"},{"name":"...","type":"..."}]}]}}],"configured":true}POST /api/backup
Section titled “POST /api/backup”Takes one now and waits for it (up to 30 minutes: a big tag_history takes
a while). No body. Answers the manifest: version, migrations, and every
table’s rows and columns.
Scheduled reports
Section titled “Scheduled reports”Historian summaries as CSV and HTML, on a schedule, by email or download.
Needs HOTLOOP_REPORTS_ENABLED. Viewers read, operators run, admins define:
a definition names where plant data gets emailed. See
REPORTS.md for the definition’s fields.
GET /api/reports
Section titled “GET /api/reports”{"configured":true,"reports":[{"id":"line1-hourly","name":"Line 1 hourly","enabled":true,"tags":[{"deviceId":"extruder-1","tagName":"Temperature"},{"deviceId":"extruder-1","tagName":"Pressure"}],"window":{"count":1,"unit":"hours","end":"run_time"},"bucket":"window","aggregations":["min","max","avg"],"schedule":{"kind":"daily","at":"06:00"},"format":"both","recipients":["shift-lead@example.com"],"retain":10,"createdAt":"2026-09-27T20:29:23.300978Z","updatedAt":"2026-09-27T20:29:23.300978Z","nextRunAt":"2026-09-28T06:00:00Z"}],"smtpConfigured":true,"timezone":"UTC"}Each definition carries nextRunAt and its most recent run as lastRun.
POST /api/reports
Section titled “POST /api/reports”Creates a definition; the body is the definition as above. 201. 400 on a
failed validation ("report line1-hourly: window end \"now\" must be run_time or local_midnight"), 409 if the id exists.
GET /api/reports/{id}
Section titled “GET /api/reports/{id}”One definition, as in the list. 404 for an unknown one.
PUT /api/reports/{id}
Section titled “PUT /api/reports/{id}”Replaces the whole definition. The id cannot change (400 if the body’s
differs). nextRunAt and lastRun are accepted and ignored, so you can send
back what you read.
DELETE /api/reports/{id}
Section titled “DELETE /api/reports/{id}”Deletes it with its runs and files, cancelling a run in flight.
{"deleted":"line1-hourly"}, or 404.
POST /api/reports/{id}/run
Section titled “POST /api/reports/{id}/run”Runs it now and waits. ?deliver=false generates and stores it without
emailing anyone, for trying a definition out. 200 means the run happened;
its own status says whether the report worked. A run that fails is a result,
not a bad request, and it is recorded either way.
{"id":1,"reportId":"line1-hourly","trigger":"manual","actor":"admin","status":"succeeded","startedAt":"2026-09-27T20:29:54.06926Z","finishedAt":"2026-09-27T20:29:54.092456655Z","windowStart":"2026-09-27T19:29:54.06926Z","windowEnd":"2026-09-27T20:29:54.06926Z","tags":2,"rows":2,"readings":984,"excluded":2,"files":[{"format":"csv","name":"20260927T202954Z-1.csv","bytes":338},{"format":"html","name":"20260927T202954Z-1.html","bytes":2430}],"emailStatus":"skipped","emailTo":["shift-lead@example.com"]}400 for a deliver that is not true or false, 404 for an unknown report.
GET /api/reports/{id}/runs
Section titled “GET /api/reports/{id}/runs”{"runs": [...]}, newest first, ?limit= default 50, at most 500. 404 for
an unknown report, not an empty list.
GET /api/reports/{id}/runs/{run}/download
Section titled “GET /api/reports/{id}/runs/{run}/download”Streams one stored file as an attachment. ?format=csv or html.
HTTP/1.1 200 OKContent-Type: text/csv; charset=utf-8Content-Disposition: attachment; filename="20260927T202954Z-1.csv"Cache-Control: private, no-store
device_id,tag_name,unit,period_start,period_end,min,max,avg,excludedextruder-1,Temperature,C,2026-09-27T19:29:54+00:00,2026-09-27T20:29:54+00:00,19.50001230192043,23.499980145373126,21.508905654397623,1HTML is served with a sandbox Content-Security-Policy: a report is a document
to read, not a page that gets to run anything. The file opened is the one the
run record names; nothing in the URL becomes part of a path. 400 for a run
number that is not one or a format that is neither, 404 for a run or file
that does not exist.
GET /api/sites
Section titled “GET /api/sites”Every site this Gateway federates, asked for its plant status over MCP in parallel, 6 seconds each, so one dead site cannot hold up the rest. The sites are the outbound MCP servers in the config. See MCP.md.
{"configured":true, "rollup":{"sites":2,"reachable":1,"devices":2,"connected":2,"tags":5,"outstandingAlarms":1,"unacknowledgedAlarms":1,"criticalAlarms":0}, "sites":[{"name":"plant-a","reachable":true,"latencyMs":14,"plant":"Demo Plant","uptime":"1m25s","devices":2,"connected":2,"degraded":0,"disconnected":0,"tags":5,"outstandingAlarms":1,"unacknowledgedAlarms":1,"criticalAlarms":0,"writesEnabled":true}, {"name":"plant-c","reachable":false,"error":"connect to MCP server \"plant-c\": calling \"initialize\": ... no such host","latencyMs":49,"devices":0,"connected":0,"degraded":0,"disconnected":0,"tags":0,"outstandingAlarms":0,"unacknowledgedAlarms":0,"criticalAlarms":0,"writesEnabled":false}]}The counts of an unreachable site mean nothing and are left out of the
roll-up: counting a site you cannot reach as zero alarms would be the most
dangerous thing this screen could do. With no sites configured:
{"configured":false,"sites":[]}.
Settings
Section titled “Settings”GET /api/settings
Section titled “GET /api/settings”Every setting, its effective value, where the value came from and the variable that sets it:
{"groups":["Plant","Safety","Polling","Historian","Database","MCP","Server"], "settings":[{"key":"ui.plantName","group":"Plant","label":"Plant name","help":"...","value":"Demo Plant East","kind":"string","source":"stored","envVar":"HOTLOOP_PLANT_NAME","editable":true}, {"key":"safety.allowWrites","group":"Safety","label":"Writes allowed","help":"...","value":true,"kind":"bool","source":"environment","envVar":"HOTLOOP_ALLOW_WRITES","editable":false}, {"key":"database.password","group":"Database","label":"Password","help":"Never shown. It comes from the release Secret.","value":"set","kind":"secret","source":"environment","envVar":"HOTLOOP_DB_PASSWORD","editable":false}]}source is stored, environment or default. A secret is only ever set
or not set. envVar is the variable the value came from. A few settings fall
back to a second variable when the HOTLOOP_ one is blank (TZ for the
timezone, POSTGRES_HOST, POSTGRES_DB, POSTGRES_USER and
POSTGRES_PASSWORD for the database), and when the fallback is what’s in
effect, envVar names it. A variable set to an empty string counts as unset,
the same as the config treats it. The poll settings are shown in milliseconds,
but their variables take a duration (5s, 500ms) or a bare number of seconds.
product in the Server group says which HotLoop this is. A feature this
product doesn’t have that the deployment switched on anyway (say
HOTLOOP_REPORTS_ENABLED=true on IoT) doesn’t stop it starting. It’s logged
as a warning at startup and listed here in an extra group, Not in this product, with its variable and a line saying where it lives. The setting is
left exactly as it is, so the same configuration on a product that has the
feature turns it on. With nothing like that set, the group isn’t there.
PUT /api/settings
Section titled “PUT /api/settings”Changes the plant-identity settings, and only those:
| Key | Type |
|---|---|
ui.plantName |
string, at most 200 characters |
ui.siteId |
string, at most 200 characters |
ui.timezone |
an IANA name this machine knows (America/Chicago) |
ui.confirmWrites |
bool |
{"ui.plantName":"Demo Plant East","ui.timezone":"America/Chicago"}Answers the full settings, as GET does. Any other key is a 400 naming it:
{"error":"safety.allowWrites is set by the deployment and cannot be changed here"}That is deliberate. The write switches decide whether this process can move machinery, and a control that can be turned on from the same browser that issues the writes is not a control. Changing them is a deployment.
Historian targets
Section titled “Historian targets”The plant’s own databases, which HotLoop writes its history to as well as its
own. See HISTORIAN-TARGETS.md. Every role that reads the
settings sees them and how each one is doing. Changing one is admin only,
because a target is somewhere plant data goes. Passwords are sealed with the
device credential key (HOTLOOP_SECRET_KEY_FILE) and never answered with;
passwordSet says whether one is stored, and passwordProblem why a stored
one can’t be opened. Every change goes on the log and takes effect at once.
GET /api/historian/targets
Section titled “GET /api/historian/targets”{"secretKey":true, "kinds":[{"id":"postgres","title":"PostgreSQL or TimescaleDB","defaultPort":5432,"defaultTable":"hotloop_history", "databaseLabel":"Database","tableLabel":"Table","passwordLabel":"Password","usesUsername":true,"usesOrg":false}, {"id":"sqlserver","title":"Microsoft SQL Server","defaultPort":1433,"defaultTable":"hotloop_history", "databaseLabel":"Database","tableLabel":"Table","passwordLabel":"Password","usesUsername":true,"usesOrg":false}, {"id":"mysql","title":"MySQL or MariaDB","defaultPort":3306,"defaultTable":"hotloop_history", "databaseLabel":"Database","tableLabel":"Table","passwordLabel":"Password","usesUsername":true,"usesOrg":false}, {"id":"influxdb","title":"InfluxDB 2 or 3","defaultPort":8086,"defaultTable":"hotloop_history", "databaseLabel":"Bucket (InfluxDB 3: database)","tableLabel":"Measurement","passwordLabel":"API token", "usesUsername":false,"usesOrg":true}], "targets":[{"id":"plant-pg","name":"Plant historian","kind":"postgres","address":"db.plant.local:5432", "database":"plant","username":"hotloop","table":"hotloop_history","useTls":true,"insecureSkipVerify":false, "enabled":true,"createdBy":"admin","passwordSet":true,"createdAt":"2026-10-03T09:12:04Z","updatedAt":"2026-10-03T09:12:04Z", "status":{"state":"down","since":"2026-10-03T10:02:11Z","queued":50000,"queueCap":50000,"written":182340, "backfilled":0,"dropped":1210,"refused":0,"lastWrite":"2026-10-03T10:02:10Z", "lastError":"failed to connect to `user=hotloop database=plant`: dial tcp 10.20.0.9:5432: connect: connection refused", "owedFrom":"2026-10-03T10:02:09Z"}}]}kinds are the databases this HotLoop writes to, and what each calls its
fields: InfluxDB has a token where the others have a password (password
carries it), a bucket where they have a database (database), a measurement
where they have a table (table), and an organization (org) none of them
have. status.state is up,
catching-up (up, and still sending what it’s owed from HotLoop’s own
history), down, starting or off (switched off). queued is what’s
waiting in memory, out of queueCap. written counts readings sent live and
backfilled the ones sent from HotLoop’s own history. dropped counts
readings the queue let go while the target couldn’t keep up: they’re owed,
not lost. refused counts readings the database itself wouldn’t take, with
its reason in lastRefusal. owedFrom and owedTo are what’s still owed;
owedTo is missing while the end is still open, which it is for as long as
the target is down.
POST /api/historian/targets
Section titled “POST /api/historian/targets”{"id":"plant-pg","name":"Plant historian","kind":"postgres","address":"db.plant.local","database":"plant", "username":"hotloop","password":"...","useTls":true}id is 1 to 63 lowercase letters, digits and -, and names the target in the
system log. address is host or host:port, with no login in it; the
kind’s default port fills in a missing one. table defaults to the kind’s
defaultTable and may carry a schema (history.hotloop). org is
InfluxDB 2’s organization. enabled defaults to true. A password needs a key:
without HOTLOOP_SECRET_KEY_FILE it’s a 400, never stored in the clear.
201 with the target and its status; 409 for an id that’s taken.
PUT /api/historian/targets/{id}
Section titled “PUT /api/historian/targets/{id}”Any of the fields above but id; what isn’t sent stays. "password":""
clears the password. A change starts the target over with its new settings,
and what it was owed is still owed. 200 with the target.
DELETE /api/historian/targets/{id}
Section titled “DELETE /api/historian/targets/{id}”Stops writing to it and forgets what it was owed. What it already holds stays
in its database. Switch it off with "enabled":false instead to keep the
settings. 200 {"deleted":"plant-pg"}.
System log
Section titled “System log”GET /api/events
Section titled “GET /api/events”Every change, write, login and connection transition, newest first.
| Query | Details |
|---|---|
category |
Repeatable: system, device, tag, write, alarm, automation, mcp, config, auth |
severity |
Repeatable: debug, info, warning, error |
subject |
Exactly: a device id, a tag key, an entity_id, equipment:4, label:critical |
q |
Case-insensitive search |
minutes |
Only the last N minutes |
limit |
Default 200, at most 5000 |
[{"id":61,"at":"2026-09-27T20:29:23.303795Z","severity":"info","category":"config","subject":"line1-hourly","actor":"admin","message":"report created"}]actor is a user, system, automation:<id> or mcp:<client>. detail,
when there is one, is extra JSON as a string.
Logbook
Section titled “Logbook”GET /api/logbook
Section titled “GET /api/logbook”One timeline of what happened, newest first: entity state changes, writes,
alarm transitions, automation and script runs, and configuration changes and
service calls from the system log. The trails are read where they are and merged, so
each keeps its own retention. Numeric readings are not in it: that is what
GET /api/history is for. See LOGBOOK.md.
| Query | Details |
|---|---|
entity |
Only what is about this entity: its state changes, writes to it and its tags, its alarms, the automation runs it triggers or that wrote to it, config changes to it |
equipment_id |
Only what happened at this node of the equipment tree or under it, by the same rule. Not with entity |
actor |
Case-insensitive part of who did it: a user, automation:<id>, mcp:<client> |
kinds |
Comma-separated or repeated: state, write, alarm, automation, event, script, recipe |
from, to |
RFC 3339 times; from inclusive, to exclusive |
limit |
Default 100, at most 1000 |
before |
The next of the previous page |
{"entries":[ {"at":"2026-09-28T09:14:02.51Z","kind":"state","id":41,"severity":"info","message":"changed to on (was off)","entity_id":"switch.tank_farm_pump","old_state":"off","state":"on","quality":"good"}, {"at":"2026-09-28T09:14:02.18Z","kind":"write","id":88,"severity":"info","message":"wrote true to tank-farm/pump (switch.turn_on on switch.tank_farm_pump) (was 0)","actor":"admin","source":"ui","entity_id":"switch.tank_farm_pump","device_id":"tank-farm","tag":"tank-farm/pump","call_id":"5c9f8545a93b5443"}, {"at":"2026-09-28T09:13:40.02Z","kind":"state","id":37,"severity":"info","message":"changed to closed (was open); 14 changes before this not logged, it was changing faster than the logbook records","entity_id":"valve.infeed","old_state":"open","state":"closed","quality":"good","suppressed":14} ], "next":"MjAyNi0wOS0yOFQwOToxMzo0MC4wMlp8c3RhdGV8Mzc"}id is the row’s id in its own trail (for a state change, the entity’s
immutable id), and with at and kind it is what next encodes: the page
after this one is everything strictly older in that order, whatever was
added in the meantime. next is left out on the last page. suppressed is
how many changes of that entity before this one were not logged, because it
was changing faster than ten at once and then one every two seconds.
400 for an unknown kind, a malformed time, from not before to, a
limit over 1000, a made-up cursor, or entity and equipment_id together;
404 for a node that does not exist. 503 when the logbook is not running
in the process answering.
Live feed
Section titled “Live feed”GET /api/stream
Section titled “GET /api/stream”Server-sent events, one way, for as long as you hold it open. SSE rather than
a WebSocket because the traffic only goes one way, and an event stream
reconnects on its own when the pod restarts under an open dashboard.
EventSource sends the session cookie; a script sends the bearer token.
On connect you get retry: 3000 and then the current picture, so a screen is
populated before anything changes:
retry: 3000
event: snapshotdata: [{"deviceId":"extruder-1","protocol":"opcua","tagName":"Temperature","tagValue":19.553128777917983,"unit":"C","quality":"good","timestamp":"2026-09-27T20:34:23.619589Z"}]
event: alarmsdata: {"alarms":[...outstanding alarms...],"summary":{"critical":0,"high":1,"medium":0,"low":0,"unacked":0,"total":1}}
event: entitiesdata: {"changed":[...every entity state...],"removed":[]}Then, as things happen:
| Event | Data | When |
|---|---|---|
values |
An array of readings, the GET /api/values shape, newest per tag |
Every 250 ms, if anything changed. Coalesced: a plant making five thousand readings a second must not push five thousand messages a second at a browser that repaints sixty times |
entities |
{"changed": [states], "removed": [entity_ids]} |
Every 250 ms, if any entity state changed. removed is an entity disabled, deleted or renamed away from |
alarm |
{"alarm": {...}, "event": "shelved"}, the alarm and what happened to it (raised, acked, cleared, shelved, unshelved, suppressed, unsuppressed) |
Immediately: alarm transitions are rare and each one matters |
device |
{"deviceId":"tank-farm","state":"connected"} |
Immediately: the screen should go red when a device drops, not at the next flush |
logbook |
{"kinds":["state","write"]}, which kinds of logbook entry have something new |
At most once a second. No entries in it, because what a screen should show depends on its filters: it asks GET /api/logbook for its own page |
event: alarmdata: {"alarm":{"definitionId":"extruder-temp-hi","state":"shelved","shelvedUntil":"2026-09-27T20:39:45.839983854Z","shelvedBy":"admin","...":"..."},"event":"shelved"}
event: devicedata: {"deviceId":"tank-farm","state":"connected"}A comment line (: heartbeat 2026-09-27T20:35:00Z) every 20 seconds keeps
proxies from closing an idle connection. A client that cannot keep up misses
values and entities updates rather than stalling everyone else; the next
one carries current values, so nothing is lost for long. The response sets
X-Accel-Buffering: no, because a buffering proxy turns a live feed into a
very slow download.
/mcp and /mcp/
Section titled “/mcp and /mcp/”The Model Context Protocol, streamable HTTP transport, for agents. Any method:
the transport uses POST for calls, GET for the server’s own stream and
DELETE to end a session. The path is HOTLOOP_MCP_PATH (/mcp by default)
and the endpoint is only there when HOTLOOP_MCP_ENABLED is on.
It is not behind the session login. It has its own bearer token,
HOTLOOP_MCP_TOKEN, checked in front of the handler so that a caller without it
cannot even list the tools:
HTTP/1.1 401 UnauthorizedWWW-Authenticate: Bearer realm="hotloop"
unauthorizedWith no token configured the endpoint is open, which is only reasonable behind
a NetworkPolicy, and startup says so in the log. An agent is a caller like any
other: its writes go through the same write gate, narrowed further by
HOTLOOP_ALLOW_MCP_WRITES, and are audited as mcp:<client name>. The tools
and resources are in MCP.md.
POST /mcpAuthorization: Bearer <token>Accept: application/json, text/event-stream
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}answers with an Mcp-Session-Id header and the server’s capabilities and
instructions.
The UI
Section titled “The UI”GET /static/
Section titled “GET /static/”The stylesheet, scripts and icon, straight out of the binary, cacheable but revalidated so a redeployed pod never serves a stale script to a page that is still open. The page itself does not need them (it carries its own inline); they are here for anything that wants them separately. No session: they are the same for everyone.
/
The single page, with its stylesheet and scripts inline so it renders the same
at the root of a host, under a path, or inside the EmberNET dashboard’s iframe.
Gzipped when the browser asks. GET and HEAD only; any other method is 405.
Any other path that no route matches lands here and gets a 404, because
serving the page for a typo would make it look like a working screen. No
session: the page is the login form until you log in.