An Electron desktop app that provides a local-first AI chat experience with tool use, MCP integration, skills, sub-agents, and memory. Built with React 19 + TypeScript + Tailwind CSS 4, orchestrated by Mastra.
Author: Contributed by community member License: MIT Package Manager: pnpm 10+ Node: 22+
pnpm install # install dependencies
pnpm dev # start electron in dev/watch mode
pnpm build # build main + preload + renderer
pnpm build:mac # build + package for macOS (arm64 + x64)
pnpm lint # eslint (ts/tsx only)
pnpm type-check # tsc --noEmit
pnpm test # vitest run (unit + component tests)
pnpm test:watch # vitest in watch mode
pnpm preview # preview production build
pnpm rebuild # rebuild native deps (electron-builder install-app-deps)Three Electron process layers with strict isolation:
electron/main.ts <- Main process: window, menus, IPC registration, tool init
electron/preload.ts <- Preload: exposes `window.app` API via contextBridge
src/App.tsx <- Renderer: React shell, sidebar, conversations, settings
| Directory | Purpose |
|---|---|
electron/agent/ |
Mastra agent orchestration, model catalog, memory, sub-agents, tokenization |
electron/ipc/ |
IPC handler registration (agent, config, conversations, mcp, memory, oauth, skills) |
electron/tools/ |
Tool implementations + registry builder |
electron/config/schema.ts |
Zod config schema (AppConfig) - central to everything |
electron/main.ts |
App bootstrap, window creation, menu, hot-reload for MCP + skills |
| Directory | Purpose |
|---|---|
src/components/thread/ |
Chat thread, composer, markdown, code blocks, tool groups, sub-agent views |
src/components/settings/ |
Settings panels (models, tools, MCP, memory, compaction, skills, advanced) |
src/components/conversations/ |
Sidebar conversation list, sub-agent section |
src/components/agent-lattice/ |
Agent Lattice OAuth auth banner |
src/providers/ |
React context providers (Config, Runtime, Attachments) |
src/lib/ |
IPC client wrapper, utilities |
electron/config/schema.ts- Zod schema defining all config (AppConfigtype)electron/tools/registry.ts- Builds active tool set from config, skills, MCP serverselectron/preload.ts- Thewindow.appIPC bridge (renderer's only way to talk to main)electron/agent/mastra-agent.ts- Mastra agent setup and streamingsrc/providers/RuntimeProvider.tsx- Manages conversation streaming state in renderersrc/providers/ConfigProvider.tsx- Reads/writes config via IPCelectron-builder.yml- macOS packaging configelectron.vite.config.ts- Vite config for main, preload, and renderer builds
All app state lives under ~/.kai/:
| Path | Contents |
|---|---|
~/.kai/config.json |
Primary config (models, tools, MCP, memory, compaction, etc.) |
~/.kai/data/ |
Conversation persistence |
~/.kai/skills/ |
Installed skill directories |
~/.kai/certs/ |
TLS certificates for integrations |
~/.kai/settings/llm.json |
Imported provider/model settings |
Config changes trigger hot-reload for MCP servers and skills (fingerprint diffing in main.ts).
ESLint enforces (see eslint.config.js):
consistent-type-imports(error) - useimport typefor type-only importsno-explicit-any(warn)no-unused-vars(warn,_prefix ignored)no-console(warn,console.warn/error/infoallowed)
Additional conventions:
- Tailwind CSS 4 (PostCSS plugin, not the old
tailwind.config.jsapproach) - Radix UI primitives for all interactive components
@/path alias maps tosrc/- Lucide React for icons
Renderer code never accesses Node APIs directly. All communication goes through window.app (defined in preload.ts):
window.app.agent.*- streaming, title generation, sub-agentswindow.app.config.*- get/set config, change listenerswindow.app.conversations.*- CRUD, active conversation trackingwindow.app.mcp.*- test MCP connectionswindow.app.memory.*- clear memory storeswindow.app.skills.*- list/get/delete/toggle skillswindow.app.dialog.*- native file pickerwindow.app.image.*- fetch/save images (bypasses CORS)
Supported via Mastra + AI SDK:
- OpenAI-compatible (custom endpoints)
- Anthropic
- Amazon Bedrock (AWS credential chain)
Config schema: models.providers (keyed by name) + models.catalog (array of model entries).
- macOS only for now -
electron-builder.ymlonly targetsmacwithdmgoutput - contextIsolation is ON - never try to use
require()or Node APIs in renderer code - Tailwind v4 - uses
@tailwindcss/postcssplugin, not the v3tailwind.config.jspattern - Tests - Vitest is the test runner (
pnpm test); the suite runs on every PR and on pre-push via Husky sandbox: falsein webPreferences - needed for preload script to work with full Node access- No conversation auto-cleanup - conversations are never auto-deleted; users manage their own threads
- Tool registry is async - tools build after window creation; MCP connections happen at startup
- Config persistence allowlist -
desktopConfigPayload()inelectron/ipc/config.tsis an explicit allowlist; new config sections MUST be added there or they won't persist
Tests follow a small set of conventions documented in CONTRIBUTING.md: explicit assertions only (no snapshots), helper-driven fixtures from test-utils/ over inline setup, no real network egress (the fetch firewall in vitest.setup.ts fails-closed against unmocked provider hosts), and reliance on the global determinism seams — frozen system time, deterministic UUIDs, and the @lydell/node-pty stub. For the architectural rationale behind these choices, see docs/TESTING_ARCHITECTURE.md.
When debugging issues that span process boundaries (renderer <-> main) or involve async race conditions, use the project-local debug log directory instead of console.warn:
| Item | Value |
|---|---|
| Directory | debug-logs/ (project root, gitignored) |
| Convention | One file per topic, e.g. debug-logs/persist.log, debug-logs/stream.log |
| Format | [ISO-timestamp] [TAG] key=value key=value ... — structured, grep-friendly |
Main process (Node / Electron main) — write directly with fs.appendFileSync:
import { appendFileSync } from 'fs';
import { join } from 'path';
const DEBUG_LOG = join(__dirname, '../../debug-logs/persist.log');
function debugLog(msg: string) {
try {
appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${msg}\n`);
} catch {}
}Renderer process — use console.warn with a [TAG] prefix (renderer cannot write to disk). The user will relay console output, or you can read it from DevTools.
- Create the log file(s) in
debug-logs/. - Add
appendFileSync-based logging in the main process code at the points of interest. - Add
console.warn-based logging in the renderer at the points of interest. - Ask the user to reproduce, then read
debug-logs/*.logto diagnose. - Clean up: remove the debug logging code once the issue is resolved. Debug logs are for investigation, not permanent instrumentation.
Last Updated: 2026-04-22