---
title: The morning board — your day on the TV, and in the room, while you make coffee
series: Build It Yourself — briefs
brief: 04
license: CC BY 4.0
tested_on: Claude Code 2.1 (Oct 2026) · Node 25 · Playwright 1.58 and 1.63 (Chromium) · catt 0.13 · macOS 26 — steps 1–3 and 5 built and run by a fresh headless session on sample data (1–3 re-run after fixes); step 4 run in --dry mode with real device discovery (TV wake + cast not exercised in the test)
effort: one evening (3–4 h) for the board on screen; +1 h for the voice and the wake-up trigger
---

# The morning board

## TL;DR
Twenty minutes after you wake up, the TV in the living room turns itself on and shows one
screen with your whole morning on it — today's calendar, the to-dos that are actually
yours, the weather, a few headlines — while a speaker reads you a 40-second version out
loud. You stop opening five apps before your first coffee. This brief is the design, the
traps (most of them are about *getting a picture onto a TV that is asleep*), and the
prompts to build your own.

---

## What you get (a scene)

> *(07:20 — kitchen, kettle on. The TV wakes up on its own.)*
>
> **TV:** a single dark board — *Good morning* · 07:20 · 18° and sunny.
> Three tiles: **2 meetings** · **3 to-dos** · **bins tonight**.
> Below: "10:00 Dentist (Tue 7.10)", "13:30 Call with the bank", the three to-dos,
> three headlines.
>
> **Speaker:** "Good morning. Two things on the calendar today — the dentist at ten, and a
> call at half past one. Three to-dos, the oldest is the insurance form. Sunny, eighteen
> degrees. Bins go out tonight."
>
> *(07:40 — the board clears itself. The TV goes back to sleep.)*

Nobody pressed anything. If nobody woke up (a lazy Saturday, a trip), nothing plays to an
empty room.

---

## Prerequisites

- The always-on machine and the `notify.sh` chat line from **brief 00**
  (`start-here-always-on-agent.md`). Every other piece here is new.
- **A TV with Chromecast built in** (most Google TV / Android TV sets, or a Chromecast
  dongle) and, optionally, **a Cast speaker** for the voice — both on the same Wi-Fi as the
  machine. Cast has no login: anything on the network can cast. It also means this does not
  work from a VPS.
- **Node 20+**, a headless browser (Playwright's Chromium), and `catt` (a Cast command-line
  tool: `pipx install catt`).
