Developer guide

How the codebase is organized, why each boundary exists, and where to change things safely. New here? Read the user-facing “how it works” first — this page explains the machinery behind it.

Mental model

Kivo is one Tauri process with four pre-created webview surfaces. React owns presentation and transient UI state — nothing else. Rust owns shortcuts, window placement, speech sessions, selected text, replacements, settings, credentials, Gemini requests, and tray lifecycle.

The one rule: sensitive text and keys are absent from serializable types wherever the UI doesn’t need them. If the webview doesn’t need it to render, it never crosses the bridge.

This split is deliberate: the webview is convenient for UI but untrusted for secrets. The Rust core (AppCore in src-tauri/src/commands/, wired in lib.rs) is the only thing allowed to touch credentials, the microphone path, or another app’s text.

Repo map

Kivo/
├── src/                    # React UI — presentation only
│   ├── App.tsx             # surface router (flow-bar | writing-tools | settings | onboarding)
│   ├── components/         # Button, Icon, Switch, ShortcutRecorder, …
│   ├── features/
│   │   ├── dictation/      # FlowBar + reducer state machine
│   │   ├── writing-tools/  # popup, actions.ts, SafeMarkdown, reducer
│   │   ├── settings/       # SettingsWindow (5 sections)
│   │   └── onboarding/     # first-run permission + setup flow
│   ├── hooks/ platform/    # nativeBridge, useNativeEvent, prefs
│   └── styles/             # tokens in globals.css, per-surface css
├── src-tauri/src/
│   ├── lib.rs main.rs      # composition root, tray, shortcuts, surfaces
│   ├── commands/           # Tauri invoke handlers + AppCore
│   ├── speech/             # session management over platform engines
│   ├── text/               # selection capture + replacement
│   ├── ai/                 # Gemini client (mod.rs), link_summary.rs
│   ├── platform/           # macos.rs, windows.rs, adapters.rs, mod.rs
│   ├── config/             # settings.json repository
│   ├── security/           # SecretString + redaction helpers
│   └── shell.rs            # windows, tray, shortcuts, placement
└── website/                # this site (static, no build step)

The four surfaces

SurfaceRole
flow-barNon-activating, always-on-top dictation pill. Tightly sized; never steals focus.
writing-toolsCompact selection-aware command + result popup. Anchors to cursor / selection / fixed point.
settingsNative-window preferences. Closing hides the window — it never quits the app.
onboardingShort first-run permission and setup flow.

Windows are pre-created and shown/hidden rather than spawned per use — that’s what keeps the Flow Bar and popup instant. Placement logic lives in shell.rs (multi-monitor aware; verify on both OSes before release). UI reducers (features/dictation/state.ts, features/writing-tools/state.ts) model the popup lifecycle, including generation counters that discard late responses when a popup closes.

Platform boundary

All OS code is isolated under src-tauri/src/platform/ behind the adapter traits in adapters.rs (PlatformSpeechEngine, PlatformTextService, PlatformCredentialStore). The rest of Rust never touches an OS API directly.

ConcernmacOS 26Windows 11 24H2+
SpeechSpeechAnalyzer + DictationTranscriberWinRT speech (needs MSIX identity)
Text accessAccessibility / Core Graphics / AppKitUI Automation + Win32 window/input
SecretsKeychainCredential Manager
Dictation keyFn/Globe via Input Monitoring (best-effort)Ctrl+Meta with Win-key suppression

The boundary reserves application-specific and clipboard-fallback replacement strategies — but V1 fails safely when native Accessibility / UI Automation can’t preserve the selection, rather than risk mangling user text.

Dictation pipeline

