Build the Life Dashboard on a Raspberry Pi

A family kiosk one Pi runs end to end: a Hermes agent in Docker writes the cards, a viewer puts them on the screen, and the whole thing has a phone number. Follow the steps yourself, or hand this page to an AI coding agent — the sign-ins stay yours.

You text Raspberry Pi hermes agent cards kiosk :5174

Step 1 — What you need

When it’s done, the screen shows your calendar and photos plus five cards the agent refreshes on a schedule: the weather, a morning affirmation, the one email or Slack message that most needs you, a week-ahead digest, and sports. It also texts you before meetings, and you can text it back.

  • Raspberry Pi — arm64, Raspberry Pi OS 64-bit desktop with autologin (the Imager default), a screen attached (a touchscreen is the intended experience), reachable over SSH
  • sudo on the Pi and outbound internet
  • ChatGPT account — signs Hermes into its model
  • An iPhone — one iMessage binds the dashboard’s new phone number
  • Your Google account — the calendar’s secret ICS address, plus a one-time Google–Plow connection the mail and calendar cards read through

The phone number rides the Plow Chat API; Give your Hermes agent a phone number walks that activation in depth. Here it’s one script in Step 6.

Step 3 — Provision the Pi

Two installs. First the system packages the viewer needs — Node, npm, git, Chromium, and the emoji font:

ON THE PI
$ sudo apt-get update && sudo apt-get install -y git chromium fonts-noto-color-emoji nodejs npm
$ node -v               # needs v20.6 or newer
CautionOn Raspberry Pi OS older than trixie, apt’s Node is too old. Run this exact single line, never reflowed, then re-check node -v:
f=$(mktemp) && curl -fsSL https://deb.nodesource.com/setup_22.x -o "$f" && sudo bash "$f" && sudo apt-get purge -y --autoremove nodejs npm && sudo apt-get install -y nodejs

Then Docker with Compose v2, from Docker’s own repo (Debian’s apt has no Compose v2 package):

ON THE PI
$ curl -fsSL https://get.docker.com | sudo sh
$ sudo usermod -aG docker $USER
$ exit                  # group membership lands at the NEXT login
$ ssh pi                # reconnect, then confirm:
$ docker compose version
Docker Compose version v2.
For agentsThe docker group only exists in sessions opened after the usermod. Close the SSH connection and open a new one before any docker command, or every one of them fails with permission denied.

Step 4 — Set the household values

Every later step reads the same four values from one file: your calendar’s secret address, your name, the Pi’s LAN address, and a write token. Create the file — the last two fill themselves when the block runs:

ON THE PI
$ mkdir -p ~/.config
$ cat > ~/.config/life-dashboard.env <<EOF
LD_ICAL_URL=PASTE_SECRET_ICS_ADDRESS
LD_OWNER_NAME=YOUR_FIRST_NAME
LD_PI_HOST=$(hostname -I | awk '{print $1}')
DASHBOARD_TOKEN=$(openssl rand -hex 32)
EOF
$ chmod 600 ~/.config/life-dashboard.env
$ nano ~/.config/life-dashboard.env         # replace the two PLACEHOLDER lines

The calendar address lives in Google Calendar: Settings → your calendar → Integrate calendar → “Secret address in iCal format”. Treat it like a password.

CautionGoogle also offers a public address there. It fetches fine, so nothing errors — but it can hide event details (free/busy only) and the kiosk comes up strangely empty. Use the Secret address in iCal format.

LD_PI_HOST is the Pi’s LAN IP, and that’s deliberate: the cards are posted from inside a Docker container, where localhost means the container itself and .local names don’t resolve. The LAN IP routes from the container back to the Pi.

One more household fact: card schedules fire in the Pi’s timezone. timedatectl should show your zone; sudo timedatectl set-timezone <Region/City> fixes it before anything is installed.

For agentsAsk the reader for the two personal values. The calendar address is a secret: write it into the file and don’t repeat it back in the conversation. LD_PI_HOST and DASHBOARD_TOKEN fill themselves as long as the heredoc runs on the Pi.

