iTranslated by AI
Visualizing Claude Code Status with a Custom Keyboard (TinyGo + RP2040 + Gopher)
Introduction
When running Claude Code in the background, it’s often hard to tell if it’s currently responding or finished. Notification banners are easily overlooked, and terminal tabs tend to get buried in the background.
To solve this, I used my DIY keyboard zero-kb02 (based on the Waveshare RP2040-Zero) and wrote firmware in TinyGo to create a "physical device to display the status of Claude Code."
Here is the final result:

| Claude Code Status | LED | OLED |
|---|---|---|
| Sending Prompt | 🔵 Spinning perimeter | Gopher animation for each letter |
| Question UI Displayed | 🔴 Red | Gopher C "Choose" |
| Response Completed | 🟢 Green | Gopher D "Done" |
I built this incrementally through trial and error, so this article covers the journey of creating it.
Environment
- Microcontroller: Waveshare RP2040-Zero
-
Keyboard:
zero-kb02distributed at tinygo-keeb/workshop- 12 WS2812 LEDs + SSD1306 OLED + 4x3 Key Matrix
- Language: TinyGo 0.40.1
- Mac: macOS (Apple Silicon)
Hardware Pin Configuration
Key Matrix Cols : GPIO5, 6, 7, 8 (Output)
Key Matrix Rows : GPIO9, 10, 11 (InputPulldown)
Keyboard WS2812 x12: GPIO1
Onboard WS2812 x1 : GPIO16
SSD1306 OLED : I2C0 (SDA=GPIO12, SCL=GPIO13, 0x3C)
Step 1: Triggering Notifications via Claude Code Hooks
I started by "making something happen the moment Claude finishes responding." Claude Code has a Stop event that allows running commands upon completion.
~/.claude/settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Response completed\" with title \"Claude Code\"'"
}
]
}
]
}
}
Now, a macOS notification banner appears when a response is finished. After changing settings, you can reload them by simply opening and closing the /hooks directory.
Finding this useful, I wanted to create even flashier notifications next.
Step 2: Lighting up the Onboard LED with TinyGo
The RP2040-Zero has an onboard WS2812 LED connected to GPIO16. I started by lighting that up.
package main
import (
"image/color"
"machine"
"time"
"tinygo.org/x/drivers/ws2812"
)
func main() {
pin := machine.GPIO16
pin.Configure(machine.PinConfig{Mode: machine.PinOutput})
ws := ws2812.New(pin)
leds := []color.RGBA{{}}
colors := []color.RGBA{
{R: 30}, {G: 30}, {B: 30},
}
for i := 0; ; i++ {
leds[0] = colors[i%len(colors)]
ws.WriteColors(leds)
time.Sleep(500 * time.Millisecond)
}
}
Build and write to the board:
tinygo build -target=waveshare-rp2040-zero -o /tmp/main.uf2 main.go
# Hold the BOOTSEL button while plugging in USB
cp /tmp/main.uf2 /Volumes/RPI-RP2/
It blinked in red, green, and blue every 0.5 seconds. It worked easily.
Step 3: Controlling Colors via USB Serial
Next, I wanted to change colors dynamically from my Mac, so I set it up to receive commands via USB CDC (Serial). It reads line-by-line from machine.Serial and parses strings like red, green, or blue.
var buf []byte
for {
if machine.Serial.Buffered() > 0 {
b, _ := machine.Serial.ReadByte()
if b == '\n' || b == '\r' {
handleCommand(string(buf))
buf = buf[:0]
} else {
buf = append(buf, b)
}
}
time.Sleep(5 * time.Millisecond)
}
Send from the Mac like this:
stty -f /dev/cu.usbmodem2101 115200 raw -echo
printf 'red\n' >| /dev/cu.usbmodem2101
Now I can change the LED color via serial commands from my Mac.
Step 4: Lighting the LED from the Stop Hook
In addition to the notification banner in Step 1, I call a script from the Stop hook to make the LED green.
~/.claude/hooks/keyboard-led.sh:
#!/bin/bash
PORT=$(ls /dev/cu.usbmodem* 2>/dev/null | head -1)
[ -z "$PORT" ] && exit 0
stty -f "$PORT" 115200 raw -echo 2>/dev/null
for line in "$@"; do
printf '%s\n' "$line" >| "$PORT" 2>/dev/null
done
Add this to settings.json:
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/keyboard-led.sh green"
}
]
}
]
Every time Claude completes a response, the onboard LED turns green 🟢
Step 5: Spinning Outer LED Animation with 12 LEDs
A single onboard LED feels a bit lonely, so I also used the 12 LEDs on the keyboard. When I first wrote to the same GPIO16, only the onboard one lit up.
Checking 02_blinky2 in the workshop repository, I found they were connected to GPIO1 (a separate pin from the onboard one). I modified the code to hold two ws2812.Device instances:
pinBoard := machine.GPIO16
pinBoard.Configure(machine.PinConfig{Mode: machine.PinOutput})
wsBoard := ws2812.New(pinBoard)
pinKeys := machine.GPIO1
pinKeys.Configure(machine.PinConfig{Mode: machine.PinOutput})
wsKeys := ws2812.New(pinKeys)
I wanted a spinning animation for the "Thinking" (prompt sending) state, so I defined the outer perimeter sequence for the 3x4 grid:
// 3x4 grid:
// 0 3 6 9
// 1 4 7 10
// 2 5 8 11
var perimeter = []int{0, 3, 6, 9, 10, 11, 8, 5, 2, 1}
func tickSpin() {
// Turn off all
for i := range keys {
keys[i] = color.RGBA{}
}
head := perimeter[spinIdx%len(perimeter)]
tail := perimeter[(spinIdx-1+len(perimeter))%len(perimeter)]
keys[head] = color.RGBA{B: 60}
keys[tail] = color.RGBA{B: 15} // Tail fade
wsKeys.WriteColors(keys)
spinIdx++
}
I call tickSpin() every 70ms in the main loop, using a non-blocking tick approach so it runs in parallel with serial reading.
Add the UserPromptSubmit hook to settings.json:
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "$HOME/.claude/hooks/keyboard-led.sh spin" }
]
}
]
Now, "prompt sending = blue spin" and "response complete = green" are achieved. It allows me to notice the status from the corner of my eye.
Step 6: Displaying Text on the SSD1306 OLED
Since colors alone don't tell the whole story, I also display status text on the OLED. The zero-kb02 has an SSD1306 128x64 OLED connected to I2C0.
machine.I2C0.Configure(machine.I2CConfig{
Frequency: 400000,
SDA: machine.GPIO12,
SCL: machine.GPIO13,
})
display := ssd1306.NewI2C(machine.I2C0)
display.Configure(ssd1306.Config{
Address: 0x3C,
Width: 128,
Height: 64,
})
I draw text using freesans.Regular12pt7b from tinygo.org/x/tinyfont:
import "tinygo.org/x/tinyfont/freesans"
func showText(s string) {
display.ClearBuffer()
tinyfont.WriteLine(display, &freesans.Regular12pt7b, 0, 22, s, white)
display.Display()
}
I added a command like text Thinking... to send it along with the hook:
$HOME/.claude/hooks/keyboard-led.sh spin "text Thinking..."
Step 7: Status Icons with Gopher Font
I then got the urge to display Gophers on the OLED, so I used the tinygo.org/x/tinyfont/gophers package. It is a special font where each letter from A to Z is assigned a Gopher illustration in a different pose.
import "tinygo.org/x/tinyfont/gophers"
func showGopher(letter, label string) {
display.ClearBuffer()
if label != "" {
tinyfont.WriteLine(display, &freesans.Regular9pt7b, 30, 14, label, white)
}
tinyfont.WriteLine(display, &gophers.Regular32pt, 50, 60, letter, white)
display.Display()
}
Assigning letters to states:
- Thinking → Gopher T
- Choose → Gopher C
- Waiting → Gopher W
- Done → Gopher D
It's truly a Gopher extravaganza seeing 26 types of Gophers. So cute ❤️
Step 8: Animation Cycling Through Gophers
I thought it would be fun if the Gopher changed while in the Thinking state, so I added a seq <word> command.
func tickSeq() {
letter := strings.ToUpper(string(seqWord[seqIdx%len(seqWord)]))
renderGopher(letter, seqWord) // Large Gopher + Label
seqIdx++
}
I call tickSeq() every 400ms in the main loop. Sending seq THINKING results in an animation where the pose changes as T → H → I → N → K → I → N → G.
$HOME/.claude/hooks/keyboard-led.sh spin "seq THINKING"
This is incredibly cute.
Step 9: Detecting When Options Appear
I also wanted to react when Claude Code brings up the question UI via AskUserQuestion. I first tried using the Notification event, but it turns out it doesn't trigger for AskUserQuestion.
So, I changed it to use the matcher in PreToolUse to trigger the hook right before a tool call:
"PreToolUse": [
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/keyboard-led.sh red 'gopher C Choose'"
}
]
}
]
Now, the moment the question UI appears, it switches to Red + Gopher C 🔴
Step 10: Adding HID Keyboard Functionality
Once I got this far, I wanted to operate the question UI with physical keys. TinyGo provides a USB HID keyboard via the machine/usb/hid/keyboard package. USB CDC and HID can coexist as a composite device.
import "machine/usb/hid/keyboard"
var kb = keyboard.New()
// Matrix index → HID keycode
var keymap = [12]keyboard.Keycode{
keyboard.KeyUpArrow, keyboard.KeyDownArrow, keyboard.KeyEsc, keyboard.KeyReturn,
0, 0, 0, 0,
0, 0, 0, 0,
}
func scanMatrix() {
for c, colPin := range colPins {
colPin.High()
time.Sleep(1 * time.Millisecond) // ★ Wait for pulldown stabilization
for r, rowPin := range rowPins {
pressed := rowPin.Get()
idx := r*4 + c
if pressed != keyState[idx] {
keyState[idx] = pressed
if k := keymap[idx]; k != 0 {
if pressed {
kb.Down(k)
} else {
kb.Up(k)
}
}
}
}
colPin.Low()
}
}
Now I can select and confirm Claude Code's question UI using the physical ↑↓ + Enter keys.
Completion
| Status | LED | OLED |
|---|---|---|
| Prompt Sending | 🔵 spin | seq THINKING (animation) |
| Question UI Displayed | 🔴 red | gopher C "Choose" |
| Notification (Auth/Idle) | 🔴 red | gopher W "Waiting" |
| Response Completed | 🟢 green + macOS Notif | gopher D "Done" |
Since the status of Claude Code is physically visible on the keyboard, I can notice it from the corner of my eye even while working in other windows. Being able to operate the question UI with physical keys also avoids reaching for my laptop keyboard, which is very comfortable.
Takeaways
Claude Code Hooks are surprisingly useful
Since you can associate arbitrary shell commands with events like Stop, UserPromptSubmit, PreToolUse, or Notification, integration with external hardware is surprisingly simple. A few lines in a configuration file are enough to make a physical device reflect Claude's status.
TinyGo + RP2040 is quite standard now
Almost everything you need is available, from ws2812 / ssd1306 / tinyfont to HID keyboard support. It's convenient to write microcontroller code while keeping the Go syntax.
The Gopher Font is a hidden gem
I feel like Regular32pt in tinygo.org/x/tinyfont/gophers is not well-known, but it's a completely playful font where each of the 26 characters is a different Gopher pose. If you have an OLED-equipped microcontroller, you should definitely use it.
About the TinyGo Keeb Tour
The zero-kb02 I used is a DIY keyboard featured in the TinyGo Keeb Tour. It is a study group where you can learn microcontroller programming and DIY keyboards using TinyGo, and it's easy for beginners to join.
The next event will be held at GopherCon SG in Singapore on May 20, 2026!
🔗 GopherCon SG 2026 - TinyGo Keeb Workshop
Opportunities to write firmware in Go while handling hardware are surprisingly rare, so please check it out if you are interested. It's the perfect material to experience the way of playing by "adding functions on the fly" like in this article.
Source Code
The repository is at:
References
- tinygo-keeb/workshop — Various samples for zero-kb02
- sago35/keyboards — Reference for more serious firmware implementations
- TinyGo Keeb Site
- Claude Code Hooks Documentation
Discussion