Architecture
Klaxon is a Tauri v2 desktop HUD for MCP agent notifications and form-based interactions.
Data Flow
Agent → HTTP (MCP/JSON-RPC) → Rust (axum) → KlaxonStore → broadcast channel
↓
Tauri UI events → React widgets
↓
SSE notifications/klaxon → AgentUser responses flow back through the same broadcast channel:
User → UI form submit → KlaxonStore → broadcast → SSE notification → AgentPackages
src-tauri/ — Rust backend
| Module | Purpose |
|---|---|
main.rs | Tauri setup, command registration, system tray, event bridging, background tickers, MCP server bootstrap. |
mcp_http.rs | axum HTTP server. JSON-RPC 2.0 over POST /mcp, SSE on GET /mcp, GET /mcp/discover. Per-agent rate limit gate. |
models.rs | Shared types: KlaxonItem, KlaxonForm, FormField (discriminated enum), KlaxonAction, AgentInfo. |
store.rs | KlaxonStore — SQLite-backed item store + broadcast::Sender<StoreEvent>. All mutations carry an actor for audit. |
audit_store.rs | klaxon_audit table writer — every store mutation records actor / action / item_id / details JSON. |
agent_store.rs | mcp_agents table writer — first/last seen, total/per-day call counts, last tool. Persisted across restarts. |
template_store.rs | klaxon_templates table — reusable form schemas referenced by klaxon.ask via template: "<name>". |
comment_store.rs | klaxon_comments table (Bundle E Phase 1) — append-only conversation thread on a single item. Distinct from parent_id which spawns sibling items. |
saved_views.rs | saved_views table — named filter combinations the user can recall from the main window sidebar. |
notification_rules.rs | channel_rules table — per-channel notification routing (mode = all/errors_only/muted, sound = default/none/alert). |
rate_limit.rs | In-memory sliding-window per-agent rate limiter (60 calls/min/agent default). |
window_state.rs | window_state table — per-window position, size, and pinned flag persisted across launches. |
settings_store.rs | SettingsStore — theme, MCP port, bearer token, quiet hours config, auto-archive policy. |
The src-tauri/capabilities/default.json file is also load-bearing: Tauri 2 grants zero permissions by default, so this file declares the permission set every webview gets (core:default, core:event:default, core:webview:default, core:window:default, core:menu:default, core:tray:default, core:image:default, core:resources:default, notification:default). Without it, every useTauriEvent subscription, every notification call, and most window APIs fail with "X not allowed. Permissions associated with this command: ...".
packages/ui/ — React frontend
Communicates with Rust via @tauri-apps/api:
invoke()for Tauri commands (UI → Rust)listen()for Tauri events (Rust → UI)
Each window is a separate Tauri webview, routed by ?panel=X:
| Panel | Window | Purpose |
|---|---|---|
?panel=klaxon | klaxon | Small transparent always-on-top HUD (KlaxonWidget). |
?panel=form | form | Small transparent on-demand form HUD (FormWidget wrapping FormWizard). |
?panel=main | main | Decorated 1000×700 main window with sidebar / list / detail / settings (MainWidget). |
The main window folds in what used to be three separate windows (Settings, History, item interaction). The tray's "Settings" menu item deep-links into the Settings tab via a main.navigate { section: "settings" } event.
Key components
| Component | Purpose |
|---|---|
widgets/MainWidget.tsx | Three-pane main window. Sidebar (filter views + channels + saved views) / item list / item detail / status bar. |
widgets/SettingsWidget.tsx | Exports ThemeTab, McpTab, NotificationsTab. The standalone SettingsWidget is legacy/test surface. |
widgets/TemplatesTab.tsx | Settings → Templates tab — list / edit / delete form templates with live FormWizard preview. |
widgets/KlaxonWidget.tsx | HUD overlay. Lists open items as KlaxonCards with Ack / Dismiss / Open Form / Open in main buttons. |
widgets/FormWidget.tsx | Form HUD overlay. Thin wrapper around FormWizard + the form.open deep-link plumbing. |
components/HistoryCard.tsx | Compact item card with ChannelChip, TagChip, PriorityChip, DeadlineChip, multi-select checkbox. |
components/FormWizard.tsx | Multi-page form wizard with conditional branching. Used by both the Form HUD and the inline detail-pane form. |
components/FormField.tsx | Per-field renderer + validateField(). Includes TagInputControl and the file/repeat/everything renderers. |
components/KeyboardHelp.tsx | Modal overlay listing every keyboard shortcut. Triggered by ? or the sidebar entry. |
components/UndoToast.tsx | 5-second undo toast at the bottom of the main window. Pushed after dismiss / archive. |
components/ContextMenu.tsx | HTML context menu shown on right-click of an item card. Status-conditional entries. |
components/SnoozePicker.tsx | Quick snooze popover (1h / 4h / Tomorrow morning / Next Monday). |
components/DateRangeChips.tsx | Date range filter chips above the item list. |
components/DraggablePanel.tsx | Draggable overlay container — used by the Klaxon and Form HUDs only. |
hooks/useKeyboardShortcuts.ts | Window-level keyboard shortcut registry, ignores keystrokes inside text inputs. |
hooks/useTauriEvent.ts | Singular useTauriEvent, plural useTauriEvents, and useRefreshOnVisible (recovery from hidden webviews). |
utils.ts | Shared helpers: relTime, levelColor, priorityLabel, deadlineLabel, fmtBytes. |
packages/protocol/ — shared types
Zod schemas for KlaxonItem, FormField (discriminated union by type), KlaxonAction (discriminated union by kind), KlaxonAnswer, AppSettings, and the form schema. This is the contract between Rust serde output and the UI.
The package's package.json main and types point at src/index.ts directly — there is no dist/ build step. Vite, vitest, and tsc all handle TS files in workspace deps. The old dist/ build artifact was removed because it could drift behind src and silently break Zod parsing in the running app, which actually happened once during early Bundle B testing. When you add a new field-type variant to the discriminated union, the UI sees it on the next vitest run / Vite HMR reload — no need to remember to rebuild dist.
The KlaxonItem.metadata field uses z.preprocess(v => v == null ? {} : v, ...) as a defense-in-depth: if the Rust side ever serializes null again (it used to, before the WorkflowFields::default() fix), the UI coerces to {} instead of dropping the entire item.
Store persistence
KlaxonStore persists to SQLite at <app_data_dir>/klaxon.db. The schema is managed by 18 sqlx migrations under src-tauri/migrations/:
| Migration | Adds |
|---|---|
0001_init.sql | klaxon_items, settings |
0002_new_widgets.sql | Action button columns |
0003_channel_tags.sql | channel, tags columns |
0004_archive.sql | archived_at column |
0005_viewed.sql | viewed_at column |
0006_indexes.sql | Status / channel / archived / viewed indexes |
0007_mcp_agents.sql | mcp_agents table |
0008_agent_id.sql | agent_id column on items |
0009_audit_log.sql | klaxon_audit table + indexes |
0010_relations.sql | parent_id, related_to columns + thread index |
0011_templates.sql | klaxon_templates table |
0012_saved_views.sql | saved_views table |
0013_workflow_fields.sql | priority, deadline_at, updated_at (+ trigger), metadata columns |
0014_window_state.sql | window_state table |
0015_channel_rules.sql | channel_rules table |
0016_snooze.sql | snoozed_until column + index |
0017_comments.sql | klaxon_comments table + per-item index (Bundle E Phase 1) |
0018_assignees.sql | assignee_id column on klaxon_items + partial index (Bundle E Phase 2) |
updated_at is auto-bumped by an AFTER UPDATE trigger so callers don't need to remember it. The trigger guards against recursion via WHEN NEW.updated_at IS OLD.updated_at.
TTL expiry
Item TTL expiry is lazy — items are expired on list_open() reads, not on a timer. This avoids a background task in the common case, and the periodic snooze sweep + auto-archive sweep are the only timers that run.
Background tasks
The Tauri setup spawns several long-lived async tasks:
| Task | Cadence | Purpose |
|---|---|---|
| Store event bridge | event-driven | Subscribes to KlaxonStore::events, emits matching Tauri events to webviews and OS notifications. |
| Snooze sweep | every 60s | Calls KlaxonStore::wake_due_snoozed to clear expired snoozes; first run is right after launch. |
| Auto-archive sweep | once per 24h | Calls KlaxonStore::auto_archive_sweep if auto_archive_after_days > 0. Re-reads settings each tick. |
| MCP HTTP server | spawned once | axum service listening on the configured / auto-picked port. |
The notification firing path inside the bridge consults two layers before showing a desktop alert:
- Per-channel rules (
notification_rules::should_notify) — defaults to "yes" when no rule is configured. - Quiet hours schedule —
AppSettings::is_in_quiet_hours(handles wrap-around windows like 22:00 → 07:00) andlevel_allowed_in_quiet_hours.
Items always still appear in the HUD and main window — only the desktop alert is suppressed.
Tauri commands (UI → Rust)
Item lifecycle
klaxon_list_open, klaxon_list_all, klaxon_list_answered, klaxon_get_item, klaxon_ack, klaxon_dismiss, klaxon_restore (undo dismiss), klaxon_archive, klaxon_unarchive, klaxon_mark_viewed, klaxon_snooze, klaxon_unsnooze, klaxon_search (debounced full-text), klaxon_answer, klaxon_run_action.
Window control
klaxon_open_form, klaxon_take_pending_form_id, klaxon_open_main_at_item, start_panel_drag, set_panel_always_on_top (also persists pinned to window_state), hide_panel, show_panel, show_panel_menu, resize_window.
Settings + MCP
settings_get, settings_set, mcp_get_status, mcp_list_agents, mcp_refresh_token.
Saved views
saved_views_list, saved_views_create, saved_views_update, saved_views_delete.
Channel rules
channel_rules_list, channel_rules_save, channel_rules_delete.
Templates (UI bypass for the MCP klaxon.template_* tools)
template_list, template_get, template_save, template_delete.
Backup / restore
klaxon_export (writes a versioned JSON file with every item), klaxon_import (re-creates each item via notify_with / ask_with, idempotent on item id).
Demo
klaxon_demo_create, demo_seed.
Tauri events (Rust → UI)
| Event | Payload | Trigger |
|---|---|---|
klaxon.created | KlaxonItem | A new item was created via any path. |
klaxon.updated | KlaxonItem | An item was mutated (status flip, message edit, archive, snooze, withdraw, etc.). |
klaxon.answered | { id, response } | A form answer was submitted. |
klaxon.viewed | { id, viewed_at } | First-view companion to klaxon.updated. Fires exactly once per item. |
klaxon.bulk_updated | { action, ids } | One event per dismiss_many / archive_many / mark_viewed_many call. |
mcp.ready | { url, token, port } | MCP server bound to a port (or rotated bearer). |
settings.changed | AppSettings | The user changed any setting. |
form.open | { id } | Tray / HUD requested the Form HUD show a specific item. |
main.navigate | { section?, item_id? } | Deep-link the main window to a section and/or item. |
agents.updated | null | An agent connected or disconnected. |
SSE bridge
The same KlaxonStore broadcast channel feeds two consumers:
- Tauri webview event bridge — re-emits store events as the webview events listed above.
- MCP SSE stream — translates each store event into a
notifications/klaxonJSON-RPC notification (see MCP API → SSE Notifications).
Both consumers see the same events; the SSE bridge has its own per-event id counter for client-side resume.
Per-agent attribution and audit
Every store mutation takes an actor: &str argument. Conventional values:
"user"— Tauri commands invoked by a UI button click"agent:<client_id>"— MCP request (x-client-idheader) → tools/call"system"— background tasks (snooze sweep, auto-archive)"demo"— demo seed"test"— unit tests"import"—klaxon_importTauri command
The actor is recorded in the klaxon_audit table alongside the action name (create, update, dismiss, archive, withdraw, snooze, etc.) and a JSON details blob describing what changed. This is the source for the klaxon/audit and klaxon/audit/{id} MCP resources.