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

Authoring with an LLM (MCP)

Byonk exposes a Model Context Protocol endpoint at /mcp. It lets an assistant like Claude Code list, read, create, edit, validate and render screens on a running byonk — including one running inside Home Assistant — entirely over the network. There is no filesystem access involved: no Samba share, no SCREENS_DIR mount, no SSH. Every tool call goes through the same screen store that backs the rest of byonk, so what the assistant sees and changes is exactly what byonk itself will render and serve.

Prerequisite: an admin token

/mcp is gated by the same admin token as the Admin API. If no token is configured, the endpoint doesn’t just refuse requests — it returns 404 Not Found, as if it didn’t exist. Set a token first:

  • config.yaml: admin.token: <your-secret>
  • environment variable BYONK_ADMIN_TOKEN (takes precedence over config.yaml)
  • the Home Assistant app’s Options screen, which provisions the token automatically

See Admin API — Enabling the API for the full rules; they apply here unchanged.

Connecting

The endpoint is http://<host>:<port>/mcp — http://localhost:3000/mcp with byonk’s default port, or http://homeassistant.local:3000/mcp for the Home Assistant app. Transport is streamable HTTP, stateless, with plain JSON responses (no server-sent-events framing to worry about). Authenticate with the token as a Bearer credential:

Authorization: Bearer <your-secret>

With the Claude Code CLI:

claude mcp add --transport http byonk http://localhost:3000/mcp \
  --header "Authorization: Bearer <your-secret>"

Or as a JSON config block (the shape most MCP clients accept):

{
  "mcpServers": {
    "byonk": {
      "type": "http",
      "url": "http://localhost:3000/mcp",
      "headers": {
        "Authorization": "Bearer <your-secret>"
      }
    }
  }
}

Tools

Read

ToolWhat it does
list_screensList every screen this server can resolve, with its repo, title and whether it is writable.
read_screen_fileRead one file inside a screen (meta.yaml, script.lua, screen.svg, or another asset). Binary files (not valid UTF-8) return no content — only the etag and binary: true.
list_screen_reposList the configured screen repositories: handle, kind, writability.
list_devicesList known TRMNL devices: MAC, model, assigned screen.
get_configRead this server’s non-secret global configuration.

Edit

ToolWhat it does
write_screen_fileWrite one file inside a screen, atomically (supports optimistic-concurrency if_match). UTF-8 text only — refuses to overwrite an existing binary asset.
create_screenScaffold a new screen from the minimal starter (meta.yaml, script.lua, screen.svg).
copy_screenFork any screen — including read-only builtins and examples — into a writable repo.
rename_screenMove a screen to a different path within its repo. Every device whose screen: named the old reference is pointed at the new one in config.yaml, in the same call, and the result lists them (rewritten_devices). When devices use the screen and the config is embedded or read-only (no CONFIG_FILE), the rename is refused and nothing moves.
delete_screenDelete a screen and every file in its directory.
delete_screen_fileDelete one sibling asset from a screen directory.
rename_screen_fileRename one sibling asset inside a screen directory (the new name may include a subfolder). Refused when the target exists; meta.yaml, script.lua and screen.svg cannot be renamed or be the target.

create_screen, copy_screen and rename_screen each take the screen’s location inside a repo — path, to_path and new_path respectively. These are directory paths (clock, or home/clock to nest), not display titles: a new screen is always scaffolded as “New Screen” and a copy keeps the source’s meta.yaml verbatim. Set the title by writing meta.yaml. The repo handle is passed separately (local, never local/clock).

Binary assets (images, fonts, anything not valid UTF-8) can be read for their etag but not their content, and cannot be written or overwritten over MCP at all — write_screen_file refuses if the target already exists and is binary. Place binary assets by another means (a writable local screen repo mounted via SCREENS_DIR, a Samba share, or an EXAMPLES_DIR-seeded repo) and author text files around them.

Render

ToolWhat it does
render_screenRender a screen and return the dithered PNG plus diagnostics (log, data, error).
validate_screenStatically check a screen — meta.yaml, Lua, and template — without running it.

render_screen shows what the panel will really look like by default: when measured colours are available (from a named panel or from its own colors_actual argument), the returned PNG is drawn in them. This changes only how the PNG is drawn, never the dithering itself — a screen’s dithering targets measured colours whenever they resolve, regardless of use_actual.

