Skip to content
GitHub

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.

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.

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...e1

Both 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 Unauthorized
WWW-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.

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.

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.

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).

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.

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.

  • 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 no lastPoll or lastGood, an alarm nobody acknowledged has no ackedAt, a fleet member never seen has no lastSeen. 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: timestamps there is Unix milliseconds, because it goes straight into a chart.
  • Durations say their unit in the name: pollIntervalMs, onDelaySec, minutes, durationMs. uptime in status is a Go duration string (11m13s).
  • A tag key is device/tag, the form every ?tag= parameter and tag field takes. Tag names may contain slashes (OPC UA paths, MQTT topics), so only the first slash separates: plc-1/Line/Zone 1 is tag Line/Zone 1 on device plc-1.
  • A numeric query parameter that is not a number is ignored and the default is used, so ?limit=abc is ?limit unset.
  • Empty lists are [], never null, in every response and every stream event, at any depth. You don’t need a null check before .length. A null you do see is never a list: it’s one of the documented nullable fields, like an entity’s equipment_id or a root node’s parent_id. A list field marked optional can still be left out entirely.

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.

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.

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.

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

For the kubelet and load balancers. Neither needs a session: a probe has no account, and neither says anything about the plant.

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.

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.

See AUTH.md for accounts, roles and the first admin.

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.

Ends the session the request came with (cookie or bearer) and clears the cookie. No body. {"status":"logged out"}.

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.

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.

[{"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.

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.

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.

{"status":"deleted"}. 400 for your own account or the last enabled admin, 404 for an unknown user.

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.

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"
}

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}]

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.

A device is one connection over one protocol. What goes in config and address depends on the protocol: see PROTOCOLS.md.

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.

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"}

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.

Deletes the device, its tags and their entities. Its history is kept. {"deleted":"tank-farm"}, or 404.

{"enabled": false} stops polling without deleting anything. Answers {"enabled":false,"id":"tank-farm"}. 404 for an unknown id.

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.

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.

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.

{"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.

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.

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"}]

{"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.

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.

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.

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.

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.

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.

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.

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.

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).

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.

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.

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"}]}]}

One device, the same shape. 404 for one HotLoop hasn’t seen.

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.

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.

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.

{"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.

{"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.

Any of profile, publish, subscribe and password; what isn’t sent stays. 200 with the login, 404 for one that doesn’t exist.

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.

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.

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.

One find, the same shape. 404 for one HotLoop hasn’t heard.

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.

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.

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.

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.

{"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.

{"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.

Any of the fields above but id; what isn’t sent stays. "password":"" clears the password. 200 with the broker.

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"}.

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"}]

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.

{"deleted":"tank-farm/level"}, or 404. The history stays. The tag’s entities become orphans (see ENTITIES.md).

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.

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.

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"]}

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.

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
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.

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.

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.

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":[]}]

{"entity": {...}, "state": {...}}. state is null for a disabled entity. 404 for an unknown one.

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.

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.

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.

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"}

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.

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.

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.

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.

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}}]

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.

One helper, as in the list. 404 for an unknown entity or one that is not a helper.

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": []}.

Deletes the helper and its value. Admin-only. {"deleted":"timer.purge"}. DELETE /api/entities/{entity_id} refuses a helper with a 409 pointing here.

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.

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"]}]
{"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.

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.

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","...":"..."}}

{"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.

[{"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"}]

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.

{"label": {...}, "devices": ["tank-farm"], "entities": []}: the label and what carries it. 404 for an unknown one.

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.

Removes it from everything carrying it. {"deleted":"critical"}, or 404.

{"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.

ISA-18.2: raising, acknowledging and returning to normal are separate things, and the log can tell which happened.

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.

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").

{"deleted":"extruder-temp-hi"}, or 404.

Optional body {"note": "checking the heater bands"}. Answers the alarm as it now is, with ackedBy and ackNote. 404 for an unknown alarm.

{"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.

No body. Answers the alarm back in whatever state its condition puts it. 409 if it was not shelved.

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.

Triggers, a condition, actions. The rule language is in AUTOMATIONS.md.

{"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.

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\""}

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.

{"deleted":"fill-tank"}, or 404.

{"enabled": false} disarms it without deleting it. Answers {"enabled":false,"id":"fill-tank"}, or 404.

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.

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.

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.

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}]

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.

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.

One script, as in the list. 404 if there is none.

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.

Deletes it and its run history, cancelling its runs in progress. Admin-only. {"deleted":"cip_cycle"}. Its entity is orphaned, not deleted.

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.

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.

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}]}]

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.

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.

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.

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.

One recipe, as in the list. 404 if there is none.

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.

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.

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"}]

One version. 404 if the recipe has no such version, 400 if {version} is not a number.

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.

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.

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.

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.

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.

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.

One, as in the list. 404 if there is none.

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.

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.

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"}]}}

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.

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"}}]}]

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 400 naming it;
  • a button on a tag that is not writable, or with a number outside the tag’s range, is a 400: the gate would refuse every press;
  • a role that does not exist is a 400;
  • an id that is taken, a screen another dashboard already has, or a role’s home another dashboard already is, is a 409.

One, as in the list. 404 if there is none, and also if the caller is not offered it.

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.

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>.

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.

{"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}.

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.

{"deleted":"press-relay-01"}, or 404.

Publishes a Node Control/Rebirth to the relay. No body. {"status":"rebirth requested"}. 404 for an unknown member.

Publishes Node Control/Reboot. {"status":"restart requested"}. Admin-only: restarting a relay drops its data until it is back.

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.

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.

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.

Paging a phone when an alarm is raised, over a webhook, SMTP, ntfy or Gotify. Needs HOTLOOP_NOTIFY_ENABLED.

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.

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.

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.

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}

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.

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.

{"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.

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.

One definition, as in the list. 404 for an unknown one.

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.

Deletes it with its runs and files, cancelling a run in flight. {"deleted":"line1-hourly"}, or 404.

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.

{"runs": [...]}, newest first, ?limit= default 50, at most 500. 404 for an unknown report, not an empty list.

Streams one stored file as an attachment. ?format=csv or html.

HTTP/1.1 200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="20260927T202954Z-1.csv"
Cache-Control: private, no-store
device_id,tag_name,unit,period_start,period_end,min,max,avg,excluded
extruder-1,Temperature,C,2026-09-27T19:29:54+00:00,2026-09-27T20:29:54+00:00,19.50001230192043,23.499980145373126,21.508905654397623,1

HTML 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.

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":[]}.

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.

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.

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.

{"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.

{"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.

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.

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"}.

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.

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.

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: snapshot
data: [{"deviceId":"extruder-1","protocol":"opcua","tagName":"Temperature","tagValue":19.553128777917983,"unit":"C","quality":"good","timestamp":"2026-09-27T20:34:23.619589Z"}]
event: alarms
data: {"alarms":[...outstanding alarms...],"summary":{"critical":0,"high":1,"medium":0,"low":0,"unacked":0,"total":1}}
event: entities
data: {"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: alarm
data: {"alarm":{"definitionId":"extruder-temp-hi","state":"shelved","shelvedUntil":"2026-09-27T20:39:45.839983854Z","shelvedBy":"admin","...":"..."},"event":"shelved"}
event: device
data: {"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.

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 Unauthorized
WWW-Authenticate: Bearer realm="hotloop"
unauthorized

With 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 /mcp
Authorization: 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 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.