Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Claude Code Attention Light

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.


Hardware

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_RGB vs NEO_GRB changes at the top of the sketch.


Repo layout

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

1. Flash the firmware

Prerequisites

  • Arduino IDE 2.x (or arduino-cli).
  • ESP32 board support by Espressif. In the IDE: Settings → Additional Boards Manager URLshttps://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.)

Add your WiFi credentials

cd firmware/claude-light
cp secrets.h.example secrets.h
# edit secrets.h and set WIFI_SSID / WIFI_PASS

secrets.h is gitignored. The S3 is 2.4 GHz only — a 5 GHz-only SSID will never connect.

Static IP (default)

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 settings (Arduino IDE)

  • Board: ESP32S3 Dev Module (under esp32).
  • USB CDC On Boot: Enabled — this routes Serial to 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.

Build the mono-LED version instead (optional)

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.


2. Verify the board

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.

States at a glance

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.)


3. Wire it into Claude Code

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_DIR is 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.

Multiple tabs

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.

Pointing at a different host

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.)


Alternative: script-free http hooks

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.


Troubleshooting

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.


Acceptance checklist

  1. curl ".../notify?state=permission" → amber pulse (mono: fast blink). ✅
  2. curl .../off → light off. ✅
  3. Press BOOT during any state → off, and /status reads idle. ✅
  4. Drop WiFi / power-cycle the router → board reconnects on its own within a minute, no reflash. ✅
  5. ESP32 powered off → sessions run normally, no hook errors in claude --debug. ✅
  6. End-to-end: trigger a permission prompt → amber; approve and finish the turn → green. ✅

Out of scope

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.


License

MIT © 2026 Antismart. Wire it into your desk, remix it, ship it — just keep the copyright notice.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages