Any system can be a source.
Your system doesn't have to learn anything new. If it speaks HTTPS and AES-GCM, it sends by itself. If not, something next to it fetches the numbers, such as a Node-RED flow or a script, and does the rest: build the JSON, seal it, PUT it to the relay.
Build. Seal. Send.
Andon asks for nothing more. What a number means is your source's call. How it looks is the app's.
Build the document
A JSON document to the schema: header and tiles. Tiles with a verdict carry a status from your thresholds. The app takes the verdict as given and recalculates nothing.
Seal the document
AES-256-GCM with a fresh nonce. The view ID and kv go in as AAD. Any language with AES-GCM can do it. With the Node-RED package this step goes away.
Send the envelope
One PUT to the relay, the write secret as bearer token. The relay throttles per view.
One complete document per view.
The schema is closed: an unknown field is a mistake, not an extension. Checking for it is your source's job. The relay only sees the envelope, and the app skips what it does not know. A mistyped field is then simply missing on the widget. The configurator validates against the schema. For your own scripts, take the schema and a validator for JSON Schema 2020-12.
| Field | Type | Required | Meaning |
|---|---|---|---|
| id | string (uuid) | yes | UUID of the view, stable across all uploads. Not to be confused with the relay's view ID (vw_ plus 26 base32 characters): that one is only in the envelope and the path, never in the document. |
| schemaVersion | const 1 | yes | Always 1, the schema rejects any other value. With a higher value the app does not render the document and asks for an app update. |
| name | string, 1–60 | yes | Display name of the view on the device. |
| generatedAt | string (date-time, Z) | yes | When the data was collected, not when it was uploaded. Reference point for staleAfterSec and the end of open timeline segments. |
| staleAfterSec | integer ≥ 1 | yes | Threshold in seconds. Once now minus generatedAt exceeds it, the app sets every tile's effective status to stale, regardless of the status delivered. Values are still rendered, in grey. |
| tiles | array, minItems 1 | yes | Display order, the app does not reorder. Unreadable entries and unknown view values are rendered as placeholders rather than dropped. |
| $schema | string | no | For editor support. The schema declares the field explicitly, the only exception in the closed object. The app ignores it. |
{
"id": "3f6c2a8e-91d4-4b7a-bc15-0e7d5f2a9c41",
"schemaVersion": 1,
"name": "Line 1",
"generatedAt": "2026-09-26T13:30:05Z",
"staleAfterSec": 180,
"tiles": [
{ "id": "t_state", "view": "label", "title": "Line 1",
"status": "ok", "value": "Running",
"subtext": "Shift until 22:00" },
{ "id": "t_oee", "view": "gauge", "title": "OEE line 1",
"titleShort": "OEE L1", "status": "ok",
"value": 80.2, "unit": "%", "decimals": 1,
"range": { "min": 0, "max": 100 }, "target": 85,
"bands": [ { "to": 60, "status": "critical" },
{ "to": 80, "status": "warning" },
{ "to": 100, "status": "ok" } ] }
]
}One view is one language. The document has no language field, and deliberately so: name, titles, subtexts and categories are the words of the place the numbers come from. The app shows what it is given and translates nothing. If you need two languages, you send two views: the same numbers, a second document, a second QR code. That is what the demo views do.
What all tiles have in common.
Keep tile IDs stable. A widget remembers the view and the tile ID, not the content. Change an ID and it no longer finds its tile. Reuse it and it shows the new tile without notice.
| Field | Type | Required | Meaning |
|---|---|---|---|
| id | string, [A-Za-z0-9._-], 1–64 | yes | Unique within the document, stable across all uploads. Uniqueness is checked by the configurator, the schema cannot express it. |
| view | enum, see tile kinds | yes | The tile kind. Decides which further fields are required, allowed or rejected. The app renders an unknown value as a placeholder. |
| title | string, 1–60 | yes | Heading in the app and the widget, single line in the widget. |
| titleShort | string, 1–12 | no | Replaces title in the small widget and on the Lock Screen, falling back to title when absent. The tightest length limit in the format. |
| icon | string (symbol name or none) | no | SF Symbols name in the header. If it is absent or unknown to the device, the app uses the kind's default symbol. none hides the symbol. |
| status | enum: ok, warning, critical, stale | no | The source's verdict, sets colour and status symbol. Absent means neutral, an unknown value counts as no status. The app also sets stale itself once staleAfterSec has passed. |
| subtext | string, ≤ 80 | no | An extra line, hidden in the small widget. Times of day rather than durations: “since 14:32” stays true, “3 minutes ago” does not. |
What every kind requires, allows and rejects.
The matrix mirrors the schema. A dash means: the validator rejects the field. The app skips it without an error.
| label | gauge | progress | line | bar | pie | donut | table | timeline | |
|---|---|---|---|---|---|---|---|---|---|
| value | ● | ● | ● | ○ | – | – | ○ | – | – |
| valueIcon | ○ | – | – | – | – | – | – | – | – |
| unit, decimals | ○ | ○ | ○ | ○ | ○ | ○ | ○ | – | – |
| range | – | ● | ● | ○ | – | – | – | – | – |
| target | – | ○ | ○ | ○ | ○ | – | – | – | – |
| bands | – | ○ | ○ | – | – | – | – | – | – |
| categories | – | – | – | – | ○ | ● | ● | – | – |
| series | – | – | – | ● | ● | ● | ● | – | – |
| statuses (in a series) | – | – | – | – | ○ | – | – | – | – |
| columns, rows | – | – | – | – | – | – | – | ● | – |
| from, to, states, lanes | – | – | – | – | – | – | – | – | ● |
label
The only kind whose value may be a string (“Running”), and the only one with a valueIcon, the symbol in front of the value.
gauge and progress
The same data, a number on a scale, drawn differently. Both need range. bands colour the scale, target marks the goal.
line
series as a time series, as samples or as a grid. value is optional and appears as the current value next to the chart.
bar
The mode follows from categories. With categories: values per category, several series side by side, statuses only with exactly one series. No bar carries the tile's status, because the order comes from you. Without categories: time series as in line. With one series the last bar is now and carries the tile's status. Mixing the two modes is rejected by the schema.
pie and donut
Shares of one whole: exactly one series of values, one per category. donut shows value in the middle, pie takes no value. Neither takes statuses, because their palette deliberately leaves out the traffic-light colours. The app shows negative values as faulty.
table and timeline
Carry no value, no unit, no decimals. Their numbers live in cells and segments and bring their formatting there.
What the values look like.
Timestamp
ISO 8601 in UTC, ending in Z: 2026-09-22T18:30:05Z; fractional seconds are allowed. An offset like +02:00 is rejected. Applies to every timestamp in the document.
"generatedAt": "2026-09-22T18:30:05Z"
Symbol
An SF Symbols name of lowercase letters and digits, dot-separated: server.rack, bolt.fill. none switches the default symbol off. Whether the symbol exists is checked by the app alone. An unknown name falls back: for icon to the kind's symbol, for valueIcon to the status symbol. SF Symbols ↗
"icon": "server.rack", "valueIcon": "none"
Scale and bands
A band runs up to to and always carries a status; the first starts at range.min. Bands belong to the scale, not to the value: they colour the background of gauge and progress and nothing else. The app never derives the tile's status from them. The source sets it itself, consistent with the bands. If the last band does not reach range.max, the rest of the scale stays uncoloured. The order in the array does not matter; the app sorts by to.
"range": { "min": 0, "max": 100 }, "target": 85, "bands": [ { "to": 60, "status": "critical" }, { "to": 80, "status": "warning" }, { "to": 100, "status": "ok" } ]
Series
Three shapes. samples: a timestamp and a number per point, for measurements that arrive when they arrive. The order does not matter; the app sorts by time. start, stepSec, points: an even grid, far smaller on the wire. values: one number per category in the order of categories. Exactly as many numbers as categories, otherwise the app shows the tile as faulty. Every series needs a name. From two series on, the legend shows it. statuses: optional next to values, only on a bar with categories and exactly one series. One status per bar in the same order, or null for neutral: the accent colour, not grey, because grey means stale. Traffic lights only for bars that carry a verdict, the rest stays null. The schema does not check the length of statuses: missing entries count as null, the app drops surplus ones.
{ "name": "Actual",
"samples": [ ["2026-09-22T20:34:46Z", 81.75],
["2026-09-22T21:34:46Z", 80.32] ] }
{ "name": "Actual", "start": "2026-09-22T19:30:00Z",
"stepSec": 900, "points": [356, 344, 332] }
{ "name": "Today", "values": [21, 17, 31] }
{ "name": "Load", "values": [97, 85, 40],
"statuses": ["critical", "warning", null] }Table
At most 6 columns and 50 rows. That is the ceiling, not a target. The widget shows only the first rows, depending on its size, and the small one only the first and the last column. The app shows the whole table. Unit and decimals belong to the column, the cells stay plain numbers. A cell is text, a number, or null for “no value”. null is not 0. rows may be empty. Every row carries exactly as many cells as there are columns. The schema cannot check this, and the app otherwise shows the tile as faulty.
"columns": [ { "title": "Machine" }, { "title": "OEE", "unit": "%", "decimals": 1 } ], "rows": [ { "cells": ["Press 1", 42.7], "status": "warning" }, { "cells": ["Press 2", null] } ]
Timeline
states declare what can happen (up to 8, IDs up to 32 characters). A state without status is coloured from the category palette. lanes are the rows (up to 8, up to 200 segments). A segment is [start, state-id], ascending, and lasts until the next one. The last one ends at min(to, generatedAt), and to may lie in the future. If a segment points to an unknown state or the segments are not ascending, the app shows the tile as faulty. Likewise if to is not after from or a state ID occurs twice.
"from": "2026-09-22T20:00:00Z", "to": "2026-09-23T04:00:00Z", "states": [ { "id": "run", "label": "Running", "status": "ok" }, { "id": "maint", "label": "Maintenance" } ], "lanes": [ { "name": "Press 1", "segments": [ ["2026-09-22T20:00:00Z", "maint"], ["2026-09-22T20:16:00Z", "run"] ] } ]
A statement, not decoration.
The status is the source's verdict, and the app takes it as given. Devices are woken only when the envelope carries notify: true. Set it when a tile changes its status, appears or disappears, and not otherwise. A status inside a tile, say in a table row, does not count. If a value hovers around a threshold, hysteresis belongs in the source: every wake-up costs battery.
The document has no colour field. The app colours pie and donut segments and timeline states without a status from a fixed palette: eight shades from blue to violet. Green, amber, red and grey are not in it, they are reserved for status. With more categories than colours it starts over. bar with one series: accent colour. Over time the last bar carries the tile's status; over categories statuses colours each bar. Several series: one colour each and a legend. A tile shows its status as a stripe along its left edge and a symbol in its header. The number itself keeps the normal text colour.
| ok | green | Everything as intended. |
| warning | amber | Somebody should look, nothing is broken. |
| critical | red | Somebody has to act now. |
| stale | grey | The value is old or missing. Says nothing about the thing: a silent probe is stale, a broken machine is critical. |
| default | accent colour | No status set, so neutral: a number without a verdict. |
Seal it yourself, in any language.
The relay checks the format only: it never holds the content key and never sees plaintext.
{
"v": 1,
"view": "vw_totavbtprh6rdpgg2m2f6vrwny",
"kv": 3,
"nonce": "fDcgsU7nTxZlN5Op",
"ct": "…",
"notify": true
}| Rule | Value | Note |
|---|---|---|
| Algorithm | AES-256-GCM | 16-byte tag at the end of ct |
| Content key | 32 bytes | Generated on your side, never reaches the relay. |
| Nonce | 12 bytes | Cryptographically random, fresh for every envelope, never twice with the same key. |
| AAD | andon-v1|<view>|<kv> | UTF-8, kv in decimal without leading zeros. notify is not part of it: it is an instruction to the relay, not content. |
| Plaintext | JSON | The view document, byte for byte as your source produced it. |
| Field order | v, view, kv, nonce, ct, notify | Always write notify, even as false. |
| Encoding | base64, with padding | For nonce and ct, standard alphabet, not URL-safe. |
| Size | ≤ 262144 bytes | The whole envelope, not the plaintext. |
| Rotation | kv + 1 | New content key, new QR code for every device. |
One PUT, one status code.
The relay checks every upload in the order of the table. The first check that fails decides the response. The error response is JSON with a stable code in error and an explanation in message. The stored envelope then stays unchanged. With 429, Retry-After says after how many seconds the next upload is accepted. If the envelope passes every check, the relay answers 204.
# Upload: envelope by PUT, write secret as bearer token curl -sS --fail-with-body -X PUT "$ANDON_URL/v1/views/$ANDON_VIEW/envelope" \ -H "Authorization: Bearer $ANDON_WRITE_SECRET" \ -H "Content-Type: application/json" \ --data-binary @envelope.json # 204 = stored · 400 = bad envelope · 401 = wrong secret # 404 = unknown view · 413 = over 256 KiB · 429 = throttled, see Retry-After # Who is reading? Device names come back # encrypted (label_enc). curl -sS "$ANDON_URL/v1/views/$ANDON_VIEW/grants" \ -H "Authorization: Bearer $ANDON_WRITE_SECRET"
| Check in the relay | Error code | HTTP |
|---|---|---|
| View ID in the path has the right format | not_found | 404 |
| Total size at most 262144 bytes | envelope_too_large | 413 |
Bearer token present in the Authorization header | unauthorized | 401 |
| The view exists | not_found | 404 |
| Write secret matches the view | unauthorized | 401 |
| Valid JSON object, nothing after it | envelope_invalid_json | 400 |
v present and equal to 1 | envelope_unsupported_version | 400 |
view present and equal to the view ID in the path | envelope_view_mismatch | 400 |
kv integer from 1 to 2147483647 | envelope_invalid_kv | 400 |
nonce base64, exactly 12 bytes | envelope_invalid_nonce | 400 |
ct base64, at least 16 bytes | envelope_invalid_ct | 400 |
notify absent, null or boolean | envelope_invalid_notify | 400 |
| At most one upload every 10 seconds | throttled | 429 |
The pairing link.
An andon:// link as a QR code. The configurator makes it for you. For anyone building it themselves, here are the details.
andon://pair ?r=https%3A%2F%2Frelay.andon.app &v=vw_y4hhwcih4knngvkrpzjtrevaau &i=is_9Xk2QvT7Ld0sPmZaR4HcNy1BgWuEoIjF3rTxVbK6pQs &k=ZctIEt0J-GkaBRjav9PcP_qIVsmhuzoDGxGDHkkezjY &kv=1 # r relay URL, https, percent-encoded # v view ID vw_ + 26 characters base32 # i invite secret is_ + 43 characters base64url # k content key 43 characters base64url (256 bit) # kv key version integer from 1 # QR code: error correction M, quiet zone 4 modules, # at least 4 cm across, no logo
https only
r is the relay address without a trailing slash, percent-encoded. The app rejects http:// and makes no request to the relay on a malformed link.
Two kinds of base64
i and k are base64url without padding and are not percent-encoded on top. Unlike nonce and ct in the envelope, which are standard base64 with padding.
Valid more than once
The same invite secret pairs any number of devices. One code is enough for the whole team.
Revoking renews the invitation
When a device is revoked, the relay renews the invite secret in the same step. Old codes pair no new devices, paired ones stay.
The key is in the code
Whoever photographed the code can open every envelope of this key version. Only a new content key with kv plus one makes it worthless. Treat it like a password: not by chat, not in tickets, not in screenshots.
The routes a source needs.
Responses with a body are JSON, and errors come as error plus message with stable codes. The relay deletes a view 30 days after its last upload, and one with no upload and no paired device after 10.
| Route | Purpose | Response |
|---|---|---|
| Create · no secret, rate-limited per IP | ||
| POST /v1/views | Create a view. The secrets are in this response only; the relay stores hashes. | 201 {id, write_secret, invite_secret} |
| Source · bearer = write secret | ||
| PUT /v1/views/{id}/envelope | Send an envelope | 204 |
| GET /v1/views/{id}/grants | Paired devices, names encrypted (label_enc). A device can also remove itself. | 200 [grant] |
| DELETE /v1/views/{id}/grants/{device_id} | Revoke a device; the invitation is renewed in the same step | 200 {invite_secret} |
| POST /v1/views/{id}/rotate | Renew the write or invite secret, secret in the body: write or invite | 200 {write_secret} | {invite_secret} |
| DELETE /v1/views/{id} | Delete the view | 204 |
One script builds, one seals.
The raw way, by hand: two scripts joined by a pipe. You see every step. The Node-RED package does all of this for you.
Source
Fetches the numbers, sets the statuses and writes the view document to stdout. Knows no key.
#!/usr/bin/env bash # Server status as an Andon view: load, disk, services. Writes the document to # stdout; andon-seal.mjs or andon_seal.py seals and sends it. The status is decided HERE. set -euo pipefail now=$(date -u +%Y-%m-%dT%H:%M:%SZ) load=$(awk '{print $1}' /proc/loadavg) cores=$(nproc) disk=$(df --output=pcent / | tail -1 | tr -dc '0-9') pg_status=ok; pg_isready -q || pg_status=critical # Bands and status belong together: the same threshold for the colour and the verdict. load_pct=$(awk -v l="$load" -v c="$cores" 'BEGIN{printf "%.0f", l/c*100}') load_status=ok [ "$load_pct" -gt 70 ] && load_status=warning [ "$load_pct" -gt 90 ] && load_status=critical jq -n --arg now "$now" --arg host "$(hostname -s)" \ --argjson load_pct "$load_pct" --arg load_status "$load_status" \ --argjson disk "$disk" --arg pg "$pg_status" ' { id: "b7d41f92-3c58-4ad6-9e07-58c1f2a4d130", schemaVersion: 1, name: $host, generatedAt: $now, staleAfterSec: 180, tiles: [ { id: "t_pg", view: "label", title: "PostgreSQL", icon: "cylinder", status: $pg, value: (if $pg == "ok" then "Online" else "Down" end) }, { id: "t_load", view: "gauge", title: "Load per core", titleShort: "Load", status: $load_status, value: $load_pct, unit: "%", decimals: 0, range: { min: 0, max: 150 }, bands: [ { to: 70, status: "ok" }, { to: 90, status: "warning" }, { to: 150, status: "critical" } ] }, { id: "t_disk", view: "progress", title: "Disk /", titleShort: "Disk", status: (if $disk >= 90 then "critical" elif $disk >= 80 then "warning" else "ok" end), value: $disk, unit: "%", decimals: 0, range: { min: 0, max: 100 } } ] }'
Seal
Reads the document from stdin, seals it and sends it. Knows nothing about your data.
#!/usr/bin/env node // Reads a view document from stdin, seals it (AES-256-GCM) and uploads it by PUT. // Configuration from the environment (ANDON_*). Node.js 18 or newer. import { createCipheriv, createHash, randomBytes } from 'node:crypto' import { readFileSync, writeFileSync, existsSync } from 'node:fs' const need = (k) => process.env[k] || (() => { throw new Error(k + ' is not set') })() const url = need('ANDON_URL').replace(/\/+$/, '') const view = need('ANDON_VIEW') const key = Buffer.from(need('ANDON_CONTENT_KEY'), 'base64url') if (key.length !== 32) throw new Error('ANDON_CONTENT_KEY must decode to 32 bytes') const kv = Number(process.env.ANDON_KEY_VERSION || 1) const cache = process.env.ANDON_CACHE || '/tmp/andon-' + view + '.fp' const doc = JSON.parse(readFileSync(0, 'utf8')) // Only a changed status wakes the devices: fingerprint of tile id + status. // ANDON_NOTIFY=always|never overrides it. No cache yet means: wake, to be safe. const fp = createHash('sha256').update(doc.tiles.map(t => t.id + '=' + (t.status ?? '-')).sort().join('\n')).digest('hex') const mode = process.env.ANDON_NOTIFY || 'auto' const notify = mode === 'always' || (mode !== 'never' && (!existsSync(cache) || readFileSync(cache, 'utf8') !== fp)) // Fresh 12-byte nonce, AAD "andon-v1|<view>|<kv>", 16-byte tag appended to the ciphertext. const nonce = randomBytes(12) const cipher = createCipheriv('aes-256-gcm', key, nonce, { authTagLength: 16 }) cipher.setAAD(Buffer.from('andon-v1|' + view + '|' + kv)) const ct = Buffer.concat([cipher.update(JSON.stringify(doc), 'utf8'), cipher.final(), cipher.getAuthTag()]) // Field order v, view, kv, nonce, ct, notify; nonce and ct as standard base64 with padding. const body = JSON.stringify({ v: 1, view, kv, nonce: nonce.toString('base64'), ct: ct.toString('base64'), notify }) if (Buffer.byteLength(body) > 262144) throw new Error('envelope larger than 256 KiB') const res = await fetch(url + '/v1/views/' + view + '/envelope', { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + need('ANDON_WRITE_SECRET') }, body, }) if (res.status === 204) { writeFileSync(cache, fp); console.log('ok', Buffer.byteLength(body), 'bytes, notify=' + notify) } else { console.error('relay answered', res.status, await res.text()); process.exit(1) }
Call
The pipe connects the two. The values come from the configurator's .env, as environment variables. A timer such as cron repeats the call.
$ ./metrics.sh | node andon-seal.mjs ok 974 bytes, notify=true $ sleep 30; ./metrics.sh | node andon-seal.mjs ok 975 bytes, notify=false $
Check before you seal.
The relay sees only the envelope, and the app reads tolerantly. So only your source can check: with these files and a validator for JSON Schema 2020-12.
The binding schema of the view document. The configurator checks against the same one.
view-v1.sample.jsonA complete view document with every tile kind, valid against the schema. A good place to start.
envelope-v1.schema.jsonThe shape of the envelope, to catch mistakes early. Only the relay checks whether view matches the path, and the size.
Design a view. Pair it. Manage it.
In the configurator you put the tiles together and see them as on the iPhone, checked against the schema. Then you create the view, pair your iPhone or iPad by QR code and send a first test. You manage the devices there too. The content key never leaves your browser.