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>/mcphttp://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.
delete_screenDelete a screen and every file in its directory.
delete_screen_fileDelete one sibling asset from a screen directory.

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
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. A colors_actual returned by the screen’s own script still wins over this; 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: script, render_opts (the colors_actual argument above), panel.colors_actual, 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, script params, refresh interval and name, the dither tuning knobs max_error, noise_scale, chroma_clamp and strength, 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.

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.

Names are checked before anything is written: an unknown dither algorithm, an unconfigured panel, a malformed colors list or a temperature_profile other than default/a/b all fail with a message listing what is accepted. 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.
  • 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. examples is writable directly, but copying still keeps the original example 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.