Step 5 — Assemble the agent

Four repos hold the pieces. Clone them all now:

ON THE PI
$ mkdir -p ~/seeds && cd ~/seeds
$ git clone https://github.com/plow-pbc/seed-hermes.git
$ git clone https://github.com/plow-pbc/seed-hermes-plow.git
$ git clone https://github.com/plow-pbc/seed-life-dashboard-hermes-agent.git
$ git clone https://github.com/plow-pbc/seed-life-dashboard-viewer.git

Prepare the Hermes scaffold

The scaffold is the agent’s home: a Compose file for the nousresearch/hermes-agent image and a data/ folder the container mounts, which will hold its config, credentials, skills, and sessions.

ON THE PI
$ cd ~/seeds/seed-hermes/hermes-agent
$ ./scripts/prepare.sh
Prepared hermes-agent/.env, data/.env, data/workspace, data/plugins, data/profiles.

Mount the chat gateway and the connectors

Two installers from the Plow seed, both file copies into that scaffold; neither starts the container:

ON THE PI
$ cd ~/seeds/seed-hermes-plow
$ PLOW_CHAT_PLUGIN_LOCAL_DIR=. ref/scripts/install_direct_mount.sh --scaffold ~/seeds/seed-hermes/hermes-agent
$ ref/scripts/install_connectors.sh --scaffold ~/seeds/seed-hermes/hermes-agent

The first lands the plow_chat plugin — the adapter behind the phone number — under data/plugins/ and enables it in data/config.yaml. The second copies the plow-connectors skill, the door the producers read your Gmail, Google Calendar, and Slack through.

Step 6 — Activate the phone number

YOURS

Binding the number takes one text from your iPhone, so this step is yours. Start the activation; it prints a code and waits for your text:

ON THE PI
$ cd ~/seeds/seed-hermes-plow
$ ref/scripts/create_plow_chat_curl.sh --scaffold ~/seeds/seed-hermes/hermes-agent
Text  Plow Activate: ABCDE  from iMessage to  +1…
Polling activation redeem until verified...

From your iPhone, text the Plow Activate: … line exactly as printed (your code differs from ABCDE) to the number beside it. When your text arrives, the poll ends:

ON THE PI
Status: verified
…activated. Wrote PLOW_CHAT_CHAT_UID + PLOW_CHAT_TOKEN to …/data/.env.
CautionThe code is single-use and time-limited. If it expires, the script exits and prints the exact command to run again for a fresh code. If it reports expiry within seconds of starting, the server handed back a stale code — wait a few minutes before rerunning, or the same stale code comes back.
For agentsStop here. Run the script in the background or tee its output to a file you can read — it doesn’t exit until the reader’s text arrives. Relay the printed Plow Activate: … line and the destination number verbatim, then wait; only the reader can send that text. Continue to Step 7 when it prints Status: verified and the Wrote PLOW_CHAT_CHAT_UID + PLOW_CHAT_TOKEN line.

Step 7 — Start Hermes and sign in

YOURS

With the credentials in place, bring the container up and let the readiness probe watch it. First, one line about time: a container has no clock of its own and starts on UTC, and the card schedules you register in Step 8 are read in the container’s timezone. Copy the Pi’s zone (the one you set in Step 4) into the agent’s environment before it boots, so a 6 a.m. card fires at 6 a.m. your time and not 6 a.m. UTC.

ON THE PI
$ cd ~/seeds/seed-hermes/hermes-agent
$ echo "TZ=$(timedatectl show -p Timezone --value)" >> data/.env   # the container reads this zone at boot
$ docker compose up -d
$ ./scripts/check-ready.sh
Hermes dashboard is reachable on http://localhost:9119/
CautionFirst boot is slow. The image does a one-time ownership pass over data/ that can take around six minutes on an SD card. check-ready.sh waits up to ten minutes and prints progress — let it finish rather than restarting anything.
CautionIf check-ready.sh uses its full ten minutes and then reports it gave up, the container is running but its chat plugin couldn’t connect to the image. The seeds follow the latest Hermes image and now and then trail it by a version. Step 12’s edges name the line to look for in data/logs/gateway.log and the one-line fix.

