{"slug": "teaching-an-llm-to-speak-vestaboard-note-building-vestaboard-ai", "title": "Teaching an LLM to Speak Vestaboard Note: Building Vestaboard AI", "summary": "A developer built Vestaboard AI, a Python service that uses an OpenAI-compatible LLM to generate messages for a Vestaboard split-flap display, enforcing the board's 45-character and restricted character set constraints through a deterministic validation pipeline. The system splits into a Streamlit UI for configuration and an APScheduler daemon for delivery, coordinating via a shared config file, and is available on GitHub.", "body_md": "# Teaching an LLM to Speak Vestaboard Note: Building Vestaboard AI\n\nA Vestaboard is a split-flap display — the kind that used to clatter through train-station departure boards — reimagined as a connected home object. It's gorgeous, it's tactile, and it has a wonderfully small canvas: **3 lines of 15 characters**, so 45 characters of real content, drawn from a restricted alphabet of letters, digits, a handful of symbols, and a few color chips.\n\nThat constraint is exactly what makes it a fun target for a language model. LLMs love to ramble; a Vestaboard Note physically cannot. So I built **Vestaboard AI**: a small Python service that asks an OpenAI-compatible model for a message, squeezes it through a hard validator until it fits the board, and flips it onto the display on a cron schedule. Configuration happens entirely in a browser, behind a password.\n\nThis post walks through what it is, how it works module-by-module, and how it's deployed.\n\nThe application can be found on GitHub at [https://github.com/techpreacher/vestaboard-ai](https://github.com/techpreacher/vestaboard-ai?ref=corti.com).\n\n## The shape of the problem\n\nThe whole design falls out of four hard constraints, and it's worth stating them up front because they drive every decision downstream:\n\n**45 characters of content.** The board renders 45 characters across a 3×15 grid. Both the LLM's output*and*the rendered layout have to respect this — a message can be 45 characters but still fail to wrap into three 15-char lines.**A restricted character set.** Only Vestaboard's glyphs render:`A–Z`\n\n,`0–9`\n\n, a specific punctuation set, a degree sign, and color chips. Anything else has to be substituted or rejected.**Output is a code grid.** The board doesn't take text; it takes a 6×22 grid of integer character codes. Text has to be*compiled*into that grid.**Two delivery backends.** Vestaboard offers a Cloud Read/Write API and a Local API. The code has to treat them as interchangeable.\n\nThe guiding principle: **never trust the model.** The LLM is a suggestion engine. A deterministic, heavily-tested core decides what actually reaches the board.\n\n```\nprompt → LLM generates message → compile to VBML + code grid\n       → validate (45 chars / 3×15 / charset) → deliver to board → repeat on schedule\n```\n\n## Architecture: two processes, one file\n\nThe system is split into **two independent processes that never talk to each other directly**. They coordinate through a single `config.json`\n\non disk.\n\n```\nconfig.json (0600, service user)  ← single source of truth\n   ▲ write (atomic: temp + os.replace)        ▲ read (poll content hash every 5s)\n   │                                          │\nvboard-ui  (Streamlit)                 vboard-scheduler  (APScheduler daemon)\n auth + edit config                     generate → compile → deliver\n```\n\n- The\n**UI** is the only thing that writes config. It authenticates the user and edits credentials, prompts, and schedules. It can also fire a one-off \"test send.\" - The\n**scheduler daemon** is the only thing that delivers. It reads config, builds cron jobs, and runs the generate→deliver pipeline when a job fires.\n\nWhy split them? Because the scheduler should keep ticking even while you're reloading the config page, and either process should be able to restart without taking down the other. A shared file is the entire IPC mechanism — simple, debuggable, and crash-safe.\n\nThe Python package (`src/vboard/`\n\n) breaks down like this:\n\n| Module | Responsibility |\n|---|---|\n`config` |\nPydantic models; atomic `0600` load/save |\n`logging_setup` |\nLogger + secret-redaction filter |\n`charset` |\nText → Vestaboard character codes |\n`vbml` |\nCompile text + color hints → code grid; the 45-char + charset gate |\n`llm` |\nOpenAI-compatible client + prompt scaffolding |\n`delivery` |\n`VBoard` interface, `CloudRW` impl, `Local` stub, factory |\n`pipeline` |\ngenerate → compile → regenerate → truncate → deliver |\n`daemon` |\nAPScheduler + content-hash reload |\n`ui/` |\nStreamlit auth gate, config editors, preview/test-send |\n\nDependencies are deliberately lean: `pydantic`\n\n, `httpx`\n\n, `apscheduler`\n\n, `streamlit`\n\n,`streamlit-authenticator`\n\n, and `bcrypt`\n\n. That's the whole runtime.\n\n## How it works, end to end\n\n### 1. The character set (`charset.py`\n\n)\n\nThe foundation is a lookup table from characters to Vestaboard's documented integer codes. Space is `0`\n\n, `A–Z`\n\nare `1–26`\n\n, digits `1–9`\n\nmap to `27–35`\n\nand `0`\n\nto `36`\n\n, then a punctuation block (`! @ # $ ( ) - + & = ; : ' \" % , . / ?`\n\nand `°`\n\n), and finally the color chips:\n\n```\nCOLOR_CODES = {\n    \"red\": 63, \"orange\": 64, \"yellow\": 65, \"green\": 66,\n    \"blue\": 67, \"violet\": 68, \"white\": 69, \"black\": 70, \"filled\": 71,\n}\n```\n\nThree tiny functions do all the work: `char_to_code`\n\n(case-insensitive lookup, `None`\n\nif unsupported), `is_supported`\n\n, and `encode_text`\n\n(which silently drops unencodable characters). This module is the single source of truth for \"what can the board actually display.\"\n\n### 2. Prompting the model (`llm.py`\n\n)\n\nThe LLM client is intentionally generic — it speaks the OpenAI `/chat/completions`\n\nshape, so you can point it at OpenAI, a local server, or anything compatible by setting a base URL, model name, and key.\n\nThe interesting part is the **system prompt**, which front-loads the constraints so the model gets it right most of the time without a round trip:\n\nYou write messages for a Vestaboard split-flap display. Output ONLY the message text. It must fit on 3 lines of at most 15 characters each (45 characters of content total). Use only A-Z, 0-9, spaces, and basic punctuation. You may add color accents using tokens like`{red}`\n\nor`{blue}`\n\nat the start of a line. Keep it punchy. No explanations, no quotes around the message.\n\nTwo details matter here. First, color is expressed as inline `{color}`\n\ntokens the model can emit naturally, which the compiler later turns into chip codes. Second, there's a `shorter=True`\n\nmode that appends *\"Your previous attempt was too long. Make it noticeably shorter.\"* — this is the retry lever\n\nthe pipeline pulls when validation fails. Generation runs at `temperature=0.9`\n\nfor a bit of variety, with a generous read timeout because some endpoints are slow.\n\n### 3. Compiling and validating (`vbml.py`\n\n)\n\nThis is the gate, and it's pure functions all the way down. `compile(text, color_hints_enabled) `\n\ndoes the following, bailing out with a reason string at the first failure:\n\n**Strip color hints**(`{red}`\n\netc.) so they don't count as content.**Reject unsupported characters**— anything that isn't a space and isn't in the charset fails immediately.** Enforce the 45-character content limit**, counting only non-space, supported glyphs.** Greedily word-wrap**the text into lines of ≤15 characters. If it needs more than 3 lines, or any single line exceeds 15, it fails.** Lay it onto the grid.**The board is a 6×22 surface; the Note's 15 columns are centered within the 22 (`col_offset = (22 - 15) // 2`\n\n), and the 3 text lines land on rows 1–3, each line itself centered within its 15. The result is a`list[list[int]]`\n\nof character codes.**Place color chips.** When hints are enabled, the first`{color}`\n\ntoken becomes a chip at the start of its line.\n\nThe output is a `CompileResult`\n\ncarrying the grid, the content length, a `valid`\n\nflag, and a human-readable `reason`\n\nwhen it's invalid. There's also a last-resort `truncate_to_fit`\n\nthat word-boundary-trims a too-long message down to something that *does* fit — used only after the model has had its chances.\n\n### 4. The pipeline (`pipeline.py`\n\n)\n\n`run_once`\n\nties generation and validation together with a retry loop. The logic is small enough to quote the heart of it:\n\n```\nfor attempt in range(1, MAX_ATTEMPTS + 1):\n    text = generate(cfg.llm, prompt.text, shorter=(attempt > 1))\n    result = vbml.compile(text, prompt.color_hints_enabled)\n    if result.valid:\n        break\n```\n\nSo: generate, compile, and if it doesn't fit, ask the model again with the \"make it shorter\" nudge — up to **3 attempts**. If all three fail, fall back to `truncate_to_fit`\n\nrather than give up. Only a valid grid gets handed to delivery. Every failure mode (LLM error, un-compilable output, delivery error, the not-yet-implemented local backend) returns a structured `PipelineResult`\n\ninstead of throwing, so the daemon can log it and move on. Note the dependency-injected `generate`\n\nand `deliver_factory`\n\nparameters — that's what makes the pipeline trivially testable without real HTTP.\n\n### 5. Delivery (`delivery.py`\n\n)\n\nDelivery hides behind a one-method `Protocol`\n\n:\n\n``` python\n@runtime_checkable\nclass VBoard(Protocol):\n    def send(self, grid: list[list[int]]) -> None: ...\n```\n\n`CloudRW`\n\nimplements it by POSTing the JSON grid to `https://rw.vestaboard.com/`\n\nwith the `X-Vestaboard-Read-Write-Key`\n\nheader. `LocalAPI`\n\nis a stub that raises `NotImplementedError`\n\n— the interface is ready, the implementation deferred. A `make_delivery`\n\nfactory picks the backend from config. Swapping backends is a one-word config change, exactly as the constraints demanded.\n\n### 6. The scheduler daemon (`daemon.py`\n\n)\n\nThe daemon turns each enabled prompt's 5-field cron string into an APScheduler `CronTrigger`\n\n, then sits in a 5-second poll loop watching the config file. The clever bit is *how* it detects changes:\n\n``` python\ndef _signature(self):\n    data = self.config_path.read_bytes()\n    return hashlib.sha256(data).hexdigest()\n```\n\nIt hashes the file **contents** rather than trusting `mtime`\n\n. Filesystem modification-time granularity is one second on some mounts, so an edit landing in the same tick as the previous sync could be missed forever. A content hash can't be fooled that way. When the hash changes, the daemon rebuilds all jobs from scratch — hot reload, no restart, picked up within ~5 seconds.\n\n### 7. The UI and auth (`ui/`\n\n)\n\nThe front end is Streamlit: an authentication gate in front of pages for credentials, prompts & schedules, and a preview/test-send panel. It's single-user — the password is **bcrypt-hashed** (never stored or logged in plaintext) via `streamlit-authenticator`\n\n, and every page lives behind the gate. On first run, the UI prompts you to set the admin password.\n\n## Security: secrets that stay secret\n\nBecause the config UI is meant to be exposed to the internet, secret hygiene was non-negotiable from the start:\n\n**Atomic, locked-down config writes.**`save_config`\n\nwrites to a temp file,`chmod`\n\ns it to`0600`\n\n, and`os.replace`\n\ns it into place — so a reader never sees a half-written file, and the secrets-bearing config is only ever readable by its owner.**Centralized secret redaction.** Every API key — Vestaboard, local, and LLM — is registered with the logging layer (`register_secret`\n\n) the moment it's loaded or used. A logging filter scrubs those values from all output, at every level, including tracebacks. Keys simply cannot leak into logs.**Hashed password, never plaintext.** bcrypt, stored as a hash in config, verified on login.**Localhost-only binding.** The app speaks plain HTTP and binds to`127.0.0.1`\n\nonly. TLS is the reverse proxy's job.\n\n## Deployment\n\nThere are two supported ways to run it, and both run the same two processes against a shared config.\n\n### Containers (the quick path)\n\nA multi-stage Dockerfile builds a single image with `uv`\n\n, running as a non-root user (`uid 10001`\n\n). `compose.yml`\n\nthen runs that one image as **two services** — `ui`\n\nand `scheduler`\n\n— sharing a named volume mounted at `/data`\n\n:\n\n```\ndocker compose up -d --build\n```\n\n- The UI is published on\nonly — never directly on a public port.`127.0.0.1:8501`\n\n- Config lives on the\n`vboard-config`\n\nvolume at`/data/config.json`\n\n. No secrets are baked into the image. - Both services run with\n`no-new-privileges`\n\nand all Linux capabilities dropped; the UI has a health check hitting Streamlit's`/_stcore/health`\n\n.\n\n### systemd (the host-native path)\n\nThe `deploy/`\n\ndirectory ships two unit files that run the UI and scheduler as a dedicated, unprivileged `vboard`\n\nuser out of `/opt/vboard`\n\n, reading `/opt/vboard/config.json`\n\n. Install the user, `uv sync`\n\nthe deps, drop the units into `/etc/systemd/system/`\n\n, and `systemctl enable --now`\n\nboth.\n\n### TLS in front\n\nEither way, the app never handles certificates. A reverse proxy terminates TLS and forwards to `127.0.0.1:8501`\n\n. **Caddy** does it in three lines with automatic Let's Encrypt:\n\n```\nyour.domain {\n    reverse_proxy 127.0.0.1:8501\n}\n```\n\n**nginx** works too — the one thing that matters is forwarding the WebSocket upgrade headers, because Streamlit depends on them.\n\nThe flow for an operator is: open the UI, set the admin password, paste in the Vestaboard and LLM credentials, add prompts with cron schedules, hit preview to sanity-check the rendered grid, and walk away. The scheduler picks up every change within five seconds.\n\n## What I'd reach for next\n\nA few things are stubbed with their interfaces already in place: the **Local API** delivery backend, **multi-user** accounts, **encryption of secrets at rest**, and **message history / analytics**. The delivery `Protocol`\n\nand the config models were designed so these slot in without disturbing the core.\n\nThe part I'm happiest with is the division of labor: the LLM is treated as creative but untrustworthy, and a small, pure, exhaustively-tested compiler has the final say on what the board displays. That's what makes it safe to point an open-ended prompt at a physical object in my living room and let it run on a timer — the model can be as imaginative as it likes, but it will *never* push something the Vestaboard can't render. Connecting an LLM to a beautiful, constrained little display turned out to be less about the model and more about the gate in front of it.", "url": "https://wpnews.pro/news/teaching-an-llm-to-speak-vestaboard-note-building-vestaboard-ai", "canonical_source": "https://corti.com/teaching-an-llm-to-speak-vestaboard-note-building-vestaboard-ai/", "published_at": "2026-06-27 19:48:54+00:00", "updated_at": "2026-06-27 20:03:05.570361+00:00", "lang": "en", "topics": ["large-language-models", "ai-tools", "developer-tools"], "entities": ["Vestaboard", "OpenAI", "GitHub", "Streamlit", "APScheduler", "techpreacher"], "alternates": {"html": "https://wpnews.pro/news/teaching-an-llm-to-speak-vestaboard-note-building-vestaboard-ai", "markdown": "https://wpnews.pro/news/teaching-an-llm-to-speak-vestaboard-note-building-vestaboard-ai.md", "text": "https://wpnews.pro/news/teaching-an-llm-to-speak-vestaboard-note-building-vestaboard-ai.txt", "jsonld": "https://wpnews.pro/news/teaching-an-llm-to-speak-vestaboard-note-building-vestaboard-ai.jsonld"}}