Teaching an LLM to Speak Vestaboard Note: Building Vestaboard AI

TechPreacher1 pts0 comments

Teaching an LLM to Speak Vestaboard Note: Building Vestaboard AI

Sign in<br>Subscribe

A 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.<br>That 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.<br>This post walks through what it is, how it works module-by-module, and how it's deployed.<br>The application can be found on GitHub at https://github.com/techpreacher/vestaboard-ai.<br>The shape of the problem<br>The whole design falls out of four hard constraints, and it's worth stating them up front because they drive every decision downstream:<br>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.<br>A restricted character set. Only Vestaboard's glyphs render: A–Z, 0–9, a specific punctuation set, a degree sign, and color chips. Anything else has to be substituted or rejected.<br>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.<br>Two delivery backends. Vestaboard offers a Cloud Read/Write API and a Local API. The code has to treat them as interchangeable.<br>The guiding principle: never trust the model. The LLM is a suggestion engine. A deterministic, heavily-tested core decides what actually reaches the board.<br>prompt → LLM generates message → compile to VBML + code grid<br>→ validate (45 chars / 3×15 / charset) → deliver to board → repeat on schedule<br>Architecture: two processes, one file<br>The system is split into two independent processes that never talk to each other directly . They coordinate through a single config.json on disk.<br>config.json (0600, service user) ← single source of truth<br>▲ write (atomic: temp + os.replace) ▲ read (poll content hash every 5s)<br>│ │<br>vboard-ui (Streamlit) vboard-scheduler (APScheduler daemon)<br>auth + edit config generate → compile → deliver<br>The 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."<br>The scheduler daemon is the only thing that delivers. It reads config, builds cron jobs, and runs the generate→deliver pipeline when a job fires.<br>Why 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.<br>The Python package (src/vboard/) breaks down like this:

Module<br>Responsibility

config<br>Pydantic models; atomic 0600 load/save

logging_setup<br>Logger + secret-redaction filter

charset<br>Text → Vestaboard character codes

vbml<br>Compile text + color hints → code grid; the 45-char + charset gate

llm<br>OpenAI-compatible client + prompt scaffolding

delivery<br>VBoard interface, CloudRW impl, Local stub, factory

pipeline<br>generate → compile → regenerate → truncate → deliver

daemon<br>APScheduler + content-hash reload

ui/<br>Streamlit auth gate, config editors, preview/test-send

Dependencies are deliberately lean: pydantic, httpx, apscheduler, streamlit,<br>streamlit-authenticator, and bcrypt. That's the whole runtime.<br>How it works, end to end<br>1. The character set (charset.py)<br>The foundation is a lookup table from characters to Vestaboard's documented integer codes. Space is 0, A–Z are 1–26, digits 1–9 map to 27–35 and 0 to 36, then a punctuation block (! @ # $ ( ) - + & = ; : ' " % , . / ? and °), and finally the color chips:<br>COLOR_CODES = {<br>"red": 63, "orange": 64, "yellow": 65, "green": 66,<br>"blue": 67, "violet": 68, "white": 69, "black": 70, "filled": 71,<br>Three tiny functions do all the work: char_to_code (case-insensitive lookup, None if unsupported), is_supported, and encode_text (which silently drops unencodable characters). This module is the single source of truth for "what can the board actually display."<br>2. Prompting the model (llm.py)<br>The LLM client is intentionally generic — it speaks the OpenAI /chat/completions shape, so you can point it at OpenAI, a local server, or anything compatible by setting a base URL, model name, and key.<br>The 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:<br>You write messages for a...

vestaboard config characters model board grid

Related Articles