ArgumentTypeWhat it does
panelstring, optionalPanel profile from panels:. Sets the render size to the panel’s width/height and supplies its colours and dither. An unknown name falls back to the model defaults and adds a warning to log.
width, heightint, optionalRender size in pixels. Size chain, per axis: explicit width/height, then the panel’s size, then the model default (og 800x480, x 1872x1404).
imagedithered | raw | both | noneWhich image(s) to return. Default dithered. both returns the dithered image then the pre-dither one, each preceded by a text block naming it.
image_max_widthint, optionalDownscale returned image(s) to at most this width, preserving aspect ratio. Never upscales.
include_databool, default trueReturn the table the script produced.
include_svgbool, default falseAlso return the fully expanded SVG that was rasterized.
data_urisshorten | full | omitHow to treat embedded base64 data: URIs in data and the SVG. Default shorten.
use_actualbool, optionalDraw the returned PNG in the panel’s measured colours instead of the spec colours. Defaults to on whenever measured colours are available. true with nothing measured is a no-op, not an error.
colors_actualstring, optionalComma-separated hex, index-parallel to the palette (e.g. #0A0A0A,#E8E6E0,#A83A30). Lets you preview a calibration without adding a panel to config.yaml. It takes the cascade’s override rung, so since 0.20.0 it wins over a colors_actual returned by the screen’s own script — it used to lose to it, which left no way to preview a calibration against a screen that measures its own. A length mismatch is ignored (with a warning in the diagnostics’ log) rather than failing the render.

Keeping the response small

render_screen is by far the most expensive tool here, and an LLM client pays for every byte of it. Rendering the builtin default screen at 800×480 measures:

ArgumentsResponse
(defaults)65 KB
image_max_width: 20025 KB
image: "none"3 KB
image: "none", include_data: false0.3 KB
image: "raw"648 KB
image: "both"710 KB
image: "both", image_max_width: 300171 KB

Note the raw pre-dither image: it is full-colour and ten times the size of the dithered one, so raw and both are worth pairing with image_max_width unless you specifically need its exact pixels.

Screens that embed a photo are the extreme case. image_process returns the picture as a base64 data: URI, which lands in data — serialised twice, as text and as structured content — and again inside the SVG if you ask for it. Rendering a 400×240 photo screen measures:

ArgumentsResponse
data_uris: "full"336 KB
(defaults — shorten)17 KB
include_svg: true, data_uris: "full"656 KB
include_svg: true17 KB
include_svg: true, image: "none"0.9 KB
include_svg: true, image: "none", include_data: false0.7 KB

Shortening is what makes include_svg usable at all: verbatim it doubles the response to 656 KB, shortened it costs about 400 bytes.

Reading the expanded SVG

include_svg returns the markup resvg actually parsed — Tera rendered, {% extends %} resolved, script data interpolated:

<svg xmlns="http://www.w3.org/2000/svg" width="800" height="480">
  <image x="0" y="0" width="400" height="240" href="data:image/png;base64,<159784 chars elided>"/>
</svg>

Reach for it when a screen renders but looks wrong and the template and the data each look correct on their own — the bug is usually in how they combined. validate_screen parses the SVG too, but statically, without the data, and it returns no markup.

Five arguments let the caller decide what a render is worth:

  • image: "none" when you only need the script’s log, data or error — e.g. checking that an edit still runs. The images dominate the response.
  • image_max_width for a layout check at a fraction of the cost. Be aware that resampling destroys the dither pattern, so a scaled dithered image is fine for judging layout and tone and useless for judging dithering itself. Omit it when you need to inspect the real pixels.
  • include_data: false when you only want the picture and not the table that produced it.
  • data_uris to keep embedded images out of the text entirely. The default already shortens them; omit also drops the media type, and full is only worth it when you genuinely need the bytes.

The diagnostics also include measured_source, naming which layer actually supplied the measured colours for that render: render_opts (the colors_actual argument above), device (the device’s own colors_actual or its final.colors_actual — new in 0.20.0), script, panel.colors_actual, Measured-Colors header, or none.

Configure a device

ToolWhat it does
configure_deviceSet which screen a device shows and how that screen is rendered for it (use list_devices first to find its MAC).

Every field except mac is optional, and an omitted field is left as it was, so one call can change a single setting without disturbing the others. screen_ref is only required the first time a device is configured.

Besides screen_ref the tool covers the device’s panel, dither algorithm, colors palette, its own colors_actual measurement, script params, refresh interval and name, the dither tuning knobs max_error, noise_scale, chroma_clamp, strength and gamut, the final block, and the panel-behaviour flags temperature_profile, maximum_compatibility and min_png_bytes. It reaches the same settings as PATCH /api/admin/devices/{key}, which spells two of them differently: there the device is named by the URL rather than by mac, and its screen field is screen rather than screen_ref.

colors_actual, gamut and final are new in 0.20.0. gamut was configurable in config.yaml before, but no tool could reach it; colors_actual on a device is new.

final is where a setting that a screen must not override goes. Everything written directly on the device is a default: a screen that returns its own dither, palette or tuning overrides it, because only the script can inspect what it just drew. final holds the owner’s vetoes, and nothing overrides those:

{ "mac": "44:1B:F6:83:93:38", "dither": "floyd-steinberg",
  "final": { "dither": "atkinson", "refresh": 1800 } }

final accepts exactly colors, colors_actual, dither, max_error, noise_scale, chroma_clamp, strength, refresh and gamut, and merges field by field like the rest of the call. See the configuration guide’s Settings order for the full ladder.

To take a setting back rather than change it, name it in clear:

{ "mac": "44:1B:F6:83:93:38", "clear": ["noise_scale"] }

A device setting overrides the panel’s, so removing it lets the panel’s own value apply again. Setting and clearing the same field in one call is refused.

A name in clear takes at most one dot, and a bare name never reaches final: "dither" clears the device’s default, "final.dither" one field of the veto block, and "final" the whole block. So clearing "dither" on a device whose algorithm came from final changes nothing the renderer sees.

Names are checked before anything is written: an unknown dither algorithm, an unconfigured panel, a malformed colors or colors_actual list, a negative tuning number or a temperature_profile other than default/a/b all fail with a message listing what is accepted — inside final too, where the message names the field as final.dither. This matters most for dither — an unrecognised algorithm name is not an error further down the pipeline, it just renders Atkinson, so a typo used to be invisible until you looked at the panel.

Resources

Byonk also publishes its own authoring references as MCP resources, so the assistant works from this server’s actual rules instead of guessing from stale training data:

  • byonk://reference/lua-api — every global and function available to script.lua.

  • byonk://reference/svg-templates — the screen.svg templating contract.

  • byonk://reference/authoring — how screens, screen repos and writability fit together.

    Each reference is served as an index plus sections: reading the URI above returns the introduction and a list of the document’s ## sections, each with its own URI (byonk://reference/<doc>/<section>, e.g. byonk://reference/lua-api/time-functions) and the ### headings inside it. Read the section you need rather than the whole document.

  • byonk://schema/meta.yaml — the JSON Schema for meta.yaml, generated from the same type that parses it.

  • byonk://examples/<screen path> — one resource per shipped example screen, with the full meta.yaml + script.lua + screen.svg source, known to render on this server.

Have the assistant read byonk://reference/lua-api before it writes a script — it describes exactly what’s injected into the Lua sandbox, which is not the same as general-purpose Lua.

A workflow that works

  1. list_screens to see what’s already there and which repos are writable.
  2. copy_screen a builtin or an example into a writable repo as a starting point. The built-in screens (byonk-builtin) are read-only, so editing one in place fails; forking first is the way in. The examples repo is read-only too, so copy an example into local to change it; the original stays intact as a reference.
  3. Edit with write_screen_file.
  4. render_screen and read its log and error.line fields — Lua errors and template errors both surface there, pointing at what to fix.
  5. Repeat steps 3–4 until it renders clean.
  6. configure_device to put it on a real device.

Security note

The admin token this endpoint uses grants full screen-authoring rights — creating, editing and deleting screens — and device-configuration rights, not just read access. Treat it like any other credential.

/mcp also accepts requests for any Host header, unlike the loopback-only default most MCP servers use — this is deliberate, so the endpoint can be reached at a LAN hostname such as homeassistant.local:3000 rather than only localhost. That means the Bearer token is the only thing standing between this endpoint and anyone who can reach the port. Don’t expose it to the internet.