Skip to content

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 → Agent

User responses flow back through the same broadcast channel:

User → UI form submit → KlaxonStore → broadcast → SSE notification → Agent

Packages

src-tauri/ — Rust backend

ModulePurpose
main.rsTauri setup, command registration, system tray, event bridging, background tickers, MCP server bootstrap.
mcp_http.rsaxum HTTP server. JSON-RPC 2.0 over POST /mcp, SSE on GET /mcp, GET /mcp/discover. Per-agent rate limit gate.
models.rsShared types: KlaxonItem, KlaxonForm, FormField (discriminated enum), KlaxonAction, AgentInfo.
store.rsKlaxonStore — SQLite-backed item store + broadcast::Sender<StoreEvent>. All mutations carry an actor for audit.
audit_store.rsklaxon_audit table writer — every store mutation records actor / action / item_id / details JSON.
agent_store.rsmcp_agents table writer — first/last seen, total/per-day call counts, last tool. Persisted across restarts.
template_store.rsklaxon_templates table — reusable form schemas referenced by klaxon.ask via template: "<name>".
comment_store.rsklaxon_comments table (Bundle E Phase 1) — append-only conversation thread on a single item. Distinct from parent_id which spawns sibling items.
saved_views.rssaved_views table — named filter combinations the user can recall from the main window sidebar.
notification_rules.rschannel_rules table — per-channel notification routing (mode = all/errors_only/muted, sound = default/none/alert).
rate_limit.rsIn-memory sliding-window per-agent rate limiter (60 calls/min/agent default).
window_state.rswindow_state table — per-window position, size, and pinned flag persisted across launches.
settings_store.rsSettingsStore — 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:

PanelWindowPurpose
?panel=klaxonklaxonSmall transparent always-on-top HUD (KlaxonWidget).
?panel=formformSmall transparent on-demand form HUD (FormWidget wrapping FormWizard).
?panel=mainmainDecorated 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

ComponentPurpose
widgets/MainWidget.tsxThree-pane main window. Sidebar (filter views + channels + saved views) / item list / item detail / status bar.
widgets/SettingsWidget.tsxExports ThemeTab, McpTab, NotificationsTab. The standalone SettingsWidget is legacy/test surface.
widgets/TemplatesTab.tsxSettings → Templates tab — list / edit / delete form templates with live FormWizard preview.
widgets/KlaxonWidget.tsxHUD overlay. Lists open items as KlaxonCards with Ack / Dismiss / Open Form / Open in main buttons.
widgets/FormWidget.tsxForm HUD overlay. Thin wrapper around FormWizard + the form.open deep-link plumbing.
components/HistoryCard.tsxCompact item card with ChannelChip, TagChip, PriorityChip, DeadlineChip, multi-select checkbox.
components/FormWizard.tsxMulti-page form wizard with conditional branching. Used by both the Form HUD and the inline detail-pane form.
components/FormField.tsxPer-field renderer + validateField(). Includes TagInputControl and the file/repeat/everything renderers.
components/KeyboardHelp.tsxModal overlay listing every keyboard shortcut. Triggered by ? or the sidebar entry.
components/UndoToast.tsx5-second undo toast at the bottom of the main window. Pushed after dismiss / archive.
components/ContextMenu.tsxHTML context menu shown on right-click of an item card. Status-conditional entries.
components/SnoozePicker.tsxQuick snooze popover (1h / 4h / Tomorrow morning / Next Monday).
components/DateRangeChips.tsxDate range filter chips above the item list.
components/DraggablePanel.tsxDraggable overlay container — used by the Klaxon and Form HUDs only.
hooks/useKeyboardShortcuts.tsWindow-level keyboard shortcut registry, ignores keystrokes inside text inputs.
hooks/useTauriEvent.tsSingular useTauriEvent, plural useTauriEvents, and useRefreshOnVisible (recovery from hidden webviews).
utils.tsShared 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/:

MigrationAdds
0001_init.sqlklaxon_items, settings
0002_new_widgets.sqlAction button columns
0003_channel_tags.sqlchannel, tags columns
0004_archive.sqlarchived_at column
0005_viewed.sqlviewed_at column
0006_indexes.sqlStatus / channel / archived / viewed indexes
0007_mcp_agents.sqlmcp_agents table
0008_agent_id.sqlagent_id column on items
0009_audit_log.sqlklaxon_audit table + indexes
0010_relations.sqlparent_id, related_to columns + thread index
0011_templates.sqlklaxon_templates table
0012_saved_views.sqlsaved_views table
0013_workflow_fields.sqlpriority, deadline_at, updated_at (+ trigger), metadata columns
0014_window_state.sqlwindow_state table
0015_channel_rules.sqlchannel_rules table
0016_snooze.sqlsnoozed_until column + index
0017_comments.sqlklaxon_comments table + per-item index (Bundle E Phase 1)
0018_assignees.sqlassignee_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:

TaskCadencePurpose
Store event bridgeevent-drivenSubscribes to KlaxonStore::events, emits matching Tauri events to webviews and OS notifications.
Snooze sweepevery 60sCalls KlaxonStore::wake_due_snoozed to clear expired snoozes; first run is right after launch.
Auto-archive sweeponce per 24hCalls KlaxonStore::auto_archive_sweep if auto_archive_after_days > 0. Re-reads settings each tick.
MCP HTTP serverspawned onceaxum service listening on the configured / auto-picked port.

The notification firing path inside the bridge consults two layers before showing a desktop alert:

  1. Per-channel rules (notification_rules::should_notify) — defaults to "yes" when no rule is configured.
  2. Quiet hours scheduleAppSettings::is_in_quiet_hours (handles wrap-around windows like 22:00 → 07:00) and level_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)

EventPayloadTrigger
klaxon.createdKlaxonItemA new item was created via any path.
klaxon.updatedKlaxonItemAn 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.changedAppSettingsThe 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.updatednullAn agent connected or disconnected.

SSE bridge

The same KlaxonStore broadcast channel feeds two consumers:

  1. Tauri webview event bridge — re-emits store events as the webview events listed above.
  2. MCP SSE stream — translates each store event into a notifications/klaxon JSON-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-id header) → tools/call
  • "system" — background tasks (snooze sweep, auto-archive)
  • "demo" — demo seed
  • "test" — unit tests
  • "import"klaxon_import Tauri 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.