- Extension traffic never gets in the way of an update. Extensions are negotiated after the
devicetopic is joined, never before. - Both sides have to agree. An extension nobody asked for is never sent. A device that starts reporting something an operator did not turn on is worse than one that reports nothing.
The handshake
Four frames, in this order.device topic, and only to devices declaring device_api_version >= 2.2.0. The payload is every version of every extension this deployment implements and has switched on, newest first per key. An extension turned off for the deployment is absent entirely.
2. The device answers by joining. One version per extension, and only extensions it wants to serve. This frame is the device’s commitment — there is no second choice in it, which is why frame 1 exists.
3. The platform replies with the attach list. The subset of what the device offered that this device may use, which is narrower than what the platform implements — an extension can be switched off per product or per device. Keys only, no versions; the device already knows what it declared.
An extension left out here is not attached. Worth reporting locally, because from the outside it is indistinguishable from a feature quietly not working.
4. The device confirms each one. Only after <key>:attached does the platform start asking that extension for anything.
Everything after the handshake is scoped <key>:<event> in both directions.
Choosing a version
For each extension it implements, a client walks its own versions, most preferred first, and takes the first that also appears in the platform’s list for that key. Match by string equality — there is no version arithmetic to do here, and requiring it would mean a version parser in Erlang on AtomVM and another in Rust, to answer a question the platform has already answered by listing what it has.Do not wait indefinitely for frame 1. A platform predating the advertisement never sends it, and a client that waits forever loses every extension against those platforms. Join anyway five seconds after the
device topic’s join reply, using the fallback above.Versions
Every extension and the versions the platform implements, newest first. Old and new versions run side by side indefinitely — devices in the field do not upgrade in step with the platform, and some never upgrade at all.logging is the only extension with more than one version in service, which makes it the one worth testing a client’s negotiation against.
health
Device metrics, metadata and alarms, on a pace the platform sets.check goes out on one timer per connection, so however many people have the device’s page open, the device is asked once. The pace has two modes:
The first idle interval is offset randomly so a fleet that connected together does not answer together. Opening a page announces itself and switches to the watched pace immediately; closing one cannot announce itself, so the pace is reconsidered on each check and the last person leaving costs the device one extra report.
metrics
Numbers a device measures about itself, batched, on a pace the platform sets.docs/metrics.md.
geo
Device location, from GeoIP or a resolver you configure.interval_minutes of 0). A device that moves can push an update without being asked.
logging
Log lines from the device. This is the one extension with two versions in service, and they differ in payload.- 0.1.0 — batched
- 0.0.1 — one line per message
local_shell
A shell on the device, relayed to whoever is watching.network_identity
Identities the device holds on networks NervesHub does not run — an iroh endpoint id, a NetBird or Tailscale peer key.geo and health: an identity is long-lived by construction, so polling for it would be noise. A device whose details have moved — it switched relay, it was assigned a new overlay IP — can push report again at any time.
error_reports
Exceptions and explicit error reports, grouped into issues.timestamp, kind and reason are required; a report missing any of them is dropped and its neighbours in the batch are kept.
Device vitals go in context rather than being fields of their own, so a device with free heap and signal strength to report sends those under its own names and nothing on the server changes. uptime_ms, free_memory_bytes and reboot_count get friendly labels and units in the UI; everything else renders as it arrived.
firmware_uuid is the exception and is a field — it answers “which release broke this”, is carried on the issue as well as the occurrence, and the platform fills it in from the device’s connection when a report omits it.
Full contract: docs/error_reports.md.