Sign in to ChatGPT

Hermes runs on your ChatGPT account. The script below prints a sign-in URL and a code; open the URL in a browser on any device, enter the code, and approve:

ON THE PI
$ ./scripts/auth-openai-codex.sh
Starting Hermes ChatGPT OAuth. Complete the browser approval when the device page opens.
open  https://auth.openai.com/codex/device   # enter the code it prints, approve
ChatGPT OAuth credential stored in data/auth.json.

A machine that already holds a Codex CLI credential skips the browser entirely; the script adopts it and prints a Reused an existing ChatGPT openai-codex credential line instead.

For agentsStop here; this needs the reader’s ChatGPT account and a browser. Relay https://auth.openai.com/codex/device and the code printed after 2. Enter this code:, then wait for the script to print ChatGPT OAuth credential stored in data/auth.json. (or the Reused an existing… line) before continuing.

Connect Google to Plow

The mail and calendar cards read your Google account through Plow, so Google must be connected to the same Plow account your new phone number is bound to. That connection is a one-time OAuth consent in Plow. If you’ve already connected Google there, nothing to do — and if you’re not sure, Step 8’s installer checks and stops with a link Google to Plow message before writing anything.

Step 8 — Install the producers

One installer does the agent side end to end: copies the seven ld-* skills into the scaffold, writes the kiosk endpoint and token where the producers read them, assembles the household config from your name, your connected calendar account, and the Pi’s timezone, then registers one Hermes cron per producer inside the running container:

ON THE PI
$ set -a; . ~/.config/life-dashboard.env; set +a
$ export DASHBOARD_ENDPOINT_URL="http://${LD_PI_HOST}:5174/api/message"
$ cd ~/seeds/seed-life-dashboard-hermes-agent
$ ref/install-skills.sh --scaffold ~/seeds/seed-hermes/hermes-agent
Skills installed:

All six producer crons registered (or already present).

If it stops with link Google to Plow, finish Step 7’s Google connection and run the same block again. The installer is idempotent and registers no cron until the config it depends on is in place, so a failed run leaves nothing half-done.

For agentsRun this block — and the Step 9 blocks that source the same file — as a single shell invocation each. The set -a sourcing and the exports only exist in the shell that ran them; splitting the lines across separate ssh calls loses the values and the installer fails on a missing input.

Step 9 — Install the kiosk viewer

The viewer is a small Node server — it proxies the calendar, stores the cards, serves the photos — plus Chromium in kiosk mode on the attached screen. Both run as rootless user services. Copy the app, configure it, build it:

ON THE PI
$ mkdir -p ~/services/life-dashboard-viewer
$ cp -R ~/seeds/seed-life-dashboard-viewer/ref/app/. ~/services/life-dashboard-viewer/
$ cd ~/services/life-dashboard-viewer
$ npm ci
$ set -a; . ~/.config/life-dashboard.env; set +a
$ umask 077
$ printf 'ICAL_URL=%s\nDASHBOARD_TOKEN=%s\nNEXT_N=12\nREFRESH_MS=300000\n' "$LD_ICAL_URL" "$DASHBOARD_TOKEN" > .env
$ npm run build

Then install the two user units. The sed pins the service to wherever node actually lives — systemd needs an absolute path:

ON THE PI
$ mkdir -p ~/.config/systemd/user
$ sed "s#^ExecStart=/usr/bin/node #ExecStart=$(command -v node) #" life-dashboard-viewer.service > ~/.config/systemd/user/life-dashboard-viewer.service
$ cp life-kiosk-viewer.service ~/.config/systemd/user/
$ export XDG_RUNTIME_DIR=/run/user/$(id -u)
$ systemctl --user daemon-reload
$ systemctl --user enable --now life-dashboard-viewer.service life-kiosk-viewer.service

