A duckyPad turned into a live status + control surface for a raft of Claude Code agents β running in cmux or Orca.
A group of ducks resting on the water is called a raft. This is your control raft: one physical key per workspace, its LED showing what that agent is doing right now, and a press to jump straight to it β plus a row of Claude Code action macros.
The top row is per-workspace keys whose LEDs show live agent status (amber = working, blue = needs you, green = done, red = failed) and whose presses jump to that workspace. The lower rows are Claude Code + host action macros (new workspace, next workspace, jump/unread, plan mode, interrupt, yes/no, /clear, /compact, rewind).
Two hosts, one board. cmux and Orca are both supported, and the hook picks the right one from the firing session's own environment β no flag, no config. Which host owns the LEDs at any moment is decided by the profile loaded on the pad, so having both installed is fine. Each host gets its own generated profile: profile_cmux/ and profile_orca/.
Inspired by OpenAI's Codex Micro β OpenAI's first physical product, a Work Louder Γ OpenAI Supply macropad whose dedicated keys light up to show your coding agents' status. Raft reproduces the useful 80% β glanceable per-agent status + command keys β on an original (2020) duckyPad you may already own. (That's a Claude keycap on the Yes key in the photo.)
Raft is built for Claude Code β the status LEDs are driven by Claude Code lifecycle hooks and the action keys are Claude Code + host commands. But the firmware, LED protocol, and layout are agent-agnostic: point the hooks at another agent's events and swap the macro keys, and the same board works for Codex or anything else that can run a hook on start/stop/idle.
The obvious way to build this β have Claude Code hooks send the duckyPad's documented "Set RGB Single" HID command (0x04) to color each key β does not work on stock firmware. On the original duckyPad, that command is defined in the firmware header and documented in the HID protocol, but never actually implemented: the dispatcher has no branch for it, so it returns UNKNOWN_CMD (status 6) and nothing lights up. No stock OG-duckyPad firmware (v1.3.0 or the latest v3.0.4) can set a key's color from the host.
Raft fixes that with a tiny custom firmware patch that wires up the reserved command (plus keypress-persistence and a sleep-safe host-override layer), cross-compiled on macOS with a hand-rolled GNU toolchain setup. The minimal version of the patch is also being submitted upstream.
So the interesting half of this project is firmware; the rest is a small, no-daemon glue layer.
Two independent channels, exactly like the Codex Micro's frosted status keys:
βββββββββββββββββββββββββββββββ inbound (status) βββββββββββββββββββββββββββββββ
Claude Code ββ hook ββ> duckypad_hook.py βββ¬β cmux: cmux identify ββ> sudo duckypad_led.py <ws> <state>
(SessionStart/ (detect host from β (firing session β ordinal) β
Stop/Notificationβ¦) the session env) ββ orca: $ORCA_WORKTREE_ID HID 0x04 β key LED
+ orca worktree ps ββ> sudo duckypad_led.py --board 1:working 2:needs β¦
(repaints all five keys)
ββββββββββββββββββββββββββββββ outbound (control) ββββββββββββββββββββββββββββββ
duckyPad key ββ USB HID keystroke ββ> cmux (β1β5 jump) / Orca (β1β5 jump) / Claude Code (Esc, /clear, β¦)
- Outbound is just a normal USB-HID keyboard profile β no special software. The top row sends
β1ββ5on both hosts: cmux reads that as workspace N, Orca as the Nth worktree row in its sidebar. Other keys send Claude Code / host shortcuts. - Inbound, cmux: the hook resolves which workspace the firing session belongs to via
cmux identify(not the focused one β a background agent finishing still lights its own key) and sets that one key. - Inbound, Orca:
worktree psis the source of truth, and the hook is only a trigger. On each event Raft reads every worktree's agent state and repaints all five keys in one batched write, so keys track agents Raft never saw start β including Codex or Gemini panes, which never fire a Claude hook. The firing session's own state comes from the hook payload rather thanps, because Orca installs a sibling hook on the same events and its own state write may not have landed yet. - Either way, opening the HID interface needs root on macOS, so the LED writer runs behind a locked-down NOPASSWD-sudo helper. Each call opens/closes the device (no daemon, no persistent handle), and no-ops silently when the pad is unplugged.
Held in landscape with the USB cable at top-left (this is the default profile in
profile/profile_cmux/; edit profile/duckypad_build_profile.py to change it). The Orca profile differs in three keys β see below.
ββββββββββ¬βββββββββ¬βββββββββ¬βββββββββ¬βββββββββ
β WS1 β WS2 β WS3 β WS4 β WS5 β β workspace status LEDs + jump (β1β5)
ββββββββββΌβββββββββΌβββββββββΌβββββββββΌβββββββββ€
β Unread β New WS β Next WSβ Rewind β Intrpt β β navigation / actions
ββββββββββΌβββββββββΌβββββββββΌβββββββββΌβββββββββ€
β Yes β No β /clear βcompact β Auto/Plβ β Claude Code actions
ββββββββββ΄βββββββββ΄βββββββββ΄βββββββββ΄βββββββββ
| Key | Sends | Does | LED |
|---|---|---|---|
| WS1βWS5 (top row) | β1ββ5 |
Jump to cmux workspace 1β5 | live agent status β amber = working, blue = needs you, green = done (fades to off after 2 min), red = failed |
| Unread | ββ§U |
Jump to most-recent unread workspace | static |
| New WS | βN |
New cmux workspace | static |
| Next WS | ββ] |
Next workspace | static |
| Rewind | Esc Esc |
Open the rewind / checkpoint picker | static |
| Intrpt | Esc |
Interrupt Claude | static |
| Yes | types yes β |
Confirm a prompt | static |
| No | types no β |
Decline a prompt | static |
| /clear | /clear β |
Clear the conversation | static |
| /compact | /compact β |
Compact the conversation | static |
| Auto/Pl | β§Tab |
Cycle Claude Code permission mode (normal β auto-accept β plan) | static |
The top row is the heart of it: each key both shows a workspace's agent status (its LED) and jumps to it (its keystroke). The lower rows only depend on which workspace you just focused β jump with the top row, then act.
Physical-key mapping: in this landscape orientation the profile files map by physical key number β top row =
key3/6/9/12/15, middle =key2/5/8/11/14, bottom =key1/4/7/10/13β and LED index = key-file number β 1. Seedocs/calibration.md.
Identical to the cmux profile except for two middle-row keys, because the top row is now positional on both hosts:
| Key | Sends | Does | LED |
|---|---|---|---|
| 1β5 (top row) | β1ββ5 |
Switch to the Nth worktree row in Orca's sidebar | live agent status, same colors, plus dim purple = idle |
| Jump (was Unread) | βJ |
Open the switch palette and leave it open β Orca has no jump-to-unread | static |
| Next WT (was Next WS) | ββ§β |
Next worktree | static |
Set Orca's worktree sidebar sort to Manual. Its default (smart) re-sorts by agent state and attention, so rows shuffle while you work and the pad's key 3 stops meaning what it meant a minute ago. Manual sort keys off the order you dragged and ignores agent state entirely. This is the one Orca setting Raft needs.
The keys are labelled 1β5 rather than by project, deliberately: which worktree a row holds is decided in Orca's sidebar, so a name baked into the pad would go stale the moment you rearrange. Rearranging now costs nothing β no regeneration, no sudo, no re-push.
The LEDs still need to know which worktree each row holds, and that lives in ~/.config/raft/board.json:
{ "host": "orca",
"slots": [ { "label": "1", "worktree": "<worktreeId>" },
{ "label": "2", "worktree": "<worktreeId>" } ] }Write it with scripts/raft_board_init.py by reading your sidebar top to bottom β --rows raft radar billing-api β rather than hand-writing composite ids.
Why positional, when Orca has no per-worktree shortcut? Orca offers only
β1ββ9(Nth row),ββ§β/β(neighbour),ββ₯β(history back) andβJ(palette) β nothing that names a worktree. Raft originally typed a uniqueness-checked query intoβJ, because Orca's row order looked unknowable. With Manual sort it's stable, and "go to row 3" is true by construction: it can't mistarget, can't be ambiguous, and can't create a worktree. See Known limitations for what it costs.
- An original duckyPad (2020) β STM32F072, 15 keys, per-key RGB. (Not the Pro; that's a different MCU/flow.)
- macOS (the sender/hooks assume it; the firmware is cross-platform).
- cmux or Orca with Claude Code sessions. (Both installed is fine β see Known limitations.)
- Toolchain:
brew install --cask gcc-arm-embedded,brew install dfu-util, and a Python 3.10+ withpip install hidapi.
Four stages β each directory has its own README with exact commands.
-
Firmware β
firmware/README.mdClonedekuNukem/duckyPad(base commit46dffb33), copy in the GCC build files, applyfirmware/raft-firmware.patch,make, and DFU-flash. This gives you host-controllable per-key LEDs. -
Sender + hooks β
sender/README.mdsudo bash sender/duckypad_install.shinstalls the root-owned LED writer + a tightly-scoped/etc/sudoers.d/duckypad. Then mergehooks/settings.snippet.jsoninto~/.claude/settings.json. -
Profile β
profile/README.mdGenerate the 15-key profile from source (no GUI clicking) β duckyScript is compiled to.dsbbyte-for-byte with the Configurator's own compiler β and push it over HID.--host cmux(default) or--host orca; you can push both and switch with the pad's +/- buttons. -
Calibrate & test β
docs/calibration.mdConfirm the LEDβkey map for your orientation, then run parallel Claude sessions and watch the raft light up.
Orca has no stable positional addressing, so you tell Raft which five worktrees the top row owns. This is the only host-specific setup.
# 1. set Orca's worktree sidebar sort to Manual, then drag the five you care
# about into the top five rows. See what Orca currently has:
python3 scripts/raft_board_init.py --list
# 2. tell Raft which worktree each row holds, reading your sidebar top to bottom
python3 scripts/raft_board_init.py --rows raft radar billing-api
# 3. generate + push the Orca profile (labels and macros come from the board)
RAFT_CONFIG_SRC=/path/to/duckyPad-Configurator/src \
python3 profile/duckypad_build_profile.py --host orca
sudo python3 profile/duckypad_push_profile.py --profile orca
# 4. select the 'orca' profile on the pad, then fill the board in immediately
python3 scripts/duckypad_repaint.pyraft_board_init.py refuses a selector matching more than one worktree β a wrong LED is a lie, even though a wrong jump is no longer possible. Re-run scripts/raft_board_verify.py after deleting a worktree or rearranging; it exits non-zero and names the row. Steps 3 and 4 are one-time β the pad's top row is five fixed keystrokes, so rearranging your board afterwards means re-running step 2 only: no regeneration, no sudo, no re-push.
firmware/ raft-firmware.patch + GNU build (Makefile.gcc, linker script, GCC startup)
sender/ duckypad_led.py (LED writer), duckypad_hook.py (hostβLED bridge), duckypad_install.sh
profile/ build/compile/push scripts + the generated profile_cmux/ and profile_orca/
hooks/ settings.snippet.json (the Claude Code hook block to merge)
scripts/ raft_board_*.py (Orca board), duckypad_repaint.py, probe / status / led-test diagnostics
tests/ stdlib unittest suite β python3 -m unittest discover -s tests
docs/ calibration, teardown, superpowers/ (design + implementation plan)
- OLED stays portrait. The firmware has no 90Β° OLED rotation, so held in landscape the OLED text reads sideways. The keys and LEDs are laid out for landscape; the OLED is a secondary channel (blank keycaps + LED color are the real interface).
- Uses flash beyond the datasheet spec. The GCC/newlib image (~76 KB) exceeds the STM32F072C8's 64 KB spec. This die physically exposes 128 KB (the DFU descriptor reports it) and it's readback-verified on the test unit β but it's technically out-of-spec, so verify the readback on your own device. Fully recoverable: DFU is always available.
- Interrupt leaves it amber (cmux). Claude Code fires no hook when you interrupt a turn, so an interrupted session stays "working" until your next prompt (then self-corrects). Accepted, not worked around. On Orca this self-heals on the next event from any session, because
worktree psreportsinterruptedand every event repaints the whole board β an interrupted worktree shows dim purple. - One key, several panes: the loudest state wins. An Orca worktree can hold several agent panes, but it has only one LED, so Raft reduces them by precedence β
waitingβblockedβworkingβdoneβinterruptedβidle. Notedoneoutranksinterrupted: interrupt one pane while a sibling pane has just finished, and the key stays green until that green goes stale, rather than turning purple. Single-pane worktrees, which is the normal case, are unaffected. - Green auto-fades to off. A finished (green) LED switches itself off 2 min after the turn ends (
DUCKYPAD_FADE_SECto tune), so stale green doesn't linger. Red (failed) does not fade; it stays lit until the next state. cmux does this with a detached, token-cancelled fader β any newer state supersedes the pending fade. Orca needs no such bookkeeping: staleness is a pure function ofpsoutput plus the clock, so the hook just schedules an idempotent repaint for when the oldest green expires. - cmux only: requires
reorderOnNotificationoff. On cmux both channels address workspaces by sidebar position β the LED writer viaindex+1and the β1β5 jump via cmux's "select workspace N". cmux'sapp.reorderOnNotification(default on) bubbles a workspace to the top when it needs attention, which renumbers positions and desyncs the physical board. Set"app": { "reorderOnNotification": false }in~/.config/cmux/cmux.jsonandcmux reload-config. Raft's status LEDs replace what bubbling gave you. (Positions still shift if you close a middle workspace or drag rows β inherent to positional addressing.) Orca needs none of this, because its board is name-addressed. - Orca needs its sidebar sort set to Manual. The top row addresses worktrees by position (
β1ββ5= Nth worktree row), and Orca's defaultsmartsort re-ranks rows by agent state and attention β so a key silently changes meaning while you work.manualkeys off the order you dragged and ignores agent state. This is the only Orca setting Raft depends on. - Only rows 1β5 have keys, and only rows 1β9 are reachable at all. Orca's positional binding stops at
β9, so a worktree further down the sidebar cannot be jumped to by any keystroke. TheJumpkey (βJ) stays as the escape hatch. Collapsing a group also removes its worktrees from the row count, shifting everything below it. - Rearranging Orca desynchronises the LEDs until you re-run
raft_board_init.py. The keys stay correct by construction ββ3is always row 3 β butboard.jsonstill says which worktree row 3 was, so its colour reports the wrong agent. It's a one-command fix andraft_board_verify.pycatches the dead-row case, but nothing detects a silent swap between two live worktrees. - Leave Orca's
terminalShortcutPolicyon its defaultorca-first. It is what lets β-shortcuts reach Orca from inside a focused terminal pane. Set to terminal-first,β1ββ5andβJgo to the pane instead and the top row stops working from exactly where you press it. Expect a cosmetic "Terminal shortcut handled by Orca" toast; it dismisses itself. Note this applies only to β keys β the bare-Esckeys (Intrpt, Rewind) always go to whatever has focus, so they reach Claude only when its pane is focused. - The pad's loaded profile decides who owns the board. With both hosts installed, the LED writer reads the pad's current profile over HID and refuses writes from the other host, so a cmux session cannot repaint your Orca board. Profiles Raft did not push are treated as unowned and allowed, so a hand-made profile never locks you out.
- Orca ships its own Claude Code hook on the same lifecycle events, as a sibling entry in the same
~/.claude/settings.json. Raft's entry survived the 1.4.184 β 1.4.185 upgrade here, but after any Orca upgrade run the one-liner indocs/calibration.mdto confirm it is still there. - Sleep. Set the pad's sleep to a long value or Never β waking reloads the profile; the firmware's host-override layer re-asserts status colors on same-profile wake, but Never avoids the whole question.
- duckyPad by dekuNukem β the hardware and firmware this builds on (MIT).
- cmux and Orca β the workspace/worktree managers Raft reads status from.
- Concept inspiration: OpenAI's Codex Micro (Work Louder Γ OpenAI Supply) β OpenAI's first physical product.
MIT β see LICENSE. The firmware patch applies on top of dekuNukem/duckyPad (also MIT).
