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.
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.
sudo on the Pi and outbound internetThe 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.
Everything from Step 3 on runs in a shell on the Pi. Get there first.
A stock Raspberry Pi OS image answers to raspberrypi.local. If yours doesn’t, your router’s device list knows its address, and ~/.ssh/config may already hold an alias from an earlier project.
$ grep -iE 'host|hostname' ~/.ssh/config # any saved alias for the Pi? $ ping -c1 raspberrypi.local # does the default name answer? $ dns-sd -B _ssh._tcp # list SSH hosts on the LAN (Ctrl-C to stop)
~/.ssh/config, no answer at raspberrypi.local, or several SSH hosts on the LAN — stop and ask the reader how they SSH into this Pi (the exact user@host they type), then use that. Never pick a promising-looking host from a scan and continue: from Step 3 on you are installing packages and Docker on whatever machine you connected to, and a guess can land on the wrong one. Before the first sudo, have the reader confirm that hostname on the connected machine prints the name they expect.piThe rest of this page says ssh pi and means it literally. One config entry plus one key copy makes that work without a password. The username is whatever the Pi was imaged with:
$ cat >> ~/.ssh/config <<'EOF' Host pi HostName raspberrypi.local User <user> EOF $ ssh-copy-id pi # type the Pi's password once; passwordless after this $ ssh pi # you're in — the following steps run here
Two installs. First the system packages the viewer needs — Node, npm, git, Chromium, and the emoji font:
$ sudo apt-get update && sudo apt-get install -y git chromium fonts-noto-color-emoji nodejs npm $ node -v # needs v20.6 or newer
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 nodejsThen Docker with Compose v2, from Docker’s own repo (Debian’s apt has no Compose v2 package):
$ 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.…
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.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:
$ 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.
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.
LD_PI_HOST and DASHBOARD_TOKEN fill themselves as long as the heredoc runs on the Pi.Four repos hold the pieces. Clone them all now:
$ 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
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.
$ cd ~/seeds/seed-hermes/hermes-agent $ ./scripts/prepare.sh Prepared hermes-agent/.env, data/.env, data/workspace, data/plugins, data/profiles.
Two installers from the Plow seed, both file copies into that scaffold; neither starts the container:
$ 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.
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:
$ 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:
Status: verified …activated. Wrote PLOW_CHAT_CHAT_UID + PLOW_CHAT_TOKEN to …/data/.env.
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.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.
$ 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/
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.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.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:
$ ./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.
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.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.
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:
$ 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.
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.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:
$ 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:
$ 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:
$ 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:
$ 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
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:
$ 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:
$ curl -fsS 'http://127.0.0.1:5174/api/message?card=3' {"message":{…"type":"weather"…}} # no longer null — the weather card landed
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.From here the crons keep the screen current without you:
| Card | Producer | Fires |
|---|---|---|
| 3 — weather | ld-weather, the day’s forecast tile | 06:00 daily |
| 5 — sports | ld-sports, yesterday’s results | 06:00 daily |
| 2 — affirmation | ld-morning-updates, a morning note for the household | 07:00 daily |
| 1 — alert | ld-morning-triage, the one Gmail/Slack item that most needs you | 07:05 daily |
| 4 — digest | ld-weekly-digest, the week ahead | Sunday 17:00 |
| 1 + your phone | ld-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.
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.
Skim the edges before you start; removal is at the bottom.
| Edge | What to do |
|---|---|
| Cards never arrive | The 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 changed | DHCP 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 denied | The docker group lands at login. Exit the SSH session and ssh pi again. |
First docker compose up crawls | A 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 wait | The 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 restarting | Cosmetic: 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 expired | Rerun 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.6 | Older Raspberry Pi OS. Run the NodeSource line from Step 3’s caution box — the exact single line, never reflowed. |
Installer stops: link Google to Plow | Step 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 error | The 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 hours | The 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 container | docker 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. |
Stop the viewer units, stop the container, then remove the folders:
$ 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
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.