What we learned shipping an Electron AI agent dashboard
KinetAios is a local-first desktop cockpit for coding agents — Claude Code, Codex and a built-in ReAct engine in one window, on macOS and Windows. These are the architecture decisions that survived contact with reality, and the ones that didn't.
KinetAios is Electron + TypeScript with zero frontend framework — vanilla TS bundled by esbuild. It's MIT; read it, steal the patterns, tell us what we got wrong. Here's the write-up we wish we'd had before starting.
Two processes, one narrow bridge
Main process owns Node: agent runtime, SQLite, settings, CLI spawning. Renderer owns pixels and has no Node access: contextIsolation: true, nodeIntegration: false. Everything crossing the boundary goes through one typed preload API.
The discipline that makes this work: a shared types file is the single contract. Adding a capability means touching exactly three places — the API interface, the preload binding, the main handler — and they must stay in sync. Boring, explicit, greppable. Every Electron security incident starts with someone making this bridge "more convenient".
One fold function for streaming state
Every engine emits the same unified event union: token | tool | cost | status | sessionStarted | done | error. A single pure function folds one event into the conversation's turn state. Main calls it before persisting; the renderer calls the same function to update the view.
We rebuilt this once after the original per-engine state handling drifted. The rule that fell out: when you change how an event updates state, change it in exactly one place. If streaming view and stored history can disagree, they eventually will — and the disagreement ships to users before you notice.
SQLite + FTS5 as the spine
History, long-term memory, search: all SQLite. FTS5 powers full-text recall across every conversation and tool result. Three lessons paid for in production:
- Run schema migrations idempotently on init — virtual tables via
db.exec, because.prepareonly runs the first statement. - Index what you prune. A 90-day DELETE by timestamp without a range index meant a full scan of 600k+ rows every few turns; one
CREATE INDEXended it. - Bounded background jobs, always. Our memory-dedup pass was O(n²) over pairwise token sets — at 45k memories it stalled the main process for minutes at 83% CPU. Cap the working set, yield to the event loop, push the tail to a decay job. If a background task's cost isn't written in a comment next to its trigger, it's a future outage.
Spawning CLIs is the hard part on Windows
The dashboard spawns real CLIs (claude, codex) and parses their JSON streams. On Windows, npm-global CLIs are .cmd shims — and since CVE-2024-27980, Node refuses to spawn .cmd/.bat directly. The routing: shims go through shell: true (sandboxed, fixed argv), real executables spawn directly.
Second trap: a GUI-launched Electron app inherits a sparse PATH and can't find the CLIs at all. Solution is augmenting PATH with the common install dirs before spawn. Neither trap is exotic; both produce "works in my terminal, broken in the app" reports that waste afternoons.
Vanilla TS over a framework
No React. Renderer is TypeScript + HTML + CSS bundled by esbuild. For a tool UI (lists, streaming text, a modal), a framework buys you a virtual DOM and costs you a build pipeline, a state model, and bundle weight. The streaming markdown renderer is a single function: parse → fold into DOM, throttled by requestAnimationFrame (which also auto-pauses when the window is hidden — setTimeout throttling doesn't). Every "should we add React?" conversation since has died at "what would it change tomorrow?"
Fence parsing is a state machine, not a regex
A regex like /```(\w*)\n[\s\S]*?```/ happily terminates a code block on a fence that appears inside the content — and then writes the rest of the message twice. We replaced it with a small line-by-line state machine following CommonMark fence rules: about 40 lines, no edge cases since. Lesson: streaming markdown is a parsing problem, and parsing problems get state machines.
Things that didn't survive
- Per-engine event handling. Three engines, three code paths, subtle divergence. Replaced by the unified event union + one fold.
- Confirm flows in the main process. Main can't show UI; the first confirm implementation grew tentacles. Now: main parks a resolver, sends one message to the renderer, the modal replies. One map, one timeout.
- Settings as loose JSON, grown organically. Became a schemaless blob. Typed it end to end; the diff was brutal, the afterwards was quiet.
The meta-lesson
An agent dashboard is mostly plumbing around someone else's loop: stream parsing, history, memory, permissions. The plumbing is the product. Every hour spent making the bridge narrow, the state fold pure, and the background jobs bounded paid for itself; every shortcut became a 2 a.m. incident.
Open source (MIT) · 3,200+ clones · Electron + TypeScript · SQLite + FTS5 · macOS & Windows.
★ Star on GitHub Mac App Store — $12.99This plumbing ships in more than one product — 22 apps, all local-first, one developer. Browse the family →