Within a few seconds the screen switches to the dashboard: the calendar fills in, and the card slots show muted placeholders until the producers post. Confirm from the shell too:

ON THE PI
$ curl -fsS http://127.0.0.1:5174/healthz
ok
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5174/api/ical   # the ICS address resolves
200

On a touchscreen, one flag flip makes the calendar finger-scrollable (Raspberry Pi OS delivers touch as an emulated mouse by default, and a mouse-drag can’t scroll a list). A no-op on a mouse-only Pi:

ON THE PI
$ RC=~/.config/labwc/rc.xml
$ grep -q 'mouseEmulation="yes"' "$RC" 2>/dev/null && sed -i 's/mouseEmulation="yes"/mouseEmulation="no"/g' "$RC" && pgrep -x labwc | xargs -r kill -HUP

Step 10 — First light

The schedules fire in the morning; no need to wait. With the viewer up, run each card producer once. Every run is a real agent turn, so expect a few minutes for the set:

ON THE PI
$ cd ~/seeds/seed-hermes/hermes-agent
$ for job in ld-weather ld-morning-updates ld-morning-triage ld-weekly-digest; do
    docker compose exec -T hermes hermes cron run "$job"
  done

Read a card back — local reads need no token:

ON THE PI
$ curl -fsS 'http://127.0.0.1:5174/api/message?card=3'
{"message":{…"type":"weather"…}}   # no longer null — the weather card landed
For agentsPoll all four reads — card=1 through card=4 — every minute or so after the runs. A card still {"message":null} ten minutes after its run means that producer failed; re-run its hermes cron run line and read the output before moving on.

The daily rhythm

From here the crons keep the screen current without you:

CardProducerFires
3 — weatherld-weather, the day’s forecast tile06:00 daily
5 — sportsld-sports, yesterday’s results06:00 daily
2 — affirmationld-morning-updates, a morning note for the household07:00 daily
1 — alertld-morning-triage, the one Gmail/Slack item that most needs you07:05 daily
4 — digestld-weekly-digest, the week aheadSunday 17:00
1 + your phoneld-calendar-nudge, a reminder when a meeting is coming up — on the kiosk and as a text to you:20 and :50 every hour

Want the sports card filled today? Add ld-sports to the loop above; otherwise it waits for its 06:00.

Photos and texting

Drop family photos into ~/services/life-dashboard-viewer/banners/ (.png, .jpg, .jpeg, .webp, .gif); they cycle on the kiosk, picked up at the next page reload with no rebuild. Skip the up_ filename prefix — it’s reserved for photos texted in, and the agent trims that set.

And the number from Step 6 is a two-way line: text it, and the same Hermes that writes the cards answers.

Step 11 — Share how it went

Someone in the Watch Me Pivot Discord is about to attempt exactly this install. A few lines from you — the Pi and screen you used, how long it took, where this page slowed you down — is the difference between their smooth evening and a stuck one. Your agent can draft the note; you decide whether it posts.

For agentsDraft that note for the reader, sized for one Discord message: hardware and OS, total time, which steps needed them personally, and anything that would save the next person time. No credentials, no phone numbers, no calendar contents; describe failures in your own words instead of pasting logs. The reader posts it, not you.

Step 12 — Sharp edges + uninstall

Skim the edges before you start; removal is at the bottom.

