Post

ClauBlink — A Physical LED That Blinks When Claude Is Thinking

I wired up an ESP32 and an RP2040 to blink different colors depending on what Claude Code is doing. Because staring at a terminal cursor wasn't dramatic enough.

This post was written with Claude Code (Anthropic's claude-opus-4-6 model).

I roped my firmware co-pilot Claude into writing dual-board Arduino code, debugging WiFi parsers, and arguing about blink rates until the LED did exactly what we wanted.

I built a physical LED indicator that shows what Claude Code is doing in real-time. Fast blink means it’s streaming a response. Slow blink means it’s thinking. Solid green means it’s done. Works over USB serial and WiFi, supports two boards (ESP32-C3 and RP2040), and hooks into Claude Code’s event system with a 56-line shell script.

GitHub: github.com/dinhnguyen/ClauBlink


I run Claude Code sessions that sometimes take a while. At home, I don’t want to sit there watching the terminal. I want to step away, play with my kid, do something else. But checking my phone for notifications isn’t great either, because if I’m playing with my son, I’m playing with my son, not staring at a screen.

I wanted something ambient. A light in the corner of my eye that tells me “Claude is still working” or “Claude is done, come back.” Something I can notice without actively checking.

I remembered blink(1), the USB RGB LED that people used for build status and notification alerts. It’s been around since 2012 but has mostly faded out. Then I talked to a friend who does IoT work. He had a bunch of ESP32 and RP2040 boards lying around, lent me a couple, and said “just try it.” So I did.


Four states, each with a distinct visual:

State Blink Rate NeoPixel Color Meaning
RESPONSE Fast (200ms) Red Claude is streaming
WORKING Slow (800ms) Blue Claude is using tools
DONE Solid Green Claude finished
IDLE Off Nothing happening

The blink rate difference matters. The ESP32-C3 has a single-color on-board LED, so you can’t distinguish states by color. Fast vs slow blink is the only way to tell RESPONSE from WORKING without adding an RGB LED.

On the RP2040 with NeoPixels, you get both color and blink rate. Red flashing fast is unmistakably “Claude is talking.” Blue pulsing slowly is “Claude is chewing on something.”


Claude Code has a hooks system. You can run shell commands on events like PreToolUse, PostToolUse, and Stop. The notify.sh script maps these to LED states:

case "$EVENT" in
  PreToolUse)
    STATE="RESPONSE:${SLOT}"
    ;;
  PostToolUse)
    touch "$FLAG_FILE"
    STATE="WORKING:${SLOT}"
    ;;
  Stop)
    if [ -f "$FLAG_FILE" ]; then
      STATE="DONE:${SLOT}"
      rm -f "$FLAG_FILE"
    else
      STATE="RESPONSE:${SLOT}"
    fi
    ;;
esac

There’s a flag file trick here. When Claude uses tools, PostToolUse sets a flag. When Claude stops, the script checks: did Claude use any tools? If yes, show DONE (green). If no, it was a pure text response, so blink RESPONSE instead. This prevents the LED from going green on simple chat messages.

The script tries USB serial first, falls back to WiFi HTTP. So you can have the board plugged directly into your Mac, or sitting across the room on WiFi.


The first WiFi implementation looked fine. The HTTP server responded 200 OK to every request. But the LED never changed.

The bug was embarrassingly simple. The HTTP handler was passing commands like RESPONSE?slot=0 but the state parser expected RESPONSE:0 with a colon delimiter. The query string format sailed right past every if statement without matching anything.

// Before (broken)
server.on("/RESPONSE", []() { handleCommand("RESPONSE?slot=" + server.arg("slot")); });

// After (works)
server.on("/RESPONSE", []() { handleCommand("RESPONSE:" + server.arg("slot")); });

The server happily returned “OK” for every request. It just never actually changed the LED state. Classic “the API returns 200 but nothing happens” bug.


The firmware runs on both ESP32-C3 and RP2040 from the same main.cpp using preprocessor guards. The NeoPixel library is shared, the color mapping is shared, the state machine is shared. Board-specific stuff (WiFi, pin numbers, LED count) lives behind #ifdef.

The ESP32-C3 drives both its on-board single-color LED (GPIO 8) and an optional NeoPixel on GPIO 2. If you wire up a WS2812B, you get RGB colors. If you don’t, the on-board LED still works with blink-speed differentiation.

The RP2040 supports up to 5 NeoPixels on GPIO 16, one per Claude Code session slot. Multi-session support was a late addition but turned out to be useful when I’m running parallel Claude sessions.


The blink rates took a few tries. The first version had RESPONSE and WORKING both at 200ms, which made them identical on the single-color ESP32 LED. Slowing WORKING to 800ms made the difference obvious from across the room.

I’d also consider adding a buzzer for the DONE state. Sometimes I walk away while Claude is working, and a visual indicator doesn’t help if I’m not looking at it. A short beep on completion would be practical.


  • Firmware: Arduino/PlatformIO, Adafruit NeoPixel
  • Boards: ESP32-C3 Super Mini, Waveshare RP2040-Zero
  • Host script: Bash, talks serial or HTTP
  • Integration: Claude Code hooks (settings.json)