Skip to main content

Sourced from docs/notifier-status-language.md in the firmware repo.

Notifier status language

The Notifier ring speaks a status language: every AI-coding-session status maps to a distinct color family role + animation pattern, so a glance tells you what is happening and on which session - without reading the screen. This doc is the single cross-reference from a harness hook all the way to the LEDs, and it is checked against the code that actually drives them (edit the code, then this doc - do not let them drift).

The chain has four hops:

1. Harness hook → verb → State

Each harness adapter normalizes its hook events to a canonical verb; the broker maps that verb to a State (notify/broker/session.py _VERB_TO_STATE). Unknown verbs default to Running.

HarnessHook / eventverbState
ClaudeSessionStartstartIdle
ClaudeUserPromptSubmit / PreToolUserunningRunning
ClaudeNotification permission_promptnotify:permission_promptAwaitingApproval
ClaudeNotification elicitation_dialognotify:elicitation_dialogWaitingInput
ClaudeNotification idle_prompt (idle 60 s)notify:idle_promptWaitingInput
ClaudeStopdoneDone
ClaudeSessionEndendOffline
CodexSessionStartstartIdle
CodexUserPromptSubmit / PreToolUserunningRunning
CodexPermissionRequestapprovalAwaitingApproval
CodexStopdoneDone
CodexStop in plan modeplan_pendingWaitingInput
CodexSessionEndendOffline
Vibebefore_toolbefore_toolRunning
Vibeafter_tool success / failureafter_tool:success / :failureRunning / Error
Vibepost_agent_turnpost_agent_turnDone
VibeHITL timeout heuristichitl_inferredAwaitingApproval

Codex plan-mode note - Codex (≤ v0.124.0) has no dedicated "agent asked a plan question" hook: in plan mode a turn ends by presenting a plan and waiting for you, but that fires the same Stop hook as a finished turn - so the ring used to show Done (green) when it was really waiting on you ("zilch / green while it waits", owner-reported). The adapter (notify/harness/codex.py) reads permission_mode and re-tags a plan-mode Stop as plan_pending → WaitingInput. Only done is reinterpreted; running/approval/error keep their own meaning.

2. State → {theme role, pattern, brightness}

The device resolves each State to a theme-family role index + an animation pattern in one place: lib/core/src/status_style.cpp (statusStyle()), host-tested in test/test_status_style. The device no longer trusts the raw wire hue for presentation - it computes ring behavior from (State, active theme), so changing the theme actually recolors the ring.

Ambient grammar (owner 2026-07-16 - the ring is all-day peripheral signage): nothing that persists may strobe. Every needs-you state is the same smooth ~2.6 s breathe; hue alone carries the meaning (cool = answer, amber = approve, reserved alert red = error - red stays unmissable because nothing else is ever red). Fast motion is allowed only as sub-second one-shot transition cues, and even those swell or slide rather than flash. Anim::Blink maps to no status (a host test asserts the ban; the enum survives for wire compat).

StateRole (palette index)PatternBrightMeaning
Running0 - primaryComet (sweeping tail)100%model / tool working
WaitingInput1 - accentBreathe (~2.6 s)100%needs YOU (answer)
AwaitingApproval3 - detail (amber)Breathe (~2.6 s)100%decision / permission gate
Done2 - successFade (settle → 38% ember)85%turn completed
Error- alert hueBreathe (~2.6 s, "breathing red")100%tool / turn errored
Idle0 - primaryStatic dim20%session open, no active turn
Offline-Off0%session ended (segment freed)

One-shot transition cues (soft by rule): born = the arc grows fading in from dim in its own hue (350 ms); dying = collapse fading out (350 ms); Done = one green sweep (500 ms); web-action confirm = a soft pulse window (no Flash). In Dark/Calm the single-LED cue honors this table's hue (whole-ring dim breathe - an Error breathes red there too).

Role is an index into the active theme's color family; alert uses the theme's dedicated alert hue (below), so Error stays alarming yet in-family. History: Error was originally Solid, then a 300 ms hard Blink ("errors deserve attention", 2026-07-14) - live-use showed a 3.3 Hz red strobe held for minutes is an alarm, not signage; the ambient grammar supersedes both.

3. Theme families

Themes are multi-hue families, not single colors (lib/core/src/theme.cpp, themePalette(); served to the web UI at GET /api/themes). Role indices above pick a color within the active family, so the whole ring wears one coherent palette while each status stays distinguishable by role + pattern.

Themes: teal, ocean, ember, forest, openai, anthropic, mistral, rainbow, gemini, perplexity.

Alert hue per theme (themeAlertHue()) - Error reads as danger without a jarring pure-red clash on cool themes ("full red on ocean makes no sense"):

ThemeAlert hueThemeAlert hue
teal10 (coral)anthropic4
ocean14 (warm amber)mistral2
ember2 (red)rainbow0 (red)
forest6gemini6
openai4perplexity12 (amber)

4. Wire: nsn v2 carries harness + title

The nsn frame is v2: each segment carries the legacy {state, hue, anim, progress} plus an optional harness tag (claude/codex/vibe) and a short title (session task / cwd basename), appended as a backward-compatible TLV under the same magic - a v1 decoder ignores the trailing bytes (still CRC-covered). This is what lets the SessionDetail screen show "codex · deploy-plan · waiting" instead of a meaningless "job 3". Codec: lib/core/src/nsn_proto.cpp (byte-locked to notify/broker/frame.py in nimbus-notify via generated vectors). See hardware.md for the BLE transport.

The broker caps the v2 payload at the wire's 1-byte length field (≤255 bytes): with many long-titled sessions it budgets titles greedily and degrades a segment to harness-only rather than overflow (the device encoder guards the same limit). Known limitation - the broker sets a harness tag for every real session, so any frame with ≥1 active session is now v2 (LEN > 68); a device still on pre-v2 firmware (whose decoder used the old 71-byte packet buffer) rejects such frames. There is no protoVer negotiation yet - the fix for an old board is to flash v2 firmware; a broker-side protoVer gate (device advertises, broker suppresses v2) is a future item.

5. A single error ≠ a red ring

OWNER RULE (2026-07-13): a lone active session DOES fill all 45 LEDs - the full ring is the aesthetic; status is carried by color + pattern (red = errors only), never by arc length. (This inverts the earlier gap design - ring_animator layout count==1 → L/4 gap, which showed one errored session as a red arc rather than a whole red circle - because the gap read as broken LEDs.) Stale segments are reaped posture-scaled so a finished/errored job can't strand red over a healthy set (the ring-persistence rules described above).