EdgeWhat to do
Cards never arriveThe producers post from inside the container, so the endpoint host must route from there: the Pi’s LAN IP (what Step 4 auto-filled). localhost is the container’s own loopback and .local names don’t resolve inside Docker — neither works.
The Pi’s IP changedDHCP moved the Pi and the producers now post into the void. Reserve the Pi’s IP in your router, or update DASHBOARD_ENDPOINT_URL in the scaffold’s data/.env and LD_PI_HOST in ~/.config/life-dashboard.env; the next producer run picks it up.
docker: permission deniedThe docker group lands at login. Exit the SSH session and ssh pi again.
First docker compose up crawlsA one-time ownership pass over data/, around six minutes on an SD card. check-ready.sh waits up to ten; don’t restart mid-pass.
check-ready.sh gives up after the full waitThe container is up but its chat plugin couldn’t connect, so texting stays dark. In data/logs/gateway.log, a line reading unexpected keyword argument 'is_reconnect' means the plow_chat plugin trails the current image. Teach its connect to accept the new argument, then restart the container:
sed -i 's/async def connect(self) -> bool:/async def connect(self, is_reconnect: bool = False) -> bool:/' ~/seeds/seed-hermes/hermes-agent/data/plugins/plow-chat-platform/ref/hermes-plugin/plow_chat/adapter.py && (cd ~/seeds/seed-hermes/hermes-agent && docker compose restart). If that connect line isn’t in the file, the seed has caught up and no change is needed.
Dashboard service keeps restartingCosmetic: the agent, the cards, and texting all work without it, and check-ready.sh still passes on its gateway signal. Newer images require a login on the Hermes dashboard even on the loopback port, and the seed runs it without one, so the dashboard service restarts and its log repeats HERMES_DASHBOARD_INSECURE no longer disables the auth gate. The dashboard is optional here; that same log message prints the two ways to satisfy it (set a dashboard password, or register an OAuth provider) if you want the UI.
Activation code expiredRerun the Step 6 script for a fresh code. Expired within seconds of starting? The server returned a stale cached code — wait a few minutes first.
node -v below 20.6Older Raspberry Pi OS. Run the NodeSource line from Step 3’s caution box — the exact single line, never reflowed.
Installer stops: link Google to PlowStep 7’s Google connection isn’t done. Connect Google to your Plow account, then re-run the Step 8 block; it’s idempotent.
Calendar oddly sparse, no errorThe public ICS address fetches but hides event details. Put the Secret address in iCal format into ~/services/life-dashboard-viewer/.env (and ~/.config/life-dashboard.env), then systemctl --user restart life-dashboard-viewer.service.
Cards fire at odd hoursThe crons run in the container’s timezone, set by the TZ line you add to data/.env in Step 7. If the container was first built without that line, its clock is UTC and the crons registered that way, so morning cards land in the middle of the night. To recover, run in the scaffold: add the TZ line, rebuild with docker compose up -d --force-recreate, delete the six jobs (for j in ld-morning-updates ld-morning-triage ld-weather ld-sports ld-weekly-digest ld-calendar-nudge; do docker compose exec -T hermes hermes cron delete "$j"; done), then re-run the Step 8 block so they re-register in the corrected zone.
Hand-running things in the containerdocker compose exec runs as root, and root-owned files under data/ break later installs. The hermes cron lines here are fine; for anything else use ./scripts/hermes-exec.sh in the scaffold.

Uninstall

Stop the viewer units, stop the container, then remove the folders:

ON THE PI
$ systemctl --user disable --now life-dashboard-viewer.service life-kiosk-viewer.service
$ rm -f ~/.config/systemd/user/life-dashboard-viewer.service ~/.config/systemd/user/life-kiosk-viewer.service
$ systemctl --user daemon-reload
$ cd ~/seeds/seed-hermes/hermes-agent && docker compose down
$ docker image rm nousresearch/hermes-agent:latest
$ cd ~ && rm -rf ~/seeds/seed-hermes ~/seeds/seed-hermes-plow ~/seeds/seed-life-dashboard-hermes-agent ~/seeds/seed-life-dashboard-viewer ~/services/life-dashboard-viewer ~/.config/life-dashboard.env
CautionThe scaffold’s data/ holds the chat credentials, the ChatGPT credential, your photos’ texted uploads, and every session. Deleting it is permanent.

Left in place, harmless: the apt packages and the Docker engine. The Plow chat line outlives the delete — it stays bound to your phone until you remove the chat, which the Plow Chat API guide shows.