- Your **calendar** reachable from a script (Google Calendar API, an `.ics` URL, CalDAV —
  anything that returns today's events) and wherever your to-dos live.
- Optional: a "woke up" signal from your phone (an alarm-stopped automation that hits the
  machine — brief 05 builds exactly that). Without it, a fixed time works fine.

---

## Architecture (the 15-line version)

```
 trigger: fixed time ─┐
      or "woke up" +20m ┴─▶ once-a-day guard ──▶ collect ONCE ──▶ board.json (facts only)
                                                                   │
                                     compose (one LLM call, JSON)  ▼
                               summary + spoken script + card order ──▶ template fallback
                                                                   │
                         render HTML 1920×1080 ──▶ auto-fit ──▶ headless screenshot board.png
                                                                   │
              wake the TV (remote protocol) ──▶ cast the IMAGE ──┬──▶ stamp "shown today"
                                     speaker ◀── cast the voice ─┘     clear after 20 min
```

1. **Collect once, then compose.** One run gathers facts into a JSON file; nothing on the
   board fetches anything later. The LLM never *derives* a fact (a weekday, a count) — code
   computes them and the model only words them.
2. **The board is a picture, not a web page.** Render HTML to a PNG on the machine and cast
   the image. Many TV receivers will not run a live web page at all.
3. **Two devices, two jobs.** The TV shows, the speaker talks — a Cast device plays audio
   *or* visuals well, and a speaker across the room is louder than a TV on standby volume.
4. **The guard stamps after the cast, not before.** "Shown today" is written by the step
   that put pixels on the screen, so a run that died halfway retries instead of marking the
   day done.
5. **Empty is not a card.** A section with nothing in it disappears; "No open tasks 🎉" is
   noise you learn to skip, and then you skip the board.

---

## Build steps — paste each block into Claude Code, in order

### Step 1 — collect the facts (one JSON file)

```text
Create ~/agent/board/collect.js (Node 20+, no framework). It writes
~/agent/board/data/board.json for ONE person and exits. Sections:
- now: ISO timestamp, local time "HH:MM", greeting by hour (morning/afternoon/evening).
- events: calendar events for the next 3 days (the board shows today's, plus the next
  thing coming). For each: title, start "HH:MM", location if any, and a DAY LABEL computed
  in code from the absolute start date ("Today", "Tomorrow", else "Tue 7.10"). Never ask
  a model to work out a weekday.
  Skip cancelled events. If the calendar call returns an error object, FAIL the section
  loudly (an error that parses as "zero events" shows you an empty day).
- todos: open to-dos assigned to this person. Exclude anything the assistant created for
  its own bookkeeping (follow-up checks, "remember to verify X") — a filter in code, by
  type/creator field, not by a model.
- weather: today's high/low + a short condition text (wttr.in's JSON needs no key).
- news: up to 3 headlines from one or two RSS feeds, as {title, source}.
- tiles: small counts computed in code (events today, open to-dos, anything due today).
Every source is a separate try/catch: one failing source becomes {"error": "..."} in its
section, never a crashed run. Settings (person, city, feeds) come from flags/env with
defaults. Start with a --fixture flag that reads a sample calendar and to-do list from
./fixtures/*.json (shape them like your real API's responses, so swapping in the live call
later changes nothing downstream), plus --today=YYYY-MM-DD so fixtures never go stale.
```
**Check:** `node collect.js --fixture` writes `board.json`; every event has a day label you
did not have to think about; delete one fixture file and the run still succeeds with an
`error` in that one section.

### Step 2 — compose (one model call, strict JSON, a fallback)

```text
Create ~/agent/board/compose.js. Input: board.json. Output: board.composed.json with:
- summary: one sentence for the top of the board
- voice: a spoken script, plain prose, 40-60 words, numbers rounded, no lists read out,
  no URLs, nothing that is not in board.json
- order: the card keys (events, todos, news) ordered by importance for TODAY
  (weather lives in the hero row, not in a card)
Call the model once with board.json embedded in the prompt and "return only JSON with
exactly these keys" — describe the required keys FIRST in the prompt, not last. Use
`claude -p "<prompt>" --output-format json` (the result text is in .result) or any LLM API
you have. Validate the reply: extract the first {...} block (replies sometimes arrive wrapped in
prose or code fences), exact key set, voice 25-70 words, no URLs, order contains only known
keys — then drop from order, IN CODE, any section that is empty or errored (never trust the
model to do the omitting). Invalid, timed out
or the call failed → a deterministic TEMPLATE composition built from board.json in code
(the board must never depend on the model being up). Record which one produced the file
("composer": "model" | "template").
```
**Check:** run it twice — once normally, once with the model command deliberately broken
(e.g. a bad binary path in an env var). Both produce a valid `board.composed.json`; the
second says `"composer": "template"`.

### Step 3 — render a TV-sized picture

```text
Create ~/agent/board/render.js:
1. Build an HTML page from board.json + board.composed.json: a hero row (greeting, clock,
   weather), a row of big number tiles (only the non-zero ones), then cards in the composed
   order. Dark background, big type (it is read from a sofa three metres away), no
   scrollbars. Only sections that have content become cards. Cap every list at ~5 rows
   plus a "+N more" line — the auto-fit below is a safety net, not a layout strategy.
2. The TV cannot scroll. Wrap everything in a #fit container and, before the screenshot,
   scale it down until document height <= viewport height (iterate: transforms do not
   change scrollHeight, so recompute after each step). When scaling, also widen the
   container by 1/scale so a busy board still spans edge to edge instead of shrinking into
   the middle with an empty frame around it.
3. Screenshot with Playwright (Chromium, headless) at 1920x1080, after
   document.fonts.ready, to ~/agent/board/data/board.png. Pin the Playwright package
   version in package.json, install the matching browser ONCE with that same version
   (npx playwright@<pinned> install chromium), and fail with a clear message if the browser
   is missing — never let a later `npm install` float the version. If you relocate the
   browsers (PLAYWRIGHT_BROWSERS_PATH), set it identically for install AND render.
Size the type so an ordinary day renders at a scale near 1.0; if a normal board needs
heavy shrinking, the fonts are too big, not the fit too weak.
Also write the HTML next to the PNG so you can open it in a normal browser while styling.
```
**Check:** open `board.png`. Add 15 fake to-dos to the fixture and render again — the board
still fits the frame (smaller, not cut off). Empty the news fixture — the news card is gone,
not "No news".

### Step 4 — get it onto the TV (and the voice into the room)

```text
Create ~/agent/board/cast.sh <png> [voice.mp3]:
1. Find the devices by NAME, not IP: keep the last known IP per device name in
   ~/agent/board/data/devices.json; check that the cached IP still IS that device (a Cast
   device answers its own name on http://<ip>:8008/setup/eureka_info — after a router
   reshuffle an old address can belong to something else); if not, rediscover by name
   (catt scan, retry up to 3 times — discovery is flaky) and update the file. Never
   hard-code an IP; home routers reshuffle them after every power cut.
2. Wake the TV before casting. Google/Android TV accepts remote-control key presses over
   the Android TV Remote protocol (Python: androidtvremote2; one-time pairing shows a PIN
   on the TV). Send WAKEUP, check the reported power state, escalate to POWER only if it is
   still off, then HOME once it is on. If it is still off after that, exit non-zero with a
   clear message — do not report success.
3. Cast the image: catt -d <tv> cast <png>. Then, if a voice file was given, cast it to the
   SPEAKER device (not the TV) — start synthesising the voice in parallel with rendering
   so it is ready the moment the picture is up.
4. Schedule the clear: a detached background job that waits 20 minutes and runs
   catt -d <tv> stop, but only if no newer cast happened since (compare a cast id written
   to a marker file).
Any child process whose output you wait for: run with PYTHONUNBUFFERED=1 and send its
output to a log FILE, with a hard timeout — never wait on a captured pipe forever. (macOS
has no `timeout` command; use gtimeout from coreutils or a tiny wrapper.)
Distinct exit codes: device not found, TV did not wake, picture cast failed. A voice
failure only warns — the picture is already up.
Add --dry: resolve devices and print every command instead of running it.
```
**Check:** `cast.sh --dry data/board.png` prints the device IPs it resolved and the exact
commands. Then for real, with the TV in standby: the board appears without touching the
remote, the voice plays on the speaker, and 20 minutes later the screen clears.

### Step 5 — the trigger and the once-a-day guard

```text
Create ~/agent/board/run.sh — the ONLY entry point that casts:
- Refuses to cast unless BOARD_GATE_OK=1 is set in its environment (the scheduler and your
  "show it now" command set it; any stray old job or copy-paste does not). Without it:
  exit 2 before doing any work.
- Reads ~/agent/board/data/last-shown (a date). Already today → exit 0 quietly.
- Runs collect → compose → render → cast. Writes last-shown = today ONLY after cast.sh
  exits 0. A failure anywhere → notify.sh "board failed at <step>: <reason>" and leave
  last-shown alone, so the next trigger today retries.
- Quiet hours (e.g. 22:00-06:00, wrapping midnight) → exit 0, cast nothing, and do NOT
  stamp.
- Order of checks: gate token → already shown today → quiet hours. Hold a lock for the
  whole run so a timer and a "woke up" trigger cannot cast at the same moment.
- If the calendar AND the to-dos sections both came back as errors, do not cast: notify
  "board skipped: calendar and to-dos unavailable" and leave the stamp alone — a board of
  nothing but weather is not "shown", and the next trigger today can retry.
Trigger: a launchd/systemd timer at a fixed morning time, and/or an HTTP/queue "woke up"
signal that schedules run.sh 20 minutes later. Then add ~/agent/board/guard.sh, daily at
noon: if last-shown is not today, notify.sh one line "no board today — last shown <date>".
```
**Check:** run it twice in a row — one board, the second run exits immediately. Delete the
stamp, unplug the TV, run it — you get a "board failed at cast" message and no stamp; plug
it back in and run again — it shows. Run it without `BOARD_GATE_OK` — exit 2, nothing cast.

---

## The traps we hit (read these twice)

**1. A live web page cast to the TV was blank — while every tool said "success".** Casting a
URL (the "show this website" receiver) reported the session as loaded, but our TV never ran
the receiver and showed nothing. *Fix:* render to an image on the machine and cast the
image. It also makes debugging honest: the PNG on disk is exactly what should be on the
screen.

**2. The cast arrived, but the screensaver sat on top of it.** The TV was in ambient /
screensaver mode; the image loaded behind it (a video cast even played its *audio*) and
nobody saw a thing. A deeper standby ignored the first wake key entirely. *Fix:* press real
remote keys over the Android TV Remote protocol (WAKEUP → check power state → POWER only if
still off → HOME), and refuse to call it shown until the TV reports on. No developer mode
or ADB needed.

**3. "Shown today" was stamped by the decision, not by the screen.** Our once-a-day guard
read a date written when the run *decided* to show the board — before the cast. A run that
then failed or was held still marked the day done, and the board silently did not appear
for three mornings in a row. *Fix:* the stamp is written by the step that succeeded at the
thing you care about (the cast), plus a separate daily check that asks the human question
— "did a board show today?" — and messages you when the answer is no.

**4. An old scheduled job came back from the dead.** We moved the board from fixed times to
a wake-up trigger and "disabled" the old timer jobs, leaving them on disk for rollback. A
later login re-enabled one, and a board landed on the TV at night, unasked, while the new
gate was working perfectly. *Fix:* retire a job by moving its file out of the scheduler's
folder, and make the cast script itself demand a token (`BOARD_GATE_OK=1`) that only the
sanctioned path sets — so a stray scheduler cannot bypass the rules even if it runs.

**5. The model got the weekday wrong.** Asked to write "your dentist is on Tuesday", it
sometimes computed the day itself and was off by one. *Fix:* code pre-computes every day
label ("Today", "Tomorrow", "Tue 7.10") into the facts file; the model copies labels, it
never derives them.

**6. The board read the assistant's own to-dos out loud.** Follow-up checks the assistant
had filed for itself carried your name in both "requested by" and "assigned to", so they
matched "your to-dos" and the speaker read machine bookkeeping to you at breakfast. *Fix:* a
code filter that drops the assistant's own task types and self-assigned items — on every
surface that reads tasks to a human, not just this one.

**7. Device addresses drift.** After a power cut the router handed out new addresses; a
script with a hard-coded TV IP cast to nothing for two days. Discovery by name, meanwhile,
fails intermittently. *Fix:* remember the last good IP per device *name*, confirm the device
at that address still reports that name (an old address can now be someone else's device),
rediscover by name on failure, and write the new address back.

**8. A wait that never ended.** The voice step waited for the Cast tool to print "Playing",
but the tool block-buffered its output into our pipe, so the line arrived only when it
exited — every run sat out the full fallback timeout. Elsewhere, a run that captured a
child's output in memory hung for hours and left no log at all. *Fix:* `PYTHONUNBUFFERED=1`
for Python children, child output to a file, and a hard timeout that kills and reports.

**9. The renderer could not find its browser.** Headless-browser libraries expect one exact
browser build per library version. An unpinned install later pulled a newer library that
looked for a browser build that was not on disk, and the screenshot step failed until
someone noticed there was no board (a fresh test build of this brief hit the same mismatch
on its first try). *Fix:* pin the library version, install its browser with the same
pinned version, and make "browser missing" a loud failure that reaches your chat.

**10. A busy board shrank into the middle of the screen.** The auto-fit scaled the whole
board down to fit, which left a wide empty frame on a big TV. Forcing equal row heights
made it worse — the tallest card pushed everything past the frame and the fit shrank it
further. *Fix:* when scaling down, widen the container by the inverse of the scale so it
reflows edge to edge; distribute spare height as gaps between cards, never by stretching
rows.

---

## You know it works when

- [ ] With the TV in standby, `run.sh` puts the board on screen without anyone touching the
      remote, and the voice plays on the speaker within a few seconds of the picture.
- [ ] A second run the same day does nothing; a failed run leaves no stamp and messages you
      which step failed.
- [ ] Breaking the model call still produces a board (template composer).
- [ ] An empty section is absent, not "nothing here"; a crowded board still fits the screen.
- [ ] Running `cast.sh` without the gate token casts nothing.
- [ ] The noon guard messages you on a day the board did not show.
- [ ] After you change the TV's address (or reboot the router), the next run still finds it.

## Extend it

- **Ask first.** Instead of casting straight away, send "ready for the board?" to the chat
  (or a tablet on the wall) with *now / in 10 minutes / skip*, and give each slot its own
  no-answer default (morning: show anyway; evening: skip).
- **A crisper picture.** Many Cast receivers cap still images at 1280×720. Render a short
  video of the board instead (a few seconds of the settled frame) — video plays at the
  panel's native resolution.
- **Per-person boards.** Same pipeline, a different facts file and colour theme per person,
  triggered by each person's own wake-up signal.

## Changelog

- 2026-10-05 — first public version. Tested on the versions in the header.

---

*Part of **Build It Yourself** — free briefs on building a personal AI Chief of Staff, one
capability at a time: https://chief.natalicot.com/briefs. Free, forever. · License CC BY 4.0*
