Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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
SituationHTTP status
No token configured (admin disabled)404 Not Found
Authorization header missing or wrong token401 Unauthorized
Token correctrequest 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).
  • registeredtrue if the device appears in the devices: config section.
  • reservedtrue for the reserved DEFAULT device (byonk-managed fallback), false for physical devices.
  • registration_code is an empty string for devices that appear in config but have never connected (it is never null).
  • Telemetry fields (model, firmware_version, last_seen, battery_voltage, rssi) are null for devices that are configured but have never connected.
  • screen, dither, panel, colors, params reflect the resolved config mapping; they are null when 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, and license come from that screen repo’s byonk-screens.yaml manifest.
  • ref is the canonical handle/path reference (e.g. examples/gphoto) — the assignable screen id, and what screen is set to on a device.
  • title and description come from the screen’s meta.yaml.
  • params is the screen’s parameter schema (ParamField[]), sourced from meta.yaml.
  • byonk is the engine-compatibility requirement declared in meta.yaml.
  • compat_warning is null when the running engine satisfies the screen’s byonk requirement, or a human-readable string when it does not (the screen is still served).
  • width and height may be null for panels without explicit dimensions.
  • Optional ParamField keys (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 every handle/path screen ref.
  • repo / pin — the source repo and pin for remote screen repos; both null for the embedded built-in.
  • builtintrue for the embedded byonk-builtin handle (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 — how pin was resolved: "sha", "tag", "branch", or "embedded" for the built-in screen repo. null if the screen repo has never been successfully fetched. A full commit sha pin is immutable — it is fetched once and cached forever, never re-fetched. A tag or branch pin is mutable — it is re-fetched on demand (via the update endpoints below) and automatically every screen_repo_refresh_interval seconds.
  • resolved_sha — the commit sha the screen repo is currently pinned/fetched at, or null if never successfully fetched. The cache is keyed by repo + resolved_sha.
  • last_fetched — RFC3339 timestamp of the last successful fetch, or null if never successfully fetched.
  • error — the most recent fetch error message, or null if 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:

StatusMeaning
200Registered — returns the screen repo’s ScreenRepoInfo (same shape as GET /api/admin/screen-repos entries)
400Validation error (missing handle)
409handle 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:

StatusMeaning
200Updated — returns the screen repo’s ScreenRepoInfo
404No screen repo with that handle
409handle 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:

StatusMeaning
200Deleted — {"ok": true}
404No screen repo with that handle
409handle 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:

StatusMeaning
200Refresh triggered — returns the screen repo’s ScreenRepoInfo (pre-refresh snapshot)
404No 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:

StatusMeaning
200Refresh 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:

FieldMeaning
screenQualified handle/path screen reference
panelPanel profile name, as configured under panels
ditherDither algorithm — GET /api/admin/screens lists the accepted names
colorsPalette override, comma-separated #rrggbb, at least two
paramsParameters for the screen’s Lua script
refreshRefresh interval in seconds; 0 means “use the screen’s own default”
nameFriendly name
max_errorCap on accumulated dithering error
noise_scaleBlue-noise jitter scale
chroma_clampChroma clamp for dithering
strengthDither strength — 0.0 diffuses nothing, 1.0 is standard
temperature_profiledefault, a or b; passed to the device
maximum_compatibilityAsk the firmware to force a full-waveform refresh every update
min_png_bytesPad 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:

StatusMeaning
200Created — {"key": "AA:BB:CC:DD:EE:FF", "screen": "examples/swiss-departure-board"}
400Validation error (missing key/screen, unknown screen, param type mismatch, out-of-range value)
409Device 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:

StatusMeaning
200Updated — {"key": "AA:BB:CC:DD:EE:FF", "screen": "examples/swiss-departure-board"}
400Validation error
404No device with that key
409Config is embedded/read-only

DELETE /api/admin/devices/:key

Remove a device mapping from config.yaml.

Responses:

StatusMeaning
200Deleted — {"deleted": "AA:BB:CC:DD:EE:FF"}
404No 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:

ParameterMeaning
forcePresent in any form (?force, ?force=1) — re-render instead of serving the cached copy
ditheroff/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
measuredoff/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:

StatusMeaning
200image/png. Cache-Control: no-store, plus X-Byonk-Preview: hit|miss naming whether it was re-served or rendered
404No 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
}
FieldTypeAllowed values
registration_enabledbooleantrue / false
auth_modestring"api_key" or "ed25519"
screen_repo_refresh_intervalintegerseconds 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:

StatusMeaning
200Applied — {"ok": true}
400Validation error (unknown screen, invalid auth_mode)
409Config 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

KeyTypeDefaultDescription
typestringRequired. One of string, int, float, bool, enum, color, url.
requiredboolfalseWhen true, the param must be present in every device mapping.
defaultanyDefault value shown in UI when param is absent.
labelstringHuman-readable name for UI display.
descriptionstringLonger hint shown in tooltips or help text.
minnumberMinimum value (applies to int and float).
maxnumberMaximum value (applies to int and float).
stepnumberIncrement step for UI sliders.
unitstringUnit label shown next to the value (e.g., "px", "°C").
modestringUI hint for input style (e.g., "box" for a numeric input box).
optionslistRequired for enum type. A list of bare strings ([a, b]) or {value, label} objects.
sensitiveboolfalseTreat value as a secret (mask in UI).
multilineboolfalseUse a textarea instead of a single-line input.
hiddenboolfalseDo not show in UI (still accepted in API).
advancedboolfalseCollapse 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 int expected) → 400 Bad Request
  • Value outside min/max range → 400 Bad Request
  • Value not in enum options → 400 Bad Request

Extra params not listed in the schema are silently accepted (ignored by validation).