| Home | Claude Code | HTTP API | Development |
End-to-end: what happens when a Claude Code hook fires, when a transcript file gets a new line, or when you toggle a tray menu item.
The three input sources
┌──────────────────┐ POST /api/event ┌────────────────┐
│ Claude Code │──────────────────────▶ │ axum (Rust) │
│ (hook forwards │ │ :9077 │
│ raw payload) │ └───────┬────────┘
└──────────────────┘ │ adapters::dispatch
│ → apply_set
│ / apply_clear
▼
┌──────────────────┐ notify::Event ┌────────────────┐
│ transcript │─────────────────────▶ │ AppState │ app.emit
│ <session>.jsonl │ │ Mutex<Vec< │───────────────▶ Svelte
└──────────────────┘ │ AgentSession│ "sessions_ (listen)
│ >> │ updated")
└────────┬───────┘
┌──────────────────┐ #[tauri::command] │
│ Svelte UI │────────────────────▶ commands.rs ──apply_clear──▶ AppState
└──────────────────┘ │
▼
Window / TrayIcon
native APIs
┌──────────────────┐ file change event
│ config.json │─────────────────▶ config_watcher reloads ─▶ emit("config_updated")
└──────────────────┘ │
▼
Svelte + tray refresh
Every mutation to session state funnels through state::apply_set or state::apply_clear so the sticky-label rules, working-time accumulator, and upgrade-only merge policy are enforced in one place regardless of origin.
Path 1 — Hook POSTs event
- Claude Code fires a lifecycle event (
UserPromptSubmit,Stop, etc.). The hook command spawnspython claude_hook.pyand pipes the event payload to stdin. claude_hook.pyreads the payload, extractshook_event_name, and POSTs{client: "claude", event: <name>, payload: <verbatim>}to$TAURI_DASHBOARD_URL/api/event(defaulthttp://127.0.0.1:9077/api/event). The hook does no classification or config reading.POST /api/eventhits the axum handler. Origin guard rejects non-null cross-origin requests.adapters::dispatchroutes byclient;adapters::claude::dispatchmatches oneventand produces anAdapterOutput::Set { input, transcript_path } | Clear { id } | Ignore. All chat-id derivation, prompt cleaning, and transcript question-detection happen here.- For
Set,label_policy::selectdecides the(label, original_prompt)pair andAppState::apply_setruns: if status transitions out ofworking, it accumulates elapsed time intoworking_accumulated_ms; if the transition is a task boundary (done/idle→working), it zeroes the accumulator; otherwise existing timers are preserved. - If
transcript_pathis present,WatcherRegistry::startspawns a per-session tokio task with anotify::RecommendedWatcheron the transcript’s parent directory. emit_sessions_updatedbroadcasts the fresh snapshot on thesessions_updatedevent.- The Svelte frontend’s
listencallback replaces its$statesessions array, Svelte’s reactivity re-renders the list, the row updates within a frame.
Path 2 — Transcript-driven updates
- The watcher task from Path 1 is listening to filesystem events on the transcript’s parent directory.
- Claude Code writes a new JSONL line to the transcript.
notifyfires aModifyevent; the watcher filters to events matching the exact transcript path. - The task sends a drain signal over an mpsc channel to itself. A 150ms debouncer collapses bursts (editors / streaming writes often produce several events per logical change).
drainreads the new bytes from the tracked byte offset, joins with leftover content from the previous drain, and splits into complete JSONL lines + a new leftover for the next call.infer_statewalks the new lines newest-first, skipping non-conversational entries (metadata, sidechains, synthetic errors). Returns the currentstate, latestmodel, and latest summed input-side token count.apply_watcher_updatemerges the inference into the session: watcher can set status toworking, updatemodel, updateinput_tokens, but cannot roll a session back todone,idle,awaiting, orerror— hook events stay authoritative for terminal states. This avoids the race where the watcher reads a trailing assistant text as “done” while a fresh turn is already in flight.- If anything changed, the session’s
updatedtimestamp refreshes andemit_sessions_updatedfires exactly as in Path 1.
The initial drain on watcher startup suppresses the inferred state (a resume would otherwise snap to a stale “done” from the prior turn) but still surfaces model and token counts.
Tauri commands have two possible targets: native window/tray APIs (hide_window, show_window, toggle_window, quit_app) or AppState itself — remove_session calls apply_clear to dismiss a row the user no longer cares about, then re-emits the snapshot on the same sessions_updated channel.
Path 3 — Tray toggles
- User clicks “Always on top” in the tray menu.
mudafires aMenuEventwith the item’s id. - The tray handler calls
window.set_always_on_top(new_state)directly on the native window — no IPC round-trip. ConfigState::with_mutflipsalways_on_topin the managed config.ConfigState::save_to_diskwritesconfig.json.- The tray’s
CheckMenuItem::set_checkedsyncs the visual checkmark. emit_config_updatedbroadcasts the new config. The frontend picks up the updated color thresholds, token-window lookup, and (future-proof) any UI-driving fields.
Path 4 — External config edits
- User edits
config.jsondirectly (via the “Open config/logs location” tray shortcut or any editor). config_watcher— anotify::RecommendedWatcheron the config directory — receives aModifyevent.- The 150ms debouncer waits for any rename-based atomic writes to settle.
Config::load_or_defaultre-reads the file. Serde serializes both the new and current in-memory configs to JSON strings; if they’re byte-identical, the reload is skipped — this is how our own tray writes avoid re-triggering the reload path.apply_config_to_windowapplies runtime-safe changes (always-on-top, saved window position). Port changes are intentionally ignored on hot-reload and require a restart.config_updatedis emitted and the tray check marks re-sync.
Sticky-label state machine
| Existing session? | Prior status | New status | Action on original_prompt |
|---|---|---|---|
| no | — | working |
set to incoming label |
| no | — | anything | leave None |
| yes | None / done / idle |
working |
re-capture to label (new task); reset working_accumulated_ms = 0 |
| yes | any | any | leave pinned |
UI display rule:
| Status | Label shown |
|---|---|
awaiting |
current label (the agent’s question) |
error |
current label (the error message from the agent) |
working / done / idle |
original_prompt if set, else current label |
This is why an approval cycle — working → awaiting → working with label = "yes" — keeps “fix foo.py” visible across the round-trip, while a genuinely new task after done gets a fresh display.