A physical light on your desk that tells you when Claude Code needs you. It goes amber when Claude is waiting for permission, blue when it wants input, green when a turn finishes, and red on error — then goes dark when there's nothing pending or you press the button.
Claude Code lifecycle event
→ hook in ~/.claude/settings.json
→ scripts/claude-light.sh (reads the hook JSON on stdin)
→ curl http://claude-light.local/notify?state=...
→ ESP32 sets the state and drives the LED until it's cleared
The board answers instantly and never blocks; all blinking is timed in the main loop. If the light is unplugged or offline, your Claude Code sessions run at full speed and no hook errors appear — the curl is capped at 2 seconds and always exits cleanly.
Built and tested for the ESP32-S3-WROOM-1 (DevKitC-1 and common clones).
| Function | Pin | Notes |
|---|---|---|
| Onboard RGB | GPIO 38 | WS2812 / NeoPixel, RGB byte order. Default build. Some S3 boards use GPIO 48 and/or GRB order — see troubleshooting. |
| Boot button | GPIO 0 | The BOOT button, active-LOW. Press it to clear the light. |
| Mono LED (alt) | GPIO 2 | Only if you build with USE_NEOPIXEL commented out and wire your own LED. |
Nothing else is assumed — no display, no buzzer. The onboard RGB and the BOOT button are all this needs; both are already on the dev board.
Why not GPIO 2? The "blue LED on GPIO 2" is an original ESP32-WROOM-32 thing. A stock S3-WROOM-1 board has no user LED on GPIO 2 — its onboard light is a single addressable RGB. On this board it's on GPIO 38 in RGB order; other S3 boards use GPIO 48 and/or GRB. Both are one-line
#define/NEO_RGBvsNEO_GRBchanges at the top of the sketch.
firmware/claude-light/claude-light.ino Firmware
firmware/claude-light/secrets.h.example Copy to secrets.h, add WiFi creds
scripts/claude-light.sh Hook glue (stdin → curl)
hooks/settings.snippet.json Hook config to merge into settings.json
README.md This file
.gitignore Ignores secrets.h
- Arduino IDE 2.x (or
arduino-cli). - ESP32 board support by Espressif. In the IDE: Settings → Additional
Boards Manager URLs →
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json, then Boards Manager → install esp32. - Adafruit NeoPixel library (Library Manager → search "Adafruit NeoPixel"). Required for the default build. (Not needed if you build the mono-LED variant.)
cd firmware/claude-light
cp secrets.h.example secrets.h
# edit secrets.h and set WIFI_SSID / WIFI_PASSsecrets.h is gitignored. The S3 is 2.4 GHz only — a 5 GHz-only SSID will
never connect.
The firmware pins a static IP (USE_STATIC_IP, default 192.168.1.147) so
the light is always reachable at the same address without a router DHCP
reservation — handy when mDNS is unreliable. Edit the STATIC_IP /
STATIC_GATEWAY / STATIC_SUBNET #defines to match your network, keeping the
address out of the router's DHCP hand-out range if you can. Comment out
USE_STATIC_IP to fall back to DHCP.
- Board: ESP32S3 Dev Module (under esp32).
- USB CDC On Boot: Enabled — this routes
Serialto the USB port so you can read the printed IP address over the same cable you flash with. - Leave the rest at defaults. Select the port, then Upload.
Open the Serial Monitor at 115200 baud. On boot you'll see something like:
claude-light booting...
Connecting to WiFi SSID: my-network
....
WiFi connected. IP: 192.168.1.42
mDNS started: http://claude-light.local/
The board also does a quick double green blink the moment WiFi connects — that's your "it's alive" signal. Note the IP as a fallback in case mDNS is flaky on your network.
Comment out #define USE_NEOPIXEL near the top of the .ino, wire an LED (with
a resistor) from GPIO 2 to GND, and reflash. Adjust LED_PIN /
LED_ACTIVE_HIGH if needed.
With the board on your network:
curl "http://claude-light.local/notify?state=permission" # → amber, pulsing
curl "http://claude-light.local/notify?state=waiting" # → blue, pulsing
curl "http://claude-light.local/notify?state=done" # → green, solid
curl "http://claude-light.local/notify?state=error" # → red, pulsing
curl "http://claude-light.local/off" # → off
curl "http://claude-light.local/status" # → JSON status/status returns:
{"state":"permission","uptime_ms":83122,"rssi":-54,"ms_since_ping":1203,"wifi":"connected","ip":"192.168.1.42"}If claude-light.local doesn't resolve, use the IP from the serial log
(http://192.168.1.42/...). Pressing the BOOT button while any state is
active clears the light back to idle.
| State | Meaning | NeoPixel | Mono LED |
|---|---|---|---|
idle |
nothing pending | off | off |
permission |
waiting for your approval | amber, pulsing | fast blink (200ms) |
waiting |
waiting for your input | blue, pulsing | slow blink (1s) |
done |
turn finished (calm all-clear) | green, solid | solid |
error |
something failed | red, pulsing | double-blink |
A state with no new ping for 15 minutes auto-clears to idle, so the light
never gets stuck on overnight. (STATE_TIMEOUT_MS in the firmware.)
Hooks live in ~/.claude/settings.json (global, fires for every session) or
.claude/settings.json (per-project). Merge the hooks block from
hooks/settings.snippet.json into yours.
$CLAUDE_PROJECT_DIRis set automatically for project settings. For a global~/.claude/settings.json, replace it with the absolute path to this repo, e.g./Users/you/blinkclaude/scripts/claude-light.sh.- Make sure the script is executable:
chmod +x scripts/claude-light.sh.
What the snippet wires up:
| Event + matcher | State | Meaning |
|---|---|---|
Notification / permission |
permission |
Claude needs your approval |
Notification / idle prompt |
waiting |
Claude is waiting for your input |
UserPromptSubmit |
idle |
you responded → clear the light |
PreToolUse |
idle |
Claude is working → clear it |
Stop (every turn end) |
done |
turn finished |
The state model in plain terms: pulsing (amber/blue) = Claude is blocked waiting on you; off = Claude is actively working; solid green = finished.
The UserPromptSubmit and PreToolUse hooks are what make the "needs you"
light clear itself the moment you re-engage or Claude resumes work. Without
them, an permission/waiting light lingers until the next Stop, so it can
keep glowing while Claude is already busy again after you approved something.
Because those events fire often (especially PreToolUse, once per tool call),
the hook script sends its curl fire-and-forget (detached) — the hook
returns in ~1 ms and a powered-off board never stalls your session.
Stop fires on every turn end, so done (calm solid green) shows up
constantly — that's intentional; it's the "all clear." It's deliberately not
attention-grabbing.
Running several Claude Code tabs at once? They share the one light without
stomping on each other. Each tab is identified by the session_id in its hook
payload (the script forwards it as &id=), and the board tracks tabs
independently, showing the highest-priority state across all of them:
any tab needs approval → amber
else any error → red
else any tab waiting → blue
else any tab finished → green
else → off
So if tab A is quietly working while tab B hits a permission prompt, the light
goes amber for B — it won't get cleared by A's activity. A tab that goes idle
(actively working) releases its slot, and stale tabs auto-expire after
STATE_TIMEOUT_MS. /status reports the active tab count as sessions. Up to
MAX_SESSIONS (default 8) tabs are tracked at once. Pressing the button or
hitting /off clears every tab.
The script reads $CLAUDE_LIGHT_HOST, defaulting to claude-light.local. If
mDNS is unreliable on your network, pin it to the IP:
export CLAUDE_LIGHT_HOST=192.168.1.42(Set it in your shell profile — but read the troubleshooting note below first.)
Claude Code can call a URL directly with an http-type hook, skipping the
script entirely. Point each matcher's hook at the board with the state baked
into the query string:
{
"hooks": {
"Stop": [
{ "hooks": [ { "type": "http", "url": "http://claude-light.local/notify?state=done", "timeout": 3 } ] }
]
}
}The firmware returns an empty JSON object ({}) so nothing leaks into
Claude's context, and it also accepts the state in a JSON body
({"state":"permission"}) if you'd rather POST it that way.
Tradeoff: the http hook needs the board to be reachable and to answer with
valid (or empty) JSON. The command-script path is more forgiving — a
powered-off or offline board is a silent no-op, whereas a failing http hook is
more likely to surface as an error. Use the script unless you specifically want
zero dependencies.
claude-light.local won't resolve. mDNS is flaky on some networks/VLANs.
Use the IP printed to serial on boot and set CLAUDE_LIGHT_HOST to it.
The light stays dark on an S3 board. Your board may wire the onboard RGB to
a different pin. This build defaults to GPIO 38; if it's dark, change
#define NEOPIXEL_PIN 38 to 48 (the other common S3 pin) and reflash.
The light lights up but colors are wrong (e.g. amber shows as green). The
LED's byte order differs. This build uses NEO_RGB; if colors are swapped,
change it to NEO_GRB in the Adafruit_NeoPixel strip(...) line and reflash.
No serial output / can't see the IP. On the S3, Serial only reaches USB
when USB CDC On Boot is enabled in the board settings. Enable it and reflash,
or read the IP from your router's client list.
Hooks silently do nothing / Claude's output looks corrupted. Hooks run in
non-interactive shells that still source your rc files. An unconditional
echo/print in ~/.zshrc or ~/.bashrc writes into the hook's output
stream and corrupts it. Guard shell-profile output with an interactive check:
# only print for interactive shells
[[ $- == *i* ]] && echo "welcome back"This script itself writes nothing to stdout for exactly this reason — don't add
echos to it.
Does the light slow down my session? No. The curl is -m 2 (2s ceiling) and
every code path exits 0, so an unreachable or powered-off board is a no-op. You
can confirm with claude --debug while the ESP32 is unplugged: sessions run at
normal speed and no hook errors appear.
Light won't clear. Press the BOOT button (GPIO 0), or curl .../off. It also
auto-clears after 15 minutes with no new ping.
curl ".../notify?state=permission"→ amber pulse (mono: fast blink). ✅curl .../off→ light off. ✅- Press BOOT during any state → off, and
/statusreadsidle. ✅ - Drop WiFi / power-cycle the router → board reconnects on its own within a minute, no reflash. ✅
- ESP32 powered off → sessions run normally, no hook errors in
claude --debug. ✅ - End-to-end: trigger a permission prompt → amber; approve and finish the turn → green. ✅
No OTA updates, web config portal, MQTT/Home Assistant, multi-device fanout, or battery/sleep mode — by design. If you find you need one of those, that's a conversation to have before adding it, not a silent extension.
MIT © 2026 Antismart. Wire it into your desk, remix it, ship it — just keep the copyright notice.