Admin API
Byonk exposes a token-gated management API under /api/admin/*. It lets you read device
telemetry, manage device-to-screen mappings, inspect the effective config, and update
global settings — all without restarting the server.
Enabling the API
The admin API is disabled by default. If no token is configured, every /api/admin/*
request returns 404 Not Found — the route is invisible to unauthenticated callers.
To enable it, provide a secret token in either of these ways (the environment variable takes precedence):
BYONK_ADMIN_TOKEN=mysecrettoken # environment variable
or in config.yaml:
admin:
token: mysecrettoken
Authentication
Every request must include the token as a Bearer credential:
Authorization: Bearer mysecrettoken
| Situation | HTTP status |
|---|---|
| No token configured (admin disabled) | 404 Not Found |
Authorization header missing or wrong token | 401 Unauthorized |
| Token correct | request proceeds |
The comparison is constant-time to avoid timing side-channels.
Endpoints
GET /api/admin/devices
Return all known devices: every device that has been seen (with telemetry) merged with its config mapping, plus any configured devices that have never connected yet.
Response 200 — array of device objects:
[
{
"key": "AA:BB:CC:DD:EE:FF",
"mac": "AA:BB:CC:DD:EE:FF",
"registration_code": "ABCD-1234",
"registered": true,
"reserved": false,
"model": "og",
"firmware_version": "1.7.1",
"last_seen": "2026-06-28T10:15:00+00:00",
"battery_voltage": 4.12,
"rssi": -58,
"screen": "examples/swiss-departure-board",
"dither": "atkinson",
"panel": null,
"colors": null,
"params": { "station": "Olten, Südwest", "limit": 8 }
}
]
Field notes:
key— the config map key for this device (MAC or registration code).registered—trueif the device appears in thedevices:config section.reserved—truefor the reservedDEFAULTdevice (byonk-managed fallback),falsefor physical devices.registration_codeis an empty string for devices that appear in config but have never connected (it is nevernull).- Telemetry fields (
model,firmware_version,last_seen,battery_voltage,rssi) arenullfor devices that are configured but have never connected. screen,dither,panel,colors,paramsreflect the resolved config mapping; they arenullwhen the device has no mapping.
GET /api/admin/pending
Return devices that have contacted the server but are not yet registered (i.e., they appear
in the device registry but have no matching entry in the devices: config section).
Response 200 — array of pending-device objects:
[
{
"mac": "AA:BB:CC:DD:EE:FF",
"registration_code": "ABCD-1234",
"model": "og",
"firmware_version": "1.7.1",
"last_seen": "2026-06-28T09:00:00+00:00"
}
]
Use registration_code or mac as the key when calling POST /api/admin/devices.
GET /api/admin/config
Return the effective configuration as JSON, parsed from the on-disk config.yaml. The
admin.token field is stripped from the response.
Response 200 — the full config as a JSON object (structure mirrors config.yaml).
GET /api/admin/screens
Return the available screens grouped by screen repo, plus panel profiles and supported
dither algorithms. Every screen is addressed by its canonical handle/path reference — the
value a device’s screen field is set to.
Response 200:
{
"screen_repos": [
{
"handle": "byonk-builtin",
"name": "byonk-builtin",
"description": "Screens bundled with byonk.",
"author": "Byonk",
"license": "MIT",
"screens": [
{
"ref": "examples/swiss-departure-board",
"title": "Swiss Departure Board",
"description": "Live public-transport departures for a Swiss stop.",
"params": [
{
"name": "station",
"type": "string",
"required": false,
"default": "Olten, Südwest",
"label": "Stop name",
"description": "Stop name as used by the transport API"
},
{
"name": "limit",
"type": "int",
"required": false,
"default": 8,
"label": "Departures",
"description": "Number of departures to show",
"min": 1.0,
"max": 30.0,
"mode": "box"
}
],
"byonk": "0.17",
"compat_warning": null
}
]
}
],
"panels": [
{
"name": "trmnl_og",
"width": 800,
"height": 480,
"colors": "#000000,#555555,#AAAAAA,#FFFFFF"
}
],
"dither_algorithms": [
"floyd-steinberg",
"atkinson",
"atkinson-hybrid",
"jarvis-judice-ninke",
"sierra",
"sierra-two-row",
"sierra-lite",
"stucki",
"burkes"
]
}
Field notes:
- Screens are grouped under the screen repo that provides them. The repo-level
name,description,author, andlicensecome from that screen repo’sbyonk-screens.yamlmanifest. refis the canonicalhandle/pathreference (e.g.examples/gphoto) — the assignable screen id, and whatscreenis set to on a device.titleanddescriptioncome from the screen’smeta.yaml.paramsis the screen’s parameter schema (ParamField[]), sourced frommeta.yaml.byonkis the engine-compatibility requirement declared inmeta.yaml.compat_warningisnullwhen the running engine satisfies the screen’sbyonkrequirement, or a human-readable string when it does not (the screen is still served).widthandheightmay benullfor panels without explicit dimensions.- Optional
ParamFieldkeys (label,description,min,max,step,unit,mode,options) are omitted from the JSON when not set.
GET /api/admin/screen-repos
List the registered screen repos. byonk-builtin is always present (it is the embedded
built-in screen repo, registered even without a screen_repos: config entry); any additional
entries come from the screen_repos: config section.
Response 200 — array of screen repo objects:
[
{
"handle": "byonk-builtin",
"repo": null,
"pin": null,
"builtin": true,
"token_set": false,
"screen_count": 11,
"status": "ready",
"pin_kind": "embedded",
"resolved_sha": null,
"last_fetched": null,
"error": null
},
{
"handle": "weather",
"repo": "github.com/acme/screens",
"pin": "v1.4.0",
"builtin": false,
"token_set": true,
"screen_count": 3,
"status": "ready",
"pin_kind": "tag",
"resolved_sha": "13dce1d25716356cc7fc2ef7d137b8dfc3157fbf",
"last_fetched": "2026-07-03T12:34:56+00:00",
"error": null
}
]
Field notes:
handle— the short registry key; also the first segment of everyhandle/pathscreen ref.repo/pin— the source repo and pin for remote screen repos; bothnullfor the embedded built-in.builtin—truefor the embeddedbyonk-builtinhandle (or any screen repo without a remote repo).token_set— whether an auth token is configured for the screen repo. The token itself is never serialized in any response; only this boolean is exposed.screen_count— number of screens the loader discovered in the screen repo.status— one of:"ready"— fetched (or embedded) and currently serving."fetching"— a fetch is in progress right now."error"— the screen repo has never been fetched successfully (e.g. just registered and the background fetch hasn’t completed yet, or every fetch attempt has failed and nothing is cached). It is not currently serving."offline"— the most recent refresh attempt failed, but a previously fetched checkout is still cached and continues to serve. A fetch failure never takes down an already-cached screen repo.
pin_kind— howpinwas resolved:"sha","tag","branch", or"embedded"for the built-in screen repo.nullif the screen repo has never been successfully fetched. A full commitshapin is immutable — it is fetched once and cached forever, never re-fetched. Atagorbranchpin is mutable — it is re-fetched on demand (via the update endpoints below) and automatically everyscreen_repo_refresh_intervalseconds.resolved_sha— the commit sha the screen repo is currently pinned/fetched at, ornullif never successfully fetched. The cache is keyed byrepo+resolved_sha.last_fetched— RFC3339 timestamp of the last successful fetch, ornullif never successfully fetched.error— the most recent fetch error message, ornullif the last fetch (or the current state) has no error.
POST /api/admin/screen-repos
Register a new remote screen repo. Triggers an asynchronous background
fetch (fire-and-forget) — the response reflects whatever status exists at
that instant (typically no status yet, since the fetch hasn’t completed).
Poll GET /api/admin/screen-repos for the settled result.
Request body:
{
"handle": "weather",
"repo": "github.com/acme/screens",
"pin": "v1.4.0",
"token": "ghp_xxxxxxxxxxxx"
}
Required field: handle. repo, pin, and token are optional (though a
screen repo needs repo/pin to have anything to fetch). token is used for
authenticating against a private repo and — like every screen repo token — is
never echoed back in any response.
Responses:
| Status | Meaning |
|---|---|
200 | Registered — returns the screen repo’s ScreenRepoInfo (same shape as GET /api/admin/screen-repos entries) |
400 | Validation error (missing handle) |
409 | handle is byonk-builtin (reserved), a screen repo with that handle already exists, or config is embedded/read-only (set CONFIG_FILE) |
PATCH /api/admin/screen-repos/:handle
Update an existing screen repo’s repo, pin, or token. All fields are
optional; an omitted field keeps its current value — in particular, an
omitted token is never cleared.
Request body (all fields optional):
{
"pin": "v1.5.0"
}
If repo or pin changes, a background re-fetch is triggered (same
fire-and-forget semantics as POST /api/admin/screen-repos).
Responses:
| Status | Meaning |
|---|---|
200 | Updated — returns the screen repo’s ScreenRepoInfo |
404 | No screen repo with that handle |
409 | handle is byonk-builtin (reserved), or config is embedded/read-only |
DELETE /api/admin/screen-repos/:handle
Remove a screen repo registration. Rejected if any device’s screen still
references the handle (<handle>/...) — delete or repoint those device
mappings first. On success, the in-memory loader is rebuilt immediately so
the handle’s screens stop resolving right away (the cached checkout on disk
is left in place).
Responses:
| Status | Meaning |
|---|---|
200 | Deleted — {"ok": true} |
404 | No screen repo with that handle |
409 | handle is byonk-builtin (reserved); a device references the handle (message names the offending device); or config is embedded/read-only |
POST /api/admin/screen-repos/:handle/update
Trigger a re-fetch of a single screen repo handle. Fire-and-forget: the fetch
runs in the background, and the response reflects whatever status exists at
that instant. Poll GET /api/admin/screen-repos for the settled status. Calling
this on byonk-builtin is accepted but is a no-op (the embedded screen repo is
never fetched).
Responses:
| Status | Meaning |
|---|---|
200 | Refresh triggered — returns the screen repo’s ScreenRepoInfo (pre-refresh snapshot) |
404 | No screen repo with that handle |
POST /api/admin/screen-repos/update
Trigger a forced re-fetch of every registered non-builtin screen repo
(fire-and-forget, runs in the background). Forcing bypasses the “already
cached and immutable” skip that a normal periodic refresh applies to sha
pins — every handle gets a real fetch attempt. Poll GET /api/admin/screen-repos
for the settled status of each screen repo.
Responses:
| Status | Meaning |
|---|---|
200 | Refresh triggered for all screen repos — {"ok": true} |
POST /api/admin/devices
Create a new device mapping in config.yaml.
Request body:
{
"key": "AA:BB:CC:DD:EE:FF",
"screen": "examples/swiss-departure-board",
"panel": null,
"dither": "atkinson",
"colors": null,
"params": { "station": "Bern, Bahnhof", "limit": 10 }
}
Required fields: key, screen. screen must be a qualified handle/path reference (as
listed by GET /api/admin/screens). All other fields are optional.
The full set of writable settings is:
| Field | Meaning |
|---|---|
screen | Qualified handle/path screen reference |
panel | Panel profile name, as configured under panels |
dither | Dither algorithm — GET /api/admin/screens lists the accepted names |
colors | Palette override, comma-separated #rrggbb, at least two |
params | Parameters for the screen’s Lua script |
refresh | Refresh interval in seconds; 0 means “use the screen’s own default” |
name | Friendly name |
max_error | Cap on accumulated dithering error |
noise_scale | Blue-noise jitter scale |
chroma_clamp | Chroma clamp for dithering |
strength | Dither strength — 0.0 diffuses nothing, 1.0 is standard |
temperature_profile | default, a or b; passed to the device |
maximum_compatibility | Ask the firmware to force a full-waveform refresh every update |
min_png_bytes | Pad the served PNG to at least this many bytes |
panel, dither, colors and temperature_profile are validated against what
this server actually understands, and an unknown value is a 400. This matters
for dither in particular: an unrecognised algorithm name is not an error
anywhere further down the pipeline — the renderer falls back to atkinson — so
without the check a typo would be invisible until you looked at the panel.
The device’s gamut block and the deprecated error_clamp key are not writable
here, but an existing entry keeps them: a write preserves every key it does not
manage itself.
Responses:
| Status | Meaning |
|---|---|
200 | Created — {"key": "AA:BB:CC:DD:EE:FF", "screen": "examples/swiss-departure-board"} |
400 | Validation error (missing key/screen, unknown screen, param type mismatch, out-of-range value) |
409 | Device key already exists, or config is embedded/read-only (set CONFIG_FILE env var) |
PATCH /api/admin/devices/:key
Update an existing device mapping. The :key in the URL must match an existing entry in the
devices: config section.
It accepts the same settings as POST (see the table above), minus key. All of
them merge individually: an omitted field keeps its current value.
params merges key by key, so changing one param does not drop the others.
The exception is a request that also changes screen: the params then belong to
a different script, so whatever the request carries replaces the map wholesale.
Sending no params with a screen change carries the previous params across
unchanged — they are validated against the new screen’s schema, not reset to its
defaults.
Request body (all fields optional):
{
"screen": "examples/swiss-departure-board",
"dither": "floyd-steinberg",
"params": { "limit": 5 }
}
Removing a setting
An omitted field means “leave alone”, so PATCH on its own can change a setting
but never take it back. List the settings to remove in clear:
{ "clear": ["noise_scale", "min_png_bytes"] }
A device setting overrides the panel’s, so clearing one lets the panel’s value
apply again. Every writable field except screen can be cleared — a device must
always have a screen, so change it instead. Setting and clearing the same field
in one request is a 400, as is clearing a name that is not a setting. clear
is only valid on PATCH; on POST there is nothing to clear and it is refused.
Responses:
| Status | Meaning |
|---|---|
200 | Updated — {"key": "AA:BB:CC:DD:EE:FF", "screen": "examples/swiss-departure-board"} |
400 | Validation error |
404 | No device with that key |
409 | Config is embedded/read-only |
DELETE /api/admin/devices/:key
Remove a device mapping from config.yaml.
Responses:
| Status | Meaning |
|---|---|
200 | Deleted — {"deleted": "AA:BB:CC:DD:EE:FF"} |
404 | No device with that key |
409 | :key is the reserved DEFAULT device (it cannot be deleted), or config is embedded/read-only |
GET /api/admin/devices/:key/preview
Render what this device’s panel is showing, as a PNG.
The render uses the device’s own configuration — its screen, parameters, panel
profile, dither algorithm and tuning — and its identity from the registry, so a
screen that reads device.mac or device.battery_voltage sees the real values.
A device that has never checked in reports no telemetry rather than placeholder
values.
Query parameters:
| Parameter | Meaning |
|---|---|
force | Present in any form (?force, ?force=1) — re-render instead of serving the cached copy |
dither | off/0/false/no returns the screen before dithering: the full-colour rasterization, with no palette restriction. Anything else, or absent, keeps the dithered render the panel receives |
measured | off/0/false/no draws the palette in the spec colors byonk sends to the panel, instead of the measured colors a calibration says it really produces. No effect when dither is off — an undithered render has no palette to map |
Only an explicit no turns an option off; dither=on, dither=1 and dither=true
all keep it on. Neither parameter changes what the device displays — they select
how this picture is drawn, nothing more. Each combination is cached separately, so
flipping one back and forth does not re-render.
Caching: a rendered preview is held and re-served until either the device’s
configuration changes or the screen’s own refresh_rate elapses (with a floor of
30 seconds). This is what makes it safe for a client to poll the endpoint every
few seconds. The cache does not notice edits to a screen’s source files — the
refresh rate bounds how long that can be stale, and ?force ends it immediately.
Responses:
| Status | Meaning |
|---|---|
200 | image/png. Cache-Control: no-store, plus X-Byonk-Preview: hit|miss naming whether it was re-served or rendered |
404 | No device configuration for that key — nothing is assigned, so there is nothing to preview |
A screen that fails to render still returns 200 with a PNG: the error image the
panel itself would display. A broken-image icon would say only that something
went wrong, not what.
curl -s -H "Authorization: Bearer $TOKEN" \
http://localhost:3000/api/admin/devices/AA:BB:CC:DD:EE:FF/preview \
-o preview.png
PATCH /api/admin/settings
Update global settings in config.yaml. All fields are optional; only provided fields are
changed.
Request body:
{
"registration_enabled": true,
"auth_mode": "api_key",
"screen_repo_refresh_interval": 3600
}
| Field | Type | Allowed values |
|---|---|---|
registration_enabled | boolean | true / false |
auth_mode | string | "api_key" or "ed25519" |
screen_repo_refresh_interval | integer | seconds between automatic re-fetches of mutable (tag/branch) screen repo pins; 0 disables periodic refresh (the default) |
There is no default_screen or registration_screen field here — the screen
shown to un-onboarded or unassigned devices is the reserved DEFAULT device,
set like any other device via POST / PATCH /api/admin/devices/DEFAULT
(see above).
Responses:
| Status | Meaning |
|---|---|
200 | Applied — {"ok": true} |
400 | Validation error (unknown screen, invalid auth_mode) |
409 | Config is embedded/read-only |
Comment-preserving writes and hot-reload
All write endpoints (POST, PATCH, DELETE) modify config.yaml in place using a
targeted YAML path patch. Existing comments and formatting in the file are preserved — only
the specific keys that changed are rewritten.
After a successful write the server reloads the config atomically (via an ARC swap) so the
change takes effect without a restart. The next /api/display request for an affected
device will use the updated mapping immediately. If the reloaded YAML fails to parse, the
write is rolled back to the previous file contents.
Writes require a file-backed config. If the server was started with an embedded/bundled
config (no CONFIG_FILE environment variable), write endpoints return 409 Conflict with
the message "config is embedded/read-only; set CONFIG_FILE".
Parameter schema format
Each screen declares its accepted parameters in the params: block of its meta.yaml.
Byonk parses this as YAML. The result is returned by GET /api/admin/screens and validated
on every device write.
Syntax
# <screen>/meta.yaml
title: My Screen
description: What this screen shows.
byonk: "0.19"
params:
<param-name>:
type: <type>
# … other keys …
Field reference
| Key | Type | Default | Description |
|---|---|---|---|
type | string | — | Required. One of string, int, float, bool, enum, color, url. |
required | bool | false | When true, the param must be present in every device mapping. |
default | any | — | Default value shown in UI when param is absent. |
label | string | — | Human-readable name for UI display. |
description | string | — | Longer hint shown in tooltips or help text. |
min | number | — | Minimum value (applies to int and float). |
max | number | — | Maximum value (applies to int and float). |
step | number | — | Increment step for UI sliders. |
unit | string | — | Unit label shown next to the value (e.g., "px", "°C"). |
mode | string | — | UI hint for input style (e.g., "box" for a numeric input box). |
options | list | — | Required for enum type. A list of bare strings ([a, b]) or {value, label} objects. |
sensitive | bool | false | Treat value as a secret (mask in UI). |
multiline | bool | false | Use a textarea instead of a single-line input. |
hidden | bool | false | Do not show in UI (still accepted in API). |
advanced | bool | false | Collapse into an “advanced” section in UI. |
Example — Swiss departure board screen
The bundled examples/swiss-departure-board screen declares its params in
meta.yaml:
# examples/swiss-departure-board/meta.yaml
title: Swiss Departure Board
description: Live public-transport departures for a Swiss stop.
byonk: "0.19"
params:
station:
type: string
label: "Stop name"
default: "Olten, Südwest"
description: "Stop name as used by the transport API"
limit:
type: int
label: "Departures"
default: 8
min: 1
max: 30
mode: box
description: "Number of departures to show"
Field order is preserved in the schema response. The script.lua accesses these values via
the params Lua table (params.station, params.limit).
Enum options
Enum options can be plain strings (where label defaults to the value):
theme:
type: enum
options: [light, dark, auto]
Or objects with explicit labels:
theme:
type: enum
options:
- { value: light, label: "Light mode" }
- { value: dark, label: "Dark mode" }
- { value: auto, label: "Follow system" }
Validation
When a device mapping is created or updated via the API, Byonk validates every provided param against the screen’s schema:
- Missing required params →
400 Bad Request - Wrong type (e.g., string where
intexpected) →400 Bad Request - Value outside
min/maxrange →400 Bad Request - Value not in
enumoptions →400 Bad Request
Extra params not listed in the schema are silently accepted (ignored by validation).