# The Pulse Protocol An agent-agnostic contract for driving the `pulse-svc` service aggregator (and everything downstream of it: the pulse bar widget, the presence orb, tooltips, `claude.pulse` subscribers). The service knows nothing about Claude Code — it consumes **events** and an optional **telemetry payload** over noctalia's plugin IPC. Any coding agent that can run a shell command on its lifecycle hooks (gemini-cli, codex, opencode, aider, a CI job, a cron script) can light up the same bar dot. Two adapters ship in `hooks/`: | Adapter | For | Telemetry | |---|---|---| | `pulse.py` | Claude Code (reads hook JSON on stdin, parses the session transcript) | live token burn, O(delta) | | `pulse-emit` | anything else (plain POSIX sh, args only) | whatever you pass, or none | ## Transport ``` noctalia msg plugin all [payload] ``` - `` is the plugin dispatch id: `:` — `lowcache/claude-companion:pulse-svc` (the headless aggregator service) for this install. Adapters must treat it as configurable (`pulse-emit` reads `$PULSE_TARGET`). - `all` addresses every monitor's widget instance. (`focused` or a bare connector errors when the widget sits on multiple bars.) - `[payload]` is a **single positional token** — noctalia's msg CLI splits on whitespace, so the payload must be space-free. That's why it's a CSV, not JSON. - Fire-and-forget. The dispatch returns `ok: dispatched N` or an error string; adapters ignore both (see the fail-open contract below). ## Event vocabulary Eight events. Priority decides which session the bar shows when several are active; "resting" matters for the default-slot rule below. | Event | Meaning | Priority | Resting | |---|---|---|---| | `needs_attention` | agent is blocked on the human (permission prompt, question) | 6 | no | | `error` | hard failure | 5 | yes | | `tool_start` | executing a tool / command | 4 | no | | `turn_start` | thinking — a turn has begun | 3 | no | | `text` | streaming a response | 3 | no | | `turn_end` | turn finished — output ready for the human | 2 | yes | | `idle` | session alive, nothing happening | 1 | yes | | `session_end` | session is over — **retires** its slot | — | — | Unknown events render as idle-with-the-event-kept-as-state-word; stick to the vocabulary. Glyph, accent color, and breath animation are widget-side concerns (see `VISUAL` in `pulse.luau`, and the `breath_speed`, `pulse_glow_floor`, and `orb_swell` user settings in `README.md`) — the protocol only fixes the *semantics*. ## Payload ``` model,in,out,cacheCreate,cacheRead,session ``` - `session` (field 6) is the only field that changes behavior: it keys the per-session slot, so every event from the same agent session must carry the same short id (Claude's adapter uses the first `-` segment of the session UUID; any stable `[A-Za-z0-9_-]+` token works). - `model` is a display string; use `?` when unknown. - Token fields are lifetime-cumulative for the session, not per-turn deltas. The widget displays *input* as `in + cacheCreate` (full-rate work) and shows `cacheRead` separately. All-zero telemetry is fine — the burn line is simply omitted (`model` of `?` or zero in+out hides it). - No commas or whitespace inside fields. **Minimum viable adapter:** fire bare events with just a session id — `?,0,0,0,0,`. State tracking, urgency priority, multi-session tooltip all work; you only lose the burn readout. ## Session semantics (what the service guarantees) - One slot per `session` id; re-sending updates the slot in place. - The service aggregates the **most urgent** state across all live slots (priority table above) into `claude.pulse`; widgets render this rollup and the tooltip lists every session, most recent first, with a Σ burn total. - `session_end` retires the slot. Nothing else does — a real session may sit at `idle` or `turn_end` indefinitely and stays listed. - Because only the trailing `session` field is read for routing, a `session_end` whose payload populates *only* that field is a well-formed retire for one session and nothing else: `,,,,,`. The `sessions` panel's Retire control emits exactly that, which is why manual retirement needs no new verb — anything that can send `session_end` can already clear a stuck slot. - **Payload-less events** (no CSV at all — e.g. a manual `noctalia msg plugin … all needs_attention` poke from a terminal) land in a single shared `default` slot. To keep CLI pokes from leaving a phantom session, any **resting** event (`idle`, `turn_end`, `error`) retires the `default` slot instead of updating it. Consequence for adapters: *always send a session id*; the default slot is a test surface, not a home. ## Adapter contract 1. **Fail-open, always.** Exit 0 no matter what — noctalia offline, binary missing, malformed input. An adapter runs inside an agent's hook path and must never block or error the agent. Swallow stdout/stderr, cap the dispatch with a timeout (~3 s). 2. **Tag everything with the session id** (see above). 3. **Send cumulative telemetry or none** — don't send per-turn deltas. 4. Don't invent events; map your agent's lifecycle onto the eight above. ### Lifecycle mapping guide The Claude Code mapping (from `hooks/settings.snippet.json`) doubles as the template for any agent: | Agent moment | Event | |---|---| | session starts / process launches | `idle` | | prompt submitted / turn begins | `turn_start` | | about to run a tool or shell command | `tool_start` | | tool finished, agent resumes thinking | `turn_start` | | response streaming to the user | `text` | | waiting on permission / a question for the human | `needs_attention` | | turn complete, output delivered | `turn_end` | | unrecoverable failure | `error` | | session exits (however it exits) | `session_end` | If your agent only exposes a subset (say, just "done" notifications), map what you have — a session that only ever sends `turn_end`/`session_end` still renders correctly. ### The generic emitter ``` hooks/pulse-emit [session] [model] [in] [out] [cacheCreate] [cacheRead] ``` POSIX sh, no dependencies beyond `noctalia` on PATH. Omitted fields default to `?`/`0`; omitting `session` sends a bare (default-slot) event. Env: `PULSE_TARGET` overrides the dispatch id, `PULSE_DRYRUN=1` prints the command instead of running it. Examples: ```sh pulse-emit turn_start mysess # state only pulse-emit turn_end mysess gpt-5 12000 800 # with burn figures pulse-emit session_end mysess # retire the slot long_build && pulse-emit needs_attention ci # non-agent uses work too ``` ## Downstream: the `claude.pulse` state mirror The headless `pulse-svc` **service** is the **single aggregator**; subscribers (the bar dot, the orb, or any future surface) never parse events themselves. On every event — never from a timer — it publishes a rollup snapshot to noctalia shared state under `claude.pulse` (top-level fields below, plus a `sessions` array of per-session `{sid,state,model,tin,tout,cr}` for multi-session tooltips): ```lua { state = , -- "idle" when no sessions count = , model = , -- single-session only tin = , -- one session's, or the Σ across all tout = , cr = 1> } ``` Both desktop and bar widgets receive it via `noctalia.state.watch("claude.pulse", cb)` — state.watch fires across all of a plugin's runtimes as of the Noctalia 5 beta (the earlier "bars must poll" limitation is gone). ## Deployment (retired invariant) The aggregator is the headless `pulse-svc` **`[[service]]`** — it starts at shell launch and runs with no surface, so event capture never depends on any widget being placed. (Historically the aggregator lived in the `pulse` bar widget: if that widget wasn't on a bar, every event was silently dropped and all subscribers froze — the **D10** fragility. Retired by the `[[service]]` entry kind added in the Noctalia 5 beta; requires `plugin_api >= 3` on a service-capable build.) The bar dot and orb are now pure subscribers of `claude.pulse`, so placing them is purely cosmetic.