A little Claude character that lives on a Raspberry Pi's touchscreen and dances whenever Claude Code on your Mac, Linux box, or Windows machine is waiting for your input.
| Idle / bored | Dancing (input needed) |
|---|---|
![]() |
![]() |
When you're heads-down in another window and Claude Code is sitting there asking "can I run this command?" or "what next?", the Pi spots it via a hook and the little character springs to life with the name of the project that's waiting.
┌──────────────────────────┐ ┌──────────────────────────────┐
│ Mac / Linux / Windows │ │ Raspberry Pi @ host.local │
│ running Claude Code │ │ │
│ │ │ Flask server on :8080 │
│ ~/.claude/settings.json │ │ ├─ POST /notify → dance │
│ ├─ Notification ────┼──┐ │ ├─ POST /idle → bored │
│ ├─ Stop ────┼──┤ │ ├─ POST /heartbeat → tick │
│ ├─ UserPromptSubmit ───┼──┤ │ ├─ POST /end → remove │
│ ├─ SessionStart ────┼──┼── HTTP POST ─►│ └─ GET /events (SSE) │
│ ├─ SessionEnd ────┼──┤ │ │
│ ├─ PreToolUse ────┼──┤ │ Chromium kiosk → index.html │
│ └─ PostToolUse ────┼──┘ │ └─ Grid of SVG mascots, │
│ │ │ one per Claude session │
│ ~/.claude/hooks/ │ │ │
│ └─ hook-pi.sh / .ps1 │ │ │
└──────────────────────────┘ └──────────────────────────────┘
Each Claude Code session is tracked separately on the Pi by its session_id,
so multiple Claudes running in parallel get their own mascot in a small grid
(up to 4 visible at once, dancing ones bubble to the front).
Seven Claude Code hook events
are wired up, all to a single hook-pi script with a subcommand:
| Hook | Subcommand | Pi action |
|---|---|---|
Notification |
notify |
That session's mascot starts dancing |
Stop |
notify |
That session's mascot starts dancing |
UserPromptSubmit |
idle |
Back to bored |
SessionEnd |
end |
Remove the mascot from the grid |
SessionStart |
heartbeat |
Add the mascot to the grid (idle) |
PreToolUse |
heartbeat |
Keep alive + show a whimsy word ("Frobnicating…") |
PostToolUse |
heartbeat |
Same as above |
PreToolUse and PostToolUse are pure heartbeats — they fire continuously
while Claude is working through tool calls, refreshing the session's
"last seen" timestamp and (optionally) updating the whimsy activity word.
After 10 minutes with no hook activity at all, the mascot is evicted. Hard
kills (closing the terminal, kill -9) don't fire SessionEnd, so this
eviction is the safety net.
The Pi runs a tiny Flask server that holds per-session state and streams snapshots to the browser via Server-Sent Events. The browser is Chromium in kiosk mode rendering a grid of SVG mascots with CSS keyframe animations.
This was built for a Waveshare-style 3.5" SPI touchscreen (the tft35a
overlay, 480×320 resolution), but anything that gives the Pi a framebuffer
will work — you'll just want to tweak the CSS for different aspect ratios.
What you need:
- A Raspberry Pi (Zero 2 W, 3, 4, or 5 — anything that can run Pi OS Desktop).
- A microSD card (8 GB+).
- A 3.5" SPI LCD that uses the GoodTFT / Waveshare driver tree. The cheap eBay/AliExpress 320×480 boards branded "MPI3508" or "Waveshare 3.5 RPi LCD (A)" generally work with this driver.
- Power supply for the Pi.
- A Mac (or any computer running Claude Code) on the same LAN as the Pi.
These instructions assume a fresh microSD card. If your Pi is already running with the LCD working, skip to step 4.
Use the official Raspberry Pi Imager. Pick Raspberry Pi OS (64-bit) — Desktop.
In the imager's settings cog (or Ctrl+Shift+X), pre-configure:
- Hostname:
claude-notify(this is what the Mac will talk to — if you pick something else, update the URL on the Mac side accordingly). - Username/password: anything you like. The examples below assume
username
jim; substitute yours. - Wi-Fi: join the same network as the Mac.
- SSH: enabled, public-key auth recommended.
- Locale / keyboard: whatever you prefer.
Flash the card, plug it into the Pi, power on. Give it a couple of minutes to do the first-boot resize. SSH in once:
ssh jim@claude-notify.localFor the kiosk to start automatically the Pi needs to boot straight into a graphical session as your user, no password prompt.
sudo raspi-configIn 1 System Options → S5 Boot / Auto Login, pick Desktop Autologin.
Reboot.
The screen needs a kernel overlay (tft35a) plus an fbcp userspace mirror
to show the regular HDMI framebuffer on the SPI panel. The easiest path is
the community goodtft/LCD-show
repo:
cd ~
git clone https://github.com/goodtft/LCD-show.git
chmod -R 755 LCD-show
cd LCD-show
sudo ./LCD35-showThat script edits /boot/firmware/config.txt, swaps to an X11 session
(the kiosk relies on X11, not Wayland), forces hdmi_cvt 320 480 60 6 0 0 0,
and reboots.
After the reboot the screen will be in portrait (320 wide × 480 tall).
This app is designed for landscape (480 wide × 320 tall) because that's
nicer for the mascot's wide arms. Rotate the framebuffer by editing
/boot/firmware/config.txt:
dtoverlay=tft35a:rotate=90
(Use 0, 90, 180, or 270 — pick whichever orientation looks right when
the Pi is on your desk.) Reboot once more.
Verify by SSHing in and running:
DISPLAY=:0 xrandr | head -2You should see current 480 x 320 (or matching your rotation).
cd ~
git clone https://github.com/jimbobbennett/claude-notify.git
cp -r claude-notify/pi ~/claude-notify-app # avoid name clash with this repo dir
# OR if you prefer to work from the cloned repo directly:
# keep the repo where it is and adjust the paths in start-kiosk.sh
# Use whichever layout you picked above. The autostart files assume
# the runtime lives at ~/claude-notify/, so set up a symlink or copy:
ln -snf ~/claude-notify-app ~/claude-notifyIf you want to keep things simple, just clone the repo's pi/ contents
straight into ~/claude-notify/:
mkdir -p ~/claude-notify
cp -r claude-notify/pi/. ~/claude-notify/
chmod +x ~/claude-notify/start-kiosk.shPython and Flask are usually pre-installed on Pi OS Desktop. Confirm:
python3 -c 'import flask; print(flask.__version__)'If Flask is missing:
sudo apt update
sudo apt install -y python3-flaskcd ~/claude-notify
python3 server.pyFrom any machine on the same LAN:
curl http://claude-notify.local:8080/state
# {"seq":0,"now":1700000000.0,"sessions":{}}
curl -X POST -H 'Content-Type: application/json' \
-d '{"session_id":"smoke","label":"smoke"}' \
http://claude-notify.local:8080/notify
# {"seq":1,"now":...,"sessions":{"smoke":{"state":"dancing", ...}}}Open http://claude-notify.local:8080/ in your browser and you should see
the mascot. Tap (or click) once to toggle states for a quick smoke test.
Ctrl-C the server when you're satisfied.
When you log in to the desktop, LXDE's autostart directory runs every
.desktop file it finds. Drop the supplied one in:
mkdir -p ~/.config/autostart
cp ~/claude-notify/claude-notify-kiosk.desktop ~/.config/autostart/The .desktop file just calls ~/claude-notify/start-kiosk.sh, which:
- Waits for the X server to come up (
xset q). - Disables screen blanking and DPMS.
- Starts the Flask server if it isn't already running.
- Launches Chromium in kiosk/
--appmode pointed athttp://localhost:8080. - Restarts Chromium if it dies, with exponential backoff and a long cool-down after repeated fast failures so a broken environment can't pin the Pi's CPU.
Reboot:
sudo rebootWhen the Pi comes back you should see the character idling on the LCD.
sudo apt install -y unclutterstart-kiosk.sh picks unclutter up automatically if present.
The "client" is whatever machine runs Claude Code. The supplied hook scripts are plain POSIX shell — they work as-is on macOS, Linux, and on Windows under WSL or Git Bash. Windows users who prefer native PowerShell can use the inline alternative further down.
The bash script uses curl and jq (to extract session_id / cwd from
the hook payload). jq is optional — without it the Pi still works, but
sessions all collapse into one default mascot since the script can't
parse the JSON. The PowerShell script needs neither — it uses built-in
Invoke-RestMethod and ConvertFrom-Json.
| OS | Install jq |
|---|---|
| macOS | brew install jq (often already present) |
| Debian / Ubuntu / Raspberry Pi OS | sudo apt install jq |
| Fedora / RHEL | sudo dnf install jq |
| Arch | sudo pacman -S jq |
| Windows (winget) | winget install jqlang.jq |
| Windows (scoop) | scoop install jq |
| Windows (choco) | choco install jq |
Claude Code reads its user-level settings from the same path on every
platform — your home directory. Below, ~ and $HOME mean:
| OS | $HOME resolves to |
|---|---|
| macOS | /Users/<you> |
| Linux | /home/<you> |
| Windows (Git Bash / WSL) | /c/Users/<you> or your WSL $HOME |
| Windows (PowerShell) | $env:USERPROFILE (e.g. C:\Users\<you>) |
Settings file: ~/.claude/settings.json. Hooks directory:
~/.claude/hooks/. Both are created on first use if they don't exist.
The repo ships with installers that copy the hook script and merge the
seven hook entries into settings.json for you. They're safe to re-run
(idempotent) and preserve any existing hooks you have.
./client/install.sh
# or, with a non-default Pi URL:
./client/install.sh --url http://192.168.1.42:8080Requires jq to merge JSON safely. If it's missing, the installer
prints the block to paste in manually. See the prerequisites table for
how to install jq on your OS.
.\client\install.ps1
# or, with a non-default Pi URL:
.\client\install.ps1 -Url http://192.168.1.42:8080If PowerShell refuses to run the script, allow it for the current session with:
Set-ExecutionPolicy -Scope Process -ExecutionPolicy BypassBoth installers:
- copy
hook-pi.sh/hook-pi.ps1to~/.claude/hooks/ - back up your existing
settings.json(assettings.json.bak.<timestamp>) - add seven hook entries (
Notification,Stop,UserPromptSubmit,SessionEnd,SessionStart,PreToolUse,PostToolUse), leaving anything else in the file untouched - scrub any entries from older versions (
notify-pi.sh,idle-pi.sh) - skip an entry if your command is already wired in, so re-running is safe
After the installer finishes, restart Claude Code (or open /hooks)
to pick up the new hooks. Skip ahead to Customization.
If you'd rather wire things up by hand, follow the two steps below.
macOS / Linux / WSL / Git Bash:
mkdir -p ~/.claude/hooks
cp client/hook-pi.sh ~/.claude/hooks/
chmod +x ~/.claude/hooks/hook-pi.shQuick test:
echo '{"session_id":"abc","cwd":"/path/to/some-project"}' \
| ~/.claude/hooks/hook-pi.sh notify
sleep 1
curl http://claude-notify.local:8080/state
# {"sessions":{"abc":{"state":"dancing","label":"some-project", ...}}, ...}Windows (native PowerShell):
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.claude\hooks" | Out-Null
Copy-Item client\hook-pi.ps1 "$env:USERPROFILE\.claude\hooks\"Quick test:
'{"session_id":"abc","cwd":"C:\\Users\\you\\some-project"}' `
| & "$env:USERPROFILE\.claude\hooks\hook-pi.ps1" notify
Start-Sleep 1
Invoke-RestMethod http://claude-notify.local:8080/stateIf your Pi isn't at claude-notify.local, set CLAUDE_NOTIFY_URL to its
address. The scripts pick it up from the environment. (The quick
installer handles this for you via --url / -Url.)
| Shell | Persistent override |
|---|---|
| bash / zsh (macOS, Linux, Git Bash, WSL) | echo 'export CLAUDE_NOTIFY_URL=http://192.168.1.42:8080' >> ~/.bashrc (or ~/.zshrc) |
| fish | set -Ux CLAUDE_NOTIFY_URL http://192.168.1.42:8080 |
| PowerShell | [Environment]::SetEnvironmentVariable('CLAUDE_NOTIFY_URL','http://192.168.1.42:8080','User') |
You can also just edit the URL in the script files directly.
Edit ~/.claude/settings.json (or %USERPROFILE%\.claude\settings.json
on Windows) and add a hooks block. Merge with any existing keys.
Seven hook events all point at the same hook-pi script with a
subcommand — strongly consider just using the installer; the manual
JSON below is verbose.
macOS / Linux / WSL / Git Bash — Claude Code's default shell on these
platforms is bash, so $HOME expands correctly:
{
"hooks": {
"Notification": [{ "hooks": [{ "type": "command", "command": "$HOME/.claude/hooks/hook-pi.sh notify" }] }],
"Stop": [{ "hooks": [{ "type": "command", "command": "$HOME/.claude/hooks/hook-pi.sh notify" }] }],
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "$HOME/.claude/hooks/hook-pi.sh idle" }] }],
"SessionEnd": [{ "matcher": "clear|resume|logout|prompt_input_exit|bypass_permissions_disabled|other", "hooks": [{ "type": "command", "command": "$HOME/.claude/hooks/hook-pi.sh end" }] }],
"SessionStart": [{ "hooks": [{ "type": "command", "command": "$HOME/.claude/hooks/hook-pi.sh heartbeat" }] }],
"PreToolUse": [{ "hooks": [{ "type": "command", "command": "$HOME/.claude/hooks/hook-pi.sh heartbeat" }] }],
"PostToolUse": [{ "hooks": [{ "type": "command", "command": "$HOME/.claude/hooks/hook-pi.sh heartbeat" }] }]
}
}Native Windows (PowerShell variant) — add "shell": "powershell" to
each hook entry and point at the .ps1 file:
{
"hooks": {
"Notification": [{ "hooks": [{ "type": "command", "shell": "powershell", "command": "& \"$env:USERPROFILE\\.claude\\hooks\\hook-pi.ps1\" notify" }] }],
"Stop": [{ "hooks": [{ "type": "command", "shell": "powershell", "command": "& \"$env:USERPROFILE\\.claude\\hooks\\hook-pi.ps1\" notify" }] }],
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "shell": "powershell", "command": "& \"$env:USERPROFILE\\.claude\\hooks\\hook-pi.ps1\" idle" }] }],
"SessionEnd": [{ "matcher": "clear|resume|logout|prompt_input_exit|bypass_permissions_disabled|other", "hooks": [{ "type": "command", "shell": "powershell", "command": "& \"$env:USERPROFILE\\.claude\\hooks\\hook-pi.ps1\" end" }] }],
"SessionStart": [{ "hooks": [{ "type": "command", "shell": "powershell", "command": "& \"$env:USERPROFILE\\.claude\\hooks\\hook-pi.ps1\" heartbeat" }] }],
"PreToolUse": [{ "hooks": [{ "type": "command", "shell": "powershell", "command": "& \"$env:USERPROFILE\\.claude\\hooks\\hook-pi.ps1\" heartbeat" }] }],
"PostToolUse": [{ "hooks": [{ "type": "command", "shell": "powershell", "command": "& \"$env:USERPROFILE\\.claude\\hooks\\hook-pi.ps1\" heartbeat" }] }]
}
}Restart Claude Code (or open the /hooks menu once to force a reload —
Claude Code only watches files that existed when the session started).
That's it. Open any project, ask Claude to do something that needs permission, and the Pi should light up.
- Different host / port: pass
--url/-Urlto the installer, setCLAUDE_NOTIFY_URLin your shell env, or edit the default URL inclient/hook-pi.sh/client/hook-pi.ps1. - Different session label: the script forwards
cwdbasename by default. Edit thejqexpression inclient/hook-pi.shto send something else — e.g..session_idor a fixed string. In the PowerShell version, change theSplit-Path $obj.cwd -Leafline. - Whimsy word list: add, remove, or replace the words in the awk
string in
client/hook-pi.sh($wordsarray inhook-pi.ps1). The Pi shows whichever word the most recentPreToolUse/PostToolUsepicked. - Eviction timeout: if a Claude session goes silent (hard kill, crash),
the mascot disappears after 10 minutes. Tune
IDLE_EVICT_SECONDSinpi/server.py. The activity word clears afterACTIVITY_TIMEOUTseconds (default 10) of no heartbeat. - Number of visible mascots: the frontend shows the top 4 sessions
(dancing first, then idle). Tune
MAX_VISIBLEinpi/static/app.jsand adjust the.grid[data-count="..."]rules inpi/static/style.css. - Mascot / colours / animations: all in
pi/static/style.cssandpi/static/index.html. Push the changed file withscpand kill Chromium — the kiosk loop reloads it within a few seconds. - Sound: Chromium can play
<audio>. Drop a chime inpi/static/, reference it inindex.html, and play it on state change inapp.js.
Pi screen is white / blank after boot. Usually a Chromium profile that crashed previously. Wipe it:
rm -rf ~/.config/claude-notify-chromium
~/claude-notify/start-kiosk.sh # or just rebootScreen orientation is wrong.
Edit /boot/firmware/config.txt and change dtoverlay=tft35a:rotate=N to
0, 90, 180, or 270. Reboot.
Hooks aren't firing.
Claude Code only watches settings files that existed when the session
started. Either restart Claude Code or open /hooks to force a reload.
Test the hook scripts directly first:
echo '{"session_id":"test","cwd":"/tmp/test"}' \
| ~/.claude/hooks/hook-pi.sh notify
sleep 1
curl http://claude-notify.local:8080/statecurl sometimes times out resolving claude-notify.local.
mDNS resolvers can get cranky. The Pi is still reachable — try the raw
IP, then flush the cache:
- macOS:
sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder - Linux:
sudo systemctl restart avahi-daemon(if installed) - Windows:
ipconfig /flushdns
If .local resolution doesn't work on Windows at all, install
Bonjour Print Services or just
hard-code the Pi's IP via CLAUDE_NOTIFY_URL.
Animation is jerky.
Chromium is software-rendering on the Pi (most small SPI LCDs can't
hardware-accelerate). The supplied CSS already drops the expensive bits
(filter-based pulses, per-element wiggles). If you add more, prefer
transform-only keyframes and will-change: transform on the animated
element.
Pi seems to lock up.
Check ~/claude-notify/logs/kiosk.log after reboot. The hardened
start-kiosk.sh logs every Chromium launch and cool-down. Repeated
"fast failure" entries point at an environment problem (no X, bad GPU
options, etc.) rather than a code bug.
Logs in general.
Everything lands in ~/claude-notify/logs/:
kiosk.log— what the autostart script is doing.server.log— Flask stdout/stderr.chromium.log— Chromium stderr (capped at 256 KB).
.
├── pi/
│ ├── server.py # Flask: /notify, /idle, /heartbeat, /end, /events
│ ├── start-kiosk.sh # Autostart entry point (hardened)
│ ├── claude-notify-kiosk.desktop # LXDE autostart file
│ └── static/
│ ├── index.html # Grid container + card <template>
│ ├── style.css # Grid layout, bob/bored/dance keyframes
│ └── app.js # SSE client, per-session card management
├── client/ # Hook scripts for the Claude-Code host
│ ├── hook-pi.sh / hook-pi.ps1 # Universal hook: notify|idle|heartbeat|end
│ ├── install.sh # macOS / Linux / WSL / Git Bash installer
│ └── install.ps1 # Native Windows / PowerShell installer
└── docs/
├── idle.png
├── dancing.png
└── dancing.gif
MIT — see LICENSE.

