Your Sphero R2-D2 reacts to this session: thinking, edits, errors, finished turns and permission prompts.

Makes a Sphero R2-D2 react to what happens on your computer. A small daemon keeps the droid connected over Bluetooth Low Energy and exposes POST /react/:mood on 127.0.0.1:4242. Triggers (CLI wrapper, git hook, Claude Code hooks) just call it.
Built on spherov2.js, an unofficial reverse-engineered Sphero V2 API on top of @abandonware/noble.
Requirements: Node 20 or newer (tested on Node 26), a C++ toolchain for the native BLE module, and the R2-D2 disconnected from the phone app (BLE allows one connection).
npm install
npm 11 blocks install scripts by default. If npm install prints an install-scripts warning, approve the two that need to build and rebuild:
npm install-scripts approve @abandonware/noble esbuild && npm rebuild
xcode-select --installNSBluetoothAlwaysUsageDescription. Terminal and Warp do not (tested on macOS 27), so npm start from them dies with exit 134. Use the menu bar app instead (next section).npm start and npm run explore work from there after you allow the prompt.sudo apt install bluetooth bluez libbluetooth-dev libudev-dev build-essentialsudo setcap cap_net_raw+eip $(eval readlink -f $(which node))@abandonware/noble.npm run app
Builds dist/R2 Bridge.app from the SwiftUI package in macos/R2Menu (Swift 5.9+, macOS 13+) and opens it. It sits in the status bar, starts the node daemon on launch and stops it on quit, and shows whether R2 is connected. The menu has the moods, dome and light controls, the demo page, the daemon log, start/stop/restart, and a Launch at Login toggle. Allow the Bluetooth prompt the first time. The bundle reads Contents/Resources/bridge.json for the project directory and node path, so rebuild after moving the project or changing Node.
The menu bar app listens for "R2" or "Artu" and takes the rest of the sentence as a command. R2 answers the wake word at once: front LED blue, dome turns to you with a "hey" chirp. Turn it on with Listen for "R2" / "Artu" in the menu; allow the microphone and speech recognition prompts once. ⌃⌥R is push-to-talk: the next sentence is a command with no wake word.
Recognition runs on-device in English and Spanish at the same time, so either wake word works with either language ("R2, poné música" is fine). The language is picked from the transcripts: accents, ñ and common words settle most sentences without the model; the rest go to the model with both readings and it chooses the coherent one. Replies come back in the language you spoke.
Commands are routed by a local model (qwen3:4b) through Ollama. The app runs Ollama itself: brew install ollama once, and the app starts the server headless, downloads the model on first run (progress in the menu) and stops the server on quit. The server only runs while listening is on: turning the toggle off stops it, turning it on starts it and warms the model. While R2 is away the model is unloaded from memory and the server kept, so the return is quick. The model streams its answer with the mood first, so R2 reacts about a quarter second after you stop talking, before the rest of the decision arrives. Banter is answered in beeps from R2's own sound families (chatty, laugh, positive, negative, excited, sad); the voice is used only when there is something to say. While you are still talking, trigger words ("genial", "no", "jaja", "thanks"…) flicker a LED and chirp at once. The last three exchanges are kept so follow-ups make sense. Intents:
Your own phrases go in ~/Library/Application Support/R2 Bridge/phrases.json (menu → Edit Phrases…; created with an example on first run). They are matched before the model, so they are instant and exact. Each entry has match (any of these, accent-insensitive substrings) and any of reply, mood, spotify (a spotify:track: / album: / playlist: URI), open (URL or app name) and animation (raw id). The example plays Highway to Hell for "R2, daddy is home" or "Artu, llegó papá".
The Claude tier runs claude -p headless with macos/claude-voice-settings.json, which only allows read-only tools, osascript and open, and loads the companion mod so R2 reacts while Claude works. Replies are spoken with a system voice (Spanish uses the Mexican voice when no Argentine one is installed). Without Ollama a few built-in patterns keep the basics working.
npm start
Scans for a device advertising D2-…, connects, sets the idle stance and waits. A keep-alive command is sent every 60 s so the droid does not fall asleep; if the link drops, the daemon rescans and reconnects on its own.
Being away. When you walk out of range the daemon scans every 5 s, backing off to every 30 s after ten minutes, and exits after three minutes without R2 so the menu bar app restarts it with a fresh Bluetooth stack. Reactions sent meanwhile are dropped. Coming back after more than a minute gets a welcome reaction. While connected the daemon reads signal strength every 5 s; three readings under -88 dBm trigger a goodbye wave before the link dies (tune with R2_GOODBYE_RSSI). The menu shows "away since" and the current dBm, and voice listening pauses while R2 is away. Sphero toys keep advertising in soft sleep, so reconnection works without touching R2; after many hours of deep sleep, tap its head.
curl -X POST http://127.0.0.1:4242/react/happy
curl -X POST http://127.0.0.1:4242/react/alarm
curl -X POST http://127.0.0.1:4242/react/attention
curl -X POST http://127.0.0.1:4242/react/thinking
curl http://127.0.0.1:4242/health
Moods: happy, excited, laugh, chatty, no, alarm, attention, thinking, sad, busy, listening, welcome, goodbye, idle. POST /blip/<family> plays one random clip from a sound family (chatty, positive, negative, laugh, excited, sad, hey). Reactions run one at a time in the order received. The daemon also watches CPU load and plays busy when the 1-minute load average per core goes above 1.5, then happy when it drops.
R2_FAKE=1 npm start runs without Bluetooth: a fake droid logs each command instead (noble is never loaded). Add R2_FAKE_DISCONNECT_MS=<ms> to simulate one dropped link and exercise the reconnect path.
With the daemon running, open <http://127.0.0.1:4242/>. It lists every mood, every named animation and audio ID with a note field (saved to explore-log.tsv), free ID inputs, and live controls for the dome, stance, LEDs, holo projector, logic displays, volume, wake and sleep. The page talks to POST /cmd ({"method": "turnDome", "args": [90]}, whitelisted methods only), GET /catalog and GET/POST /notes|/note.
The IDs are undocumented. src/animations.ts ships names taken from the Python spherov2 port, unverified on your droid. Check them:
npm run explore -- animation 0 55
npm run explore -- audio 1600 1800
Each ID plays, then you are asked for a note; notes go to explore-log.tsv. npm run explore needs a Bluetooth-capable terminal (see macOS above). From any other terminal, map IDs through the running daemon instead:
curl -X POST http://127.0.0.1:4242/play/animation/40
curl -X POST http://127.0.0.1:4242/play/audio/1950
Fix the names in src/animations.ts as you go. Audio IDs are 16-bit, so the bridge sends them through playR2D2Sound(hi, lo) rather than the single-byte playAudioFile.
Link the CLI once so r2 is on your PATH:
npm link
r2 run <command>: runs the command and reacts happy on exit 0, alarm otherwise. Example: r2 run npm test.r2 react <mood>: fire a mood by hand.r2 git-hook install: adds a post-commit hook to the current repo that plays happy after every commit. r2 git-hook uninstall removes it.r2 claude-hook: prints a minimal Claude Code hooks block (Stop → happy, permission_prompt / idle_prompt → attention); copy in examples/claude-settings.json. The companion mod below does more; use one or the other, not both.mod/r2-companion is a Claude Code plugin (function hooks) that gives R2 a running commentary of a session:
R2 · thinkinghappy; without edits → a "hey" chirp and dome to centersadattention/r2 <mood> fires any mood the daemon knows (GET /health lists them)Install it once for every session (the repo is its own marketplace, read from this folder, so edits apply after /reload-plugins):
claude plugin marketplace add ~/workspaces/loucimj/r2d2-bridge
claude plugin install r2-companion@r2d2-bridge --scope user
From GitHub instead: /plugin install r2-companion --marketplace loucimj/r2d2-bridge. The bridge URL is a plugin option (bridgeUrl, default http://127.0.0.1:4242). Check it with claude plugin validate mod/r2-companion and claude plugin test mod/r2-companion. A daemon that is down is ignored; nothing in the session slows down or breaks.
r2 run and the git hook fail silently when the daemon is not running, so they never break your tools; r2 react tells you. The port is the PORT constant in src/index.ts; if you change it, point the triggers at it with R2_BRIDGE_URL.
npm start dies right after listening on … with exit code 134 (SIGABRT): macOS killed node because the app you launched it from has no Bluetooth usage description. This happens inside editors and AI tools that embed a shell (the crash report says NSBluetoothAlwaysUsageDescription). Run it from Terminal or iTerm instead.Scanning devices... and nothing else: Bluetooth permission (see macOS above), or R2 is asleep. Tap its head or put it on the charger for a second.No R2-D2 found: still connected to the phone app, or out of range.Command Timedout: the link dropped mid-command. The daemon reconnects by itself.npm install: make sure the toolchain above is installed, or try Node 20 LTS. @abandonware/noble 1.9.2-26 builds cleanly on Node 20 through 26 on Apple Silicon.hooks/register.ts 142 lines1// R2 companion: forwards what happens in this session to the r2d2-bridge daemon.
2//
3// turn starts → dome turns, logic displays on, status "thinking"
4// a tool fails → short negative chirp, red back LED
5// turn ends with edits → happy mood; without edits → a "hey" chirp, dome back to center
6// turn aborted / errored → dome to center / sad mood
7// permission or idle prompt → attention mood
8// /r2 <mood> → fire a mood by hand
9//
10// Every call is fire-and-forget on the daemon side and swallowed here: a daemon that is
11// down must never slow or break the session.
12import type { EngineInterface, Register } from 'claude-code'
13
14type Mood = string // validated by the daemon, which owns the list (GET /health lists it)
15
16const MOODS = ['happy', 'excited', 'laugh', 'chatty', 'no', 'alarm', 'attention', 'thinking', 'sad', 'busy', 'listening', 'welcome', 'goodbye', 'idle']
17const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
18const SOUND_NEGATIVE = 3101 // R2_NEGATIVE_1, see src/animations.ts in r2d2-bridge
19const SOUND_HEY = 2813 // R2_HEY_1
20const NEEDS_YOU = new Set(['permission_prompt', 'idle_prompt'])
21
22// The validator requires every function that receives `$` to be declared at the top of the file.
23let base = 'http://127.0.0.1:4242'
24let edited = false
25
26function show($: EngineInterface, text: string) {
27 return $.ui.status(`R2 · ${text}`)
28}
29
30async function post($: EngineInterface, path: string, body?: unknown): Promise<boolean> {
31 return (await send($, path, body)).ok
32}
33
34/** The daemon's answer, or a readable failure when it is unreachable. */
35async function send($: EngineInterface, path: string, body?: unknown): Promise<{ ok: boolean; error?: string }> {
36 try {
37 const res = await $.http.fetch(base + path, {
38 method: 'POST',
39 headers: body ? { 'content-type': 'application/json' } : undefined,
40 body: body ? JSON.stringify(body) : undefined,
41 })
42 if (res.ok) return { ok: true }
43 let error = `bridge answered ${res.status}`
44 try {
45 error = (JSON.parse(res.text) as { error?: string }).error ?? error
46 } catch {}
47 return { ok: false, error }
48 } catch {
49 return { ok: false, error: 'R2 bridge is not reachable. Start it with `npm run app` in r2d2-bridge.' }
50 }
51}
52
53function react($: EngineInterface, mood: Mood) {
54 return post($, `/react/${mood}`)
55}
56
57function cmd($: EngineInterface, method: string, ...args: number[]) {
58 return post($, '/cmd', { method, args })
59}
60
61function chirp($: EngineInterface, id: number) {
62 return post($, `/play/audio/${id}`)
63}
64
65async function connected($: EngineInterface): Promise<boolean> {
66 try {
67 const res = await $.http.fetch(`${base}/health`)
68 return res.ok && JSON.parse(res.text).connected === true
69 } catch {
70 return false
71 }
72}
73
74export const register: Register = (on, options) => {
75 base = String(options.bridgeUrl ?? base).replace(/\/$/, '')
76
77 on('session.start', async ($, e, next) => {
78 await $.command.register({
79 name: 'r2',
80 description: `Make R2-D2 react: /r2 ${MOODS.join('|')}`,
81 })
82 show($, (await connected($)) ? 'connected' : 'offline')
83 return next(e)
84 })
85
86 on('command.run', { command: 'r2' }, async ($, e) => {
87 const mood = e.args.trim()
88 if (!/^[a-z]+$/.test(mood)) return { text: `usage: /r2 ${MOODS.join('|')}` }
89 const sent = await send($, `/react/${mood}`)
90 return { text: sent.ok ? `R2: ${mood}` : `R2: ${sent.error}` }
91 })
92
93 on('turn.start', async ($, e, next) => {
94 edited = false
95 show($, 'thinking')
96 await cmd($, 'setR2D2LogicDisplaysIntensity', 255)
97 await cmd($, 'turnDome', 60)
98 return next(e)
99 })
100
101 on('tool.call', async ($, e, next) => {
102 const ran = await next(e)
103 if (e.agentId) return ran
104 if (ran.deny === undefined && ran.isError === true) {
105 show($, `failed: ${e.tool}`)
106 await cmd($, 'setR2D2BackLEDcolor', 255, 0, 0)
107 await chirp($, SOUND_NEGATIVE)
108 } else if (EDIT_TOOLS.has(e.tool)) {
109 edited = true
110 }
111 return ran
112 })
113
114 on('turn.complete', async ($, e, next) => {
115 const result = await next(e)
116 if (e.agentId) return result
117 if (e.reason === 'answer' && edited) {
118 show($, 'happy')
119 await react($, 'happy')
120 } else if (e.reason === 'answer') {
121 show($, 'done')
122 await cmd($, 'turnDome', 0)
123 await chirp($, SOUND_HEY)
124 } else if (e.reason === 'aborted') {
125 show($, 'interrupted')
126 await cmd($, 'turnDome', 0)
127 } else {
128 show($, e.reason)
129 await react($, 'sad')
130 }
131 return result
132 })
133
134 on('classic.Notification', async ($, e, next) => {
135 if (NEEDS_YOU.has(e.notification_type)) {
136 show($, 'needs you')
137 await react($, 'attention')
138 }
139 return next(e)
140 })
141}
142