Physical status light for Claude Code sessions. Maps session state to blink(1), WLED, Govee (LAN), Home Assistant, or any webhook-compatible device.

Physical status light for Claude Code sessions. Turn a USB light, smart bulb, or LED strip into a live indicator of what Claude is doing.
v1 Milestone: Lamp only. The Broadcast feature (streaming AG-UI to a web viewer) comes in a later release.

claude-onair is a Claude Code mod (plugin) plus a local helper daemon (onaird). It maps your session state to hardware:
The daemon aggregates state across all your Claude Code sessions and supports multiple light drivers simultaneously.
Recommended for v1:
| Device | Price | Control | Why |
|---|---|---|---|
| blink(1) mk3 | $29.95 | USB HID | No network, no pairing. Two RGB LEDs (main state + subagent/quota accent). Works on macOS/Win/Linux. |
| WLED (ESP32) | ~$20 | Local HTTP JSON API | DIY, bright, hackable. Store effects as presets and switch with one command. Perfect for desk "ON AIR" signs. |
| Govee (LAN) | varies | UDP (no cloud) | Direct LAN control for H6xxx series. Bright, affordable LED strips. Works offline. |
| Home Assistant (any light) | varies | REST API | Covers Hue, LIFX, Govee, Zigbee, Matter, and anything else HA integrates. |
Native drivers for Philips Hue and LIFX are planned for v1.1. Stream Deck (input + output) is also v1.1.
npm install -g pnpm)# Clone this repo
git clone https://github.com/djig/claude-onair.git
cd claude-onair
# Install dependencies and build
pnpm install
pnpm build
# Add as a marketplace (development install)
claude plugin marketplace add ./packages/mod
claude plugin install onair@onair-local --scope user
# After updating the plugin locally, sync the marketplace
claude plugin marketplace update onair-local
# Then reload in a Claude session with: /reload-plugins
Start the daemon:
# The daemon must be running for the status light to work
onaird up
Check status:
# In a Claude Code session
/onair status
# Or via the CLI
onaird status
macOS / Windows: Plug it in. Done.
Linux: Add a udev rule so the device is accessible without root:
# Create rule file
sudo tee /etc/udev/rules.d/51-blink1.rules > /dev/null <<EOF
SUBSYSTEM=="usb", ATTR{idVendor}=="27b8", ATTR{idProduct}=="01ed", MODE="0666"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="27b8", ATTRS{idProduct}=="01ed", MODE="0666"
EOF
# Reload rules
sudo udevadm control --reload-rules
sudo udevadm trigger
# Unplug and replug the blink(1)
Run onaird doctor to verify:
onaird doctor
claude-onair:onaird config set driver wled
onaird config set wled.ip 192.168.1.100
Store your color patterns as presets in WLED's web UI. The daemon sends {"ps": <presetNumber>} to switch states, so you get smooth device-side animations with zero flicker.
Govee H6xxx series LED strips support direct LAN control via UDP (no cloud required). Tested with H612F.
onaird config set driver govee
onaird config set govee.ip 192.168.1.50
onaird doctor
macOS Note: The first time you send UDP to a LAN device, macOS may show a "Local Network" permission prompt. Grant access in System Settings → Privacy & Security → Local Network and enable the checkbox for Terminal (or iTerm, etc.).
Compatible models: H6xxx series (H6104, H6159, H6163, H6173, H618x, H619x, H612x, etc.). Check Govee's spec sheet for "LAN Control" support.
light.office_lamp)onaird config set driver home-assistant
onaird config set ha.url http://homeassistant.local:8123
onaird config set ha.token YOUR_TOKEN_HERE
onaird config set ha.entity light.office_lamp
Config lives at ~/.config/claude-onair/config.json. Edit via CLI:
# View current config
onaird config list
# Set a driver
onaird config set driver blink1 # or wled, home-assistant, webhook
# Theme colors (HSB)
onaird config set theme.thinking.color "#0066ff"
onaird config set theme.thinking.pattern breathe
# Quiet hours (lamp stays dim)
onaird config set quiet-hours.enabled true
onaird config set quiet-hours.start "22:00"
onaird config set quiet-hours.end "08:00"
# How long "done" stays green before fading
onaird config set done-fade-minutes 3
The default "On Air" theme ships with these states:
thinking:
color: "#0066ff" # blue
pattern: breathe
speed: slow
tool:
color: "#00cccc" # cyan
pattern: solid
needs-you:
color: "#ff8800" # amber
pattern: blink
speed: fast
done:
color: "#00cc00" # green
pattern: solid
fade-after-minutes: 3
error:
color: "#ff0000" # red
pattern: solid
idle:
color: "#000000" # off
pattern: off
You can define custom themes in config.json under themes: { "my-theme": { ... } }, then activate with:
onaird config set active-theme my-theme
/onair # Status overview
Note: /onair test colors and onaird test are not yet implemented. Use a live Claude Code session to test hardware (see Testing with Real Hardware).
onaird status # Daemon status, active sessions, current state
onaird config list # Show full config
onaird config set <key> <value>
onaird doctor # Check setup (drivers, permissions)
onaird up # Start daemon (usually automatic)
Note: onaird test, onaird stop, and onaird logs are not yet implemented. To stop the daemon, use pkill onaird or send SIGTERM to the process.
127.0.0.1 and validates the Host header to block DNS rebinding.onaird generates a token at ~/.config/claude-onair/token (mode 0600). The mod reads it via Claude Code's $.fs.read.:47800) is never exposed. Broadcast (v2) will add a separate viewer port with read-only tokens.Claude Code session(s) onaird (Node daemon, per user)
┌─────────────────────────┐ POST /v1/ingest (batched) ┌─────────────────────────┐
│ claude-onair mod │ ───────────────────────────▶│ Event bus │
│ hooks: prompt.submit, │ │ ├─ StateReducer │
│ turn.*, tool.call, │ │ │ └─ Lamp drivers │
│ tool.check, session.* │ │ │ (blink1, WLED, │
│ │ │ │ HA/webhook) │
└─────────────────────────┘ └─────────────────────────┘
127.0.0.1:47800
The mod taps these Claude Code events and batches them to the daemon:
| Claude Code Event | Mapped State | Notes |
|---|---|---|
turn.start, turn.step in flight | thinking | Slow blue breathe |
tool.call before next resolves | tool | Cyan; counts subagents on LED 2 (blink(1)) |
tool.check → ask | needs-you | Amber fast blink |
tool.call where e.tool === 'AskUserQuestion' | needs-you | Same as above |
turn.complete (not aborted, no agentId) | done | Green for N minutes, then fade |
turn.complete with e.isAborted | idle | Grey flash (device-dependent) |
classic.StopFailure | error | Red solid |
session.measure → rateLimits[].percentUsed ≥ 80 | accent | Amber on LED 2 or dim (quota warning) |
The daemon reduces events from all sessions to a single aggregate state. Priority:
needs-you > error > tool ≈ thinking > done (fades) > idle > off
Debounce: Transitions shorter than ~400ms are coalesced to avoid flicker.
Each driver implements:
interface LampDriver {
async connect(): Promise<void>
async setState(state: AggregateState): Promise<void>
async disconnect(): Promise<void>
}
The daemon loads the configured driver(s) at startup. Multiple drivers can run simultaneously (e.g., blink(1) and WLED).
# Clone and install
git clone https://github.com/djig/claude-onair.git
cd claude-onair
pnpm install
# Build everything
pnpm build
# Run tests
pnpm test
# Typecheck
pnpm typecheck
# Watch mode for the daemon
pnpm dev:daemon
# Load the mod with hot reload
claude --plugin-dir ./packages/mod
cd packages/mod
claude plugin validate .
Expected output:
> types ./types/index.d.ts declares state: claude-onair.session, claude-onair.lastFlush
> ./hooks/register.ts hooks: session.start, turn.start, turn.step, tool.call, tool.check, turn.complete, classic.StopFailure, session.measure, ui.render{component=AbovePrompt}, command.run{command=onair}
> ./hooks/register.ts calls: $.fs.read, $.http.fetch, $.clock.every, $.process.run, $.ui.invalidate, $.command.register, $.state.get, $.state.set
√ Validation passed
cd packages/mod
claude plugin test .
Tests stub $.http.fetch and verify:
$.process.run/onair status, /onair test colors)We cannot run live, authenticated Claude Code sessions or control real USB/network devices in this environment. Manual testing steps:
Run onaird doctor to check driver connectivity and permissions before testing. Use the live-session step above to test your configured hardware; /onair test colors and onaird test are not implemented yet.
claude/onair/state) for Home Assistant, Node-RED, ESPHomeag-ui-chat-transport + AI Elements)$.audio.speak when a request waits >2 minturn.step/tool.check granularity, no in-TUI approval band)PermissionRequest hooks (no lamp or broadcast)How claude-onair differs:
turn.step, tool.check → ask, session.measure usage)claude --version (need 2.1.287+)claude plugin listclaude --debug and look for "claude-onair"/plugin in a session should list itonaird status (should show "running")onaird doctoronaird up to see its output; onaird logs is not implemented yet.blink(1) specific:
ls -l /dev/hidraw* should show mode 0666)WLED specific:
ping 192.168.1.100http://192.168.1.100curl http://192.168.1.100/json/stateGovee specific:
ping 192.168.1.50Home Assistant specific:
curl -H "Authorization: Bearer YOUR_TOKEN" http://homeassistant.local:8123/api/ps aux | grep onairdlsof -i :47800 (should be free or owned by onaird)onaird up --verbosels -la ~/.config/claude-onair/ (token file should be mode 0600)The daemon debounces state changes (400ms default). If flicker persists:
onaird config set debounce-ms 800
For devices with rate limits (Hue bridge ~10 cmds/s), the driver only sends commands on state changes, not every event.
Contributions welcome! Please open an issue first to discuss your idea.
packages/drivers/src/ (e.g., lifx.ts)LampDriver interfacepackages/drivers/src/index.tspackages/daemon/src/config.tspackages/daemon/src/lamp/manager.tspackages/drivers/src/__tests__/MIT © 2026 Jignesh Dhamecha
Status: v1 Lamp milestone. Broadcast feature (v2) coming soon.
Feedback: Open an issue or find me on X @djig
hooks/register.js 275 lines1/**
2 * claude-onair mod
3 *
4 * Hooks Claude Code session events and streams them to the local onaird daemon.
5 */
6const DAEMON_URL = 'http://127.0.0.1:47800';
7const FLUSH_INTERVAL_MS = 100;
8const MAX_BATCH_SIZE = 50;
9// Module state (survives hot reload via $.state)
10const sessionKey = { plugin: 'onair', key: 'session' };
11const flushKey = { plugin: 'onair', key: 'lastFlush' };
12let eventSeq = 0;
13export async function register(on, options) {
14 // Session start: initialize state, check daemon, register command
15 on('session.start', async ($, e, next) => {
16 const result = await next(e);
17 const sessionId = String(await $.session.id());
18 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '/tmp';
19 const tokenPath = `${home}/.config/claude-onair/token`;
20 await $.state.set(sessionKey, {
21 sessionId,
22 startedAt: Date.now(),
23 currentState: 'idle',
24 lastEventSeq: 0,
25 });
26 await $.state.set(flushKey, {
27 lastFlushAt: 0,
28 pendingEvents: [],
29 });
30 // Register /onair command
31 await $.command.register({
32 name: 'onair',
33 description: 'onair status and controls',
34 });
35 // Check if daemon is running; if not, log instruction
36 try {
37 const token = await readToken($, tokenPath);
38 await $.http.fetch(`${DAEMON_URL}/healthz`, {
39 headers: { 'Authorization': `Bearer ${token}` },
40 });
41 }
42 catch (err) {
43 // Daemon not running - log clear instruction for user
44 $.ui.log('[claude-onair] Daemon not running. Start it with: onaird up');
45 $.ui.log('[claude-onair] (The daemon must be running for the status light to work)');
46 }
47 await queueEvent($, {
48 t: 'session.start',
49 data: { surface: e.surface, isInteractive: e.isInteractive },
50 });
51 // Start periodic flush
52 $.clock.every(FLUSH_INTERVAL_MS, async () => {
53 await flushEvents($);
54 });
55 return result;
56 });
57 // Session end
58 on('session.end', async ($, e, next) => {
59 await queueEvent($, {
60 t: 'session.end',
61 data: { reason: e.reason },
62 });
63 await flushEvents($, true); // force flush
64 return next(e);
65 });
66 // Prompt submitted
67 on('prompt.submit', async ($, e, next) => {
68 await queueEvent($, {
69 t: 'prompt',
70 data: { length: e.text?.length || 0 },
71 });
72 return next(e);
73 });
74 // Turn start
75 on('turn.start', async ($, e, next) => {
76 await queueEvent($, {
77 t: 'turn.start',
78 data: { turnId: e.turnId },
79 }, e.agentId);
80 return next(e);
81 });
82 // Turn step (thinking/model request)
83 on('turn.step', async function* ($, e, next) {
84 await queueEvent($, {
85 t: 'step.start',
86 data: { turnId: e.turnId, index: e.index, model: e.model },
87 }, e.agentId);
88 // Forward pieces (we don't tap them individually in v1)
89 const it = next(e);
90 let result;
91 while (!(result = await it.next()).done) {
92 yield result.value;
93 }
94 await queueEvent($, {
95 t: 'step.end',
96 data: {
97 turnId: e.turnId,
98 index: e.index,
99 usage: result.value.usage,
100 stopReason: result.value.stopReason,
101 },
102 }, e.agentId);
103 return result.value;
104 });
105 // Tool call
106 on('tool.call', async ($, e, next) => {
107 await queueEvent($, {
108 t: 'tool.start',
109 data: { tool: e.tool },
110 }, e.agentId);
111 // Check if it's AskUserQuestion
112 if (e.tool === 'AskUserQuestion') {
113 await queueEvent($, {
114 t: 'needs.question',
115 data: { questions: e.questions?.length || 0 },
116 }, e.agentId);
117 }
118 const result = await next(e);
119 await queueEvent($, {
120 t: 'tool.end',
121 data: { tool: e.tool, denied: 'deny' in result },
122 }, e.agentId);
123 return result;
124 });
125 // Permission check
126 on('tool.check', async ($, e, next) => {
127 const decision = await next(e);
128 if (decision === 'ask') {
129 await queueEvent($, {
130 t: 'needs.permission',
131 data: { tool: e.tool },
132 });
133 }
134 return decision;
135 });
136 // Turn complete
137 on('turn.complete', async ($, e, next) => {
138 await queueEvent($, {
139 t: 'turn.end',
140 data: {
141 turnId: e.turnId,
142 isAborted: e.isAborted,
143 durationMs: e.durationMs,
144 },
145 }, e.agentId);
146 await flushEvents($); // flush at turn boundaries
147 return next(e);
148 });
149 // Error
150 on('classic.StopFailure', async ($, e, next) => {
151 await queueEvent($, {
152 t: 'error',
153 data: { reason: e.reason || 'unknown' },
154 });
155 return next(e);
156 });
157 // Usage
158 on('session.measure', async ($, e, next) => {
159 const result = await next(e);
160 const usage = await $.session.usage();
161 if (usage.rateLimits) {
162 const maxPercent = Math.max(...usage.rateLimits.map((rl) => rl.percentUsed || 0));
163 await queueEvent($, {
164 t: 'usage',
165 data: { percentUsed: maxPercent },
166 });
167 }
168 return result;
169 });
170 // Subagent spawn
171 on('agent.spawn', async ($, e, next) => {
172 await queueEvent($, {
173 t: 'subagent',
174 data: { delta: 1, isTeammate: e.isTeammate },
175 });
176 return next(e);
177 });
178 // Handle /onair command
179 on('command.run', { command: 'onair' }, async ($, e) => {
180 const subcommand = e.args?.[0] || 'status';
181 if (subcommand === 'status') {
182 const session = await $.state.get(sessionKey);
183 const text = session.value
184 ? `onair: ${session.value.currentState}`
185 : 'onair: not initialized';
186 return { text };
187 }
188 if (subcommand === 'test' && e.args?.[1] === 'colors') {
189 return { text: 'Test not implemented in mod yet. Use `onaird test` CLI.' };
190 }
191 return { text: 'Usage: /onair [status | test colors]' };
192 });
193}
194// $.fs.read may return a string or a result object; throw if no usable token
195// so callers fall through to their error path instead of sending "Bearer null".
196async function readToken($, tokenPath) {
197 const raw = await $.fs.read(tokenPath);
198 const text = typeof raw === 'string' ? raw : raw?.text ?? raw?.content ?? raw?.value;
199 if (typeof text !== 'string' || !text.trim()) {
200 throw new Error(`no token at ${tokenPath}`);
201 }
202 return text.trim();
203}
204async function queueEvent($, partial, agentId) {
205 const session = await $.state.get(sessionKey);
206 if (!session.value)
207 return;
208 const { value: flush } = await $.state.get(flushKey);
209 if (!flush)
210 return;
211 const event = {
212 v: 1,
213 session: session.value.sessionId,
214 seq: ++eventSeq,
215 ts: Date.now(),
216 ...(agentId && { agentId }),
217 ...partial,
218 };
219 flush.pendingEvents.push(event);
220 // Update session state hint
221 if (!agentId) {
222 const stateMap = {
223 'turn.start': 'thinking',
224 'step.start': 'thinking',
225 'tool.start': 'tool',
226 'needs.permission': 'needs-you',
227 'needs.question': 'needs-you',
228 'turn.end': partial.data.isAborted ? 'idle' : 'done',
229 'error': 'error',
230 };
231 if (stateMap[partial.t]) {
232 session.value.currentState = stateMap[partial.t];
233 await $.state.set(sessionKey, session.value);
234 }
235 }
236 await $.state.set(flushKey, flush);
237 // If batch is full, flush now
238 if (flush.pendingEvents.length >= MAX_BATCH_SIZE) {
239 await flushEvents($, true);
240 }
241}
242async function flushEvents($, force = false) {
243 const { value: flush } = await $.state.get(flushKey);
244 if (!flush || flush.pendingEvents.length === 0)
245 return;
246 const now = Date.now();
247 if (!force && now - flush.lastFlushAt < FLUSH_INTERVAL_MS) {
248 return; // too soon
249 }
250 const events = [...flush.pendingEvents];
251 flush.pendingEvents = [];
252 flush.lastFlushAt = now;
253 await $.state.set(flushKey, flush);
254 try {
255 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '/tmp';
256 const tokenPath = `${home}/.config/claude-onair/token`;
257 const token = await readToken($, tokenPath);
258 // Fire and forget (don't await so we don't exceed hook budget)
259 // In a real production version, we'd use $.http.fetch with a timeout
260 // For now, we rely on the fact that the daemon is local and fast
261 await $.http.fetch(`${DAEMON_URL}/v1/ingest`, {
262 method: 'POST',
263 headers: {
264 'Authorization': `Bearer ${token}`,
265 'Content-Type': 'application/json',
266 },
267 body: JSON.stringify(events),
268 });
269 }
270 catch (err) {
271 // Fail silently; mod should never crash the session
272 $.ui.log('[claude-onair] Failed to flush events: ' + String((err && err.stack || err.message) || err));
273 }
274}
275types/index.d.ts 33 lines1/**
2 * Type contract for claude-onair mod state.
3 * Declares values held in $.state for this plugin.
4 */
5
6export interface SessionState {
7 sessionId: string
8 startedAt: number
9 currentState: 'idle' | 'thinking' | 'tool' | 'needs-you' | 'done' | 'error'
10 lastEventSeq: number
11}
12
13export interface FlushState {
14 lastFlushAt: number
15 pendingEvents: Array<{
16 v: number
17 session: string
18 seq: number
19 ts: number
20 t: string
21 data: Record<string, unknown>
22 }>
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'onair': {
28 session: SessionState
29 lastFlush: FlushState
30 }
31 }
32}
33