speech/ manages sessions; platform/* feeds audio to the OS engine and streams levels back for the waveform (dictation-level events) and status snapshots (dictation-state: idle → listening → processing → success/error). The React side is a thin reducer over those events — see FlowBar.tsx.

On release, the transcript goes through optional AI cleanup, then text/ inserts it at the saved cursor. esc cancels; closing paths bump a generation counter so late engine callbacks are ignored.

Selection & replace pipeline

Invoking Writing Tools captures a SelectionContext (app name, bounds, initial text, canReplace) and emits writing-context. The popup runs its reducer (OPEN → RUN → RESULT / REPLACED / FAIL), calls runWritingAction, and on Replace asks Rust to write back through the platform text service. Summarize / Key Points / Chat are resultOnly — the core refuses to treat them as replacements, including through the native command path.

AI client

Pinned in src-tauri/src/ai/mod.rs:

GEMINI_MODEL = "gemini-3.8-flash"   // low thinking, latency-sensitive
endpoint   = .../v1beta/interactions
store      = false                  // always — retention off

Prompts are built in writing_prompt() behind a guard: source text is treated as untrusted content, never instructions (system prefix in mod.rs), text summaries over 200,000 chars are rejected, and link summaries go through link_summary.rs (URL-context retrieval for pages, video input for YouTube) with a 90-second deadline vs. 20 seconds for ordinary writing calls. Successful URL-retrieval evidence is required before a webpage summary is shown.

Changing the model or endpoint? Edit only src-tauri/src/ai/mod.rs and update its fixtures at the same time. That file is the single source of truth — there is intentionally no provider abstraction.

Credentials & settings

Keys are saved via PlatformCredentialStore, tested against the Interactions API, and their status surfaced as configured / untested / testing / connected / invalid / rate-limited / offline. The key value itself is a SecretString that never serializes to the frontend. Plain preferences live in settings.json via config/SettingsRepository; FrontendSettings is the sanitized projection the UI may see.

Why it’s built this way

No provider abstraction. One model, one endpoint, one call site. Abstractions would widen the blast radius of a prompt/privacy mistake for zero current benefit.

No App Sandbox on macOS. System-wide Accessibility integration is incompatible with it, so Kivo ships direct (signed + notarized) instead of via the Mac App Store.

MSIX on Windows. Native speech recognition requires an installed package identity — that’s what forces the MSIX format, not fashion.

Audio never touches Gemini. The OS engine is good and already privileged; routing voice to a cloud model would add latency, cost, and a privacy liability for no quality win.

Short, vague user errors; rich-in-kind internal codes. User-facing strings stay concise; diagnostics record operation names, error categories, and non-sensitive OS codes only.

Vite-only harness for UI. bun dev renders all four surfaces with mock bridges (append ?state=listening|processing|success|error for Flow Bar states) so UI work never needs OS integration or a real key.

Conventions

RuleDetail
LoggingNever log selections, transcripts, responses, clipboard, or credentials.
FrontendReducers + nativeBridge; generation counters to drop stale async results.
RustOS calls stay in platform/; everything else goes through adapters.
MarkdownRender AI output only through SafeMarkdown.
ThemingDesign tokens in globals.css; data-theme / data-platform on root.

Dev workflow

bun install
bun tauri dev     # full app (needs Xcode / VS Build Tools + OS perms)

bun dev           # Vite-only UI harness, no OS integration
bun typecheck && bun lint && bun test
bun test:ui       # Playwright; isolated port: KIVO_UI_TEST_PORT=1437 bun run test:ui
bun check:rust && cargo test --manifest-path src-tauri/Cargo.toml

Physical testing on both systems is required before a release: Fn/Globe handling, Windows-key suppression, speech model availability, multi-monitor placement, accessibility behavior in third-party apps, and signing.

Releases & updates

Pushing an app-v* tag runs .github/workflows/release.yml (macOS + Windows artifacts, drafted GitHub Release). bun run prepare:release generates the ignored release-only Tauri config and injects the updater public key — normal local builds have no trusted updater key on purpose. Configure TAURI_UPDATER_PUBKEY, TAURI_SIGNING_PRIVATE_KEY (+_PASSWORD), Apple signing/notarization secrets, and WINDOWS_CERTIFICATE_BASE64 / _PASSWORD / WINDOWS_PUBLISHER before tagging. Never commit updater private keys or certificates.