# Pace — architecture (systems reference)

This is the canonical detailed architecture reference for Pace — the per-system
bullet list that previously lived inline in the [repository AGENTS.md](https://github.com/HeyPace/pace/blob/main/AGENTS.md). It was
extracted to keep `AGENTS.md` a concise agent bootloader. The doctrine and the
high-level constellation diagram live in [`overview.md`](overview.md); per-file
responsibilities live in [`../development/key-files.md`](../development/key-files.md).

## App shape

- **App Type**: Menu-bar-first (`LSUIElement=true`) with no Dock icon. On MacBooks with a camera housing, the always-available Living Notch signal and quick panel are joined by one on-demand resizable Command Center window. Displays without a reported housing use the global shortcut without a synthetic center notch.
- **Framework**: SwiftUI (macOS native) with AppKit bridging for onboarding, the Command Center window, menu-bar panel, hardware-derived notch overlay, and cursor overlay
- **Pattern**: MVVM with `@StateObject` / `@Published` state management
- **Concurrency**: `@MainActor` isolation, async/await throughout
- **Analytics**: local-only no-op/timing-safe hooks via `PaceAnalytics.swift`; no cloud analytics SDK is linked.

## Planner

- **Planner**: main screen/action planning uses the local OpenAI-compatible LM Studio reasoner. The **shipped `LocalPlannerModelIdentifier` default is `google/gemma-3-12b`** (qat-4bit, ~8 GB) — per the 2026-06-12 drilldown it was the only ≤14B model beating the 4B baseline on clarify + out-of-scope + destructive-confirm. `qwen/qwen3-30b-a3b` (MoE, ~18.6 GB at Q4 but only 3B active params — runs roughly as fast as a dense 3B while reasoning at 30B scale) is the recommended stronger swap on 48 GB machines; on `scripts/eval-planners.py` it scored 15/15 on the FM-fixture set at 925ms mean, beating Apple Foundation Models (13/15, 1555ms) and qwen3-14b (15/15, 2156ms). See [`../development/info-plist-switches.md`](../development/info-plist-switches.md) for the model knob. Pure-knowledge text-only turns prefer `AppleFoundationModelsPlannerClient` when Apple Intelligence is available, falling back to LM Studio otherwise, because short answers are latency-sensitive and do not need the larger action planner. Pair the LM Studio planner with the VLM in max-loaded-models=2 setting so neither evicts the other. `&lt;think&gt;…&lt;/think&gt;` blocks stripped before TTS and action-tag parsing. No cloud LLM. **First-launch tier default:** on a brand-new install with no `pace.planner.tier.` UserDefaults state at all, `PacePlannerTierStore.loadConfiguration()` checks `SystemLanguageModel.default.availability` and resolves to `.appleFoundationModels` when Apple Intelligence is available, `.local` otherwise. Apple FM is preferred for first-launch because it ships in-process with zero external install — the user can talk to Pace immediately without LM Studio. Existing users (anyone who has ever opened Settings → Planner) see ZERO behavior change: `hasAnyPlannerTierUserDefaultsState()` gates the new Apple-FM-first default behind a fresh-install check, so their pinned `.local` tier (or any other persisted value) loads exactly as before.
- **Planner (`BuddyPlannerClient`)**: the active planner is chosen by the user in Settings → Planner via `PacePlannerTier` (Local / CLI bridge / CLI direct / Direct API / Apple FM). Default is `.local` (LM Studio + the `LocalPlannerModelIdentifier` model, `google/gemma-3-12b` by default) — existing users see zero behavior change on upgrade. `BuddyPlannerClientFactory.makeDefault()` dispatches on tier: `LocalPlannerClient` for `.local` and as a fallback for missing/invalid configs; `AppleFoundationModelsPlannerClient` for `.appleFoundationModels` (also the preferred fast pure-knowledge answer planner when Apple Intelligence is available); `CloudBridgePlannerClient` (existing) for `.cliBridge` with the same NSAlert consent + 24-hour soak gate; `PaceLocalCLIPlannerClient` for `.cliDirect`; `DirectAPIPlannerClient` for `.directAPI` BYO-key turns. The same `CompanionSystemPrompt` flows through every tier so persona and tool dialect stay byte-identical. Direct-API keys live in macOS Keychain via `PaceKeychainStore` — never UserDefaults, never a plist, never a log line. While ANY non-Local tier is serving a turn, the menu-bar capsule tints amber via `isOffDeviceTurnInFlight`. Failures fail loud; the `directAPIFallsBackToLocalOnCloudFailure` opt-in toggle (default off) is the one path to silent fallback. The main (action) planner is DECODE-CONSTRAINED to the v10 `{spokenText,intent,payload}` envelope via `response_format: json_schema` (`LocalPlannerClient.v10ResponseFormat`): `payload.calls` is typed as `[{name,args}]` for multi-step turns, `Draw.annotation`/`Clear.annotations` are in the documented action vocabulary, and a structured stream whose final text is not valid JSON is retried without `cache_prompt` (audit `outcome=invalid_structured_json`). Draw actions are DRAINED out of the parse result and rendered by `PaceAnnotationActionDrainer` before dispatch — so a draw-only turn's trace reads "no actions parsed — spoken-only turn" BY DESIGN; in mascot mode the annotation presence-callback transiently reveals the (otherwise suppressed) cursor overlay window that hosts the annotation layer. Eval coverage for these paths lives in `evals/fm-fixtures-actions/` scored on DECODED actions via `evals/pace_v10.py` mirrors.
  - **`.cliDirect` general brain (OpenSpec `openspec/changes/archive/2026-07-12-codex-general-brain`)**: a user-selectable tier that direct-spawns the user's already-authenticated `codex` (default) or `claude` CLI — no Node bridge, only the CLI on PATH — as the brain for ALL turns (the same `PaceLocalCLIPlannerClient` the `.research` lane already uses). Upstream sub-selection persists via `PacePlannerTierStore` key `pace.planner.tier.cliDirect.upstream` (`PaceLocalCLIUpstream`, default `.codex`). Off-device, so it inherits the FULL cloud-bridge safety contract via a *separate* transport-aware consent: `PaceCloudBridgeConsent.hasAcceptedDirectSpawnConsent()` + a 24-hour direct-spawn soak (`canRunDirectSpawnTurn(now:)`) — accepting the Node-bridge consent does NOT auto-grant the direct-spawn path. A `.cliDirect` turn tints the capsule amber (`isOffDeviceTurnInFlight`, keyed on `plannerClientForThisTurn is PaceLocalCLIPlannerClient`), writes a `PaceAPIAuditLog` entry (`subsystem: "planner.cliDirect"`, `target: &lt;codex|claude&gt;`) so the Privacy dashboard flips off "0 bytes → X KB to codex", and FAILS LOUD via `PaceFailureNarrator` on CLI error (no silent local fallback beyond the existing opt-in). Missing-binary preflight (`PaceLocalCLIPlannerClient.isUpstreamBinaryOnPath`) surfaces a plain-language "needs `codex` on PATH" hint before the turn. **Codex is the phone-a-friend default:** the `.research` lane defaults to Codex on fresh installs (`PaceResearchTierStore.defaultCLIBridgeUpstream = .codex`, `defaultCLIBridgeModel = ""` so Codex uses its own authenticated model; persisted upstreams honored for existing users), and cron/scheduled fires (`CompanionManager+Lifecycle` `executeTaskCallback`) route through a Codex direct-spawn brain ONLY when direct-spawn consent + 24h soak are satisfied (`BuddyPlannerClientFactory.cronTaskBrainDecision`, sharing `cliDirectDispatchDecision`) — otherwise they fall back to the local planner, so an unattended background fire never silently goes off-device (amber forced for the Codex path). The planner brain is also selectable from Settings → Models via a **RAM-aware brain picker** folded into `PaceModelMemoryBudget`'s budget section (`PaceBundledModelsSettingsTab.memoryBudgetSection`): picking a cloud/Apple-FM brain frees the local-planner RAM and the section live-updates the per-model breakdown, fits verdict, and largest-VLM-that-fits recommendation. Both the Planner tab and the Models picker route through the shared `CompanionManager.selectPlannerTierWithConsent`.

## Voice, vision, TTS

- **Speech-to-Text**: Apple **`SFSpeechRecognizer`** (on-device, `requiresOnDeviceRecognition=true`) is the default active provider — instant, no model download. `TranscriptionProvider=whisperKit` is accepted as a scaffolded local provider selection, but falls back to Apple Speech until the real WhisperKit streaming bridge lands. All cloud STT providers have been removed.
- **Text-to-Speech**: two on-device conformers of `BuddyTTSClient`. Default runtime path is **`LocalServerTTSClient`** — Kokoro-82M served by a loopback OpenAI-compatible `/v1/audio/speech` sidecar (`scripts/start-tts-server.sh`, mlx-audio on port 8880; ~150 ms warm synthesis per sentence; sentence N+1 renders while N plays). Endpoint is loopback-guarded; ANY sidecar failure falls back per-utterance to **`LocalTTSClient`** (`AVSpeechSynthesizer`, Premium > Enhanced > compact `LocalTTSVoiceIdentifier`), with a 30 s outage memo so a missing sidecar costs nothing. `TTSProvider=apple` opts out entirely. Cloud TTS has been removed.
- **Local Vision-Language Model (optional)**: LM Studio at `http://localhost:1234/v1` (OpenAI-compatible). When `UseLocalVLMForScreenContext=true`, the cursor-screen screenshot is sent to the local VLM (UI-Venus-1.5-2B by default, per `LocalVLMModelIdentifier` in Info.plist) and its structured element map is prepended to the local planner prompt. Planner/VLM HTTP roots are guarded by `PaceLocalEndpointGuard` and must be loopback-only; remote or LAN hosts fall back to localhost instead of sending data off-machine. **VLM-skip heuristic** in `PaceTagParsers.transcriptIsLikelyScreenReferential` bypasses the call for pure-Q&A transcripts; override via `AlwaysRunLocalVLMRegardlessOfTranscript=true`.
- **Screen Capture**: ScreenCaptureKit (macOS 14.2+), multi-monitor support
- **Voice Input**: Push-to-talk via `AVAudioEngine` + pluggable transcription-provider layer. System-wide keyboard shortcut via listen-only CGEvent tap.
- **Voice Input UI**: `PaceNativeInterfaceModel` maps ready, listening, understanding, approval, acting, speaking, completed, blocked, and failed into one semantic state language. `PaceSignalView` renders that language across onboarding, the real notch, and the quick panel; off-device planner activity overrides the active signal to amber. Timeline rendering exists only for active states, and Reduce Motion uses static or short crossfade presentations.
- **Cursor**: Codex-style arrow (`CodexArrowShape`) with linear-gradient fill, white highlight stroke, dual shadow.
- **Element Pointing**: the planner embeds `[POINT:x,y:label:screenN]` tags in its response. The overlay parses these, maps coordinates to the correct monitor, and animates the cursor along a bezier arc to the target.

## Memory

- **Two-tier in-context memory**: every planner call carries the last K=4 turns verbatim (`PaceThreadMemory.verbatimWindow()`) AND a rolling summary of everything older (`injectionPrefix()`, rendered into the system prompt as `&lt;conversation_so_far&gt;...&lt;/conversation_so_far&gt;`). The summary is refreshed by a detached Apple FM call after each turn via `PaceThreadSummarizer`; the user-facing path never blocks on summarization. Race-safety lives in a monotonic `summaryVersion` snapshot captured before each detached call, so out-of-order arrivals are dropped at `applySummaryUpdate`. In-session a 20-minute idle threshold still drops the live state (or Settings → Memory → Reset thread). The conversation now SURVIVES quit/relaunch: `PaceThreadMemory.snapshot(now:)`/`restore(from:)` are pure value-type accessors and `PaceThreadMemoryStore` persists a single on-device JSON (`~/Library/Application Support/Pace/thread-memory.json`, atomic write) after every turn/summary/reset; `CompanionManager.start()` rehydrates it. Policy is "resume always, until reset" — no staleness expiry; the file is wiped only on an explicit thread reset or when thread memory is disabled. The summary is still never journaled to `paceHistory` (only session id + lifecycle events are). Durable facts go through episodic memory instead.
- **Always-On Companion Mode (default off)**: typed provenance-bearing `PaceWorldObservation` evidence uses bounded atomic persistence, current-state/last-seen/change/presence queries, confidence decay, correction supersession, deterministic memory promotion, and a dedicated retrieval source. `PaceCompanionModeController` exposes off/starting/observing/interpreting/paused/degraded/privacy-blocked; `PaceCompanionInterventionPolicy` defaults to silent memory and keeps card/speech opt-ins separate. Settings → Companion and menu-bar indicators expose source consent, active capture, retention/storage, readiness, pause, and clear. App lifecycle starts explicitly enabled ambient/watch sources plus a production low-rate AVFoundation/Vision camera client that emits only non-identifying ephemeral person tracks and conservatively matches user-taught, locally persisted Vision feature prints across coarse camera zones. Teaching is an explicit centered-object capture; no photo is persisted. Ambient voice uses a bundled local pre-STT Core ML gate (`PaceWakeWordClassifier`, exact labels `hey_pace` and `background`); missing or malformed assets fail closed before Speech.framework is reachable. Accepted wakes hand off to the bounded push-to-talk conversation path. Silent cards and spoken interventions are separately default-off and wired through the intervention policy; speech additionally passes the existing live restraint/cooldown path. Routine promotion requires at least three unique supporting observations. On 2026-07-13 the owner directed "push through," accepting the remaining unmeasured live/hardware and manual `Cmd+R` risk without representing those checks or documented thresholds as passed. See `docs/product/companion-mode-privacy.md`, `docs/product/companion-mode-dogfood.md`, and `docs/product/pace-wake-word-classifier.md`.

## Actions, tools, integrations

- **Action Layer (agent mode)**: the planner should prefer `&lt;tool_calls&gt;` JSON blocks where the outer array is sequential steps and each inner array is a parallel group. Tool metadata lives in `PaceToolRegistry` so prompt docs, aliases, and risk labels share one source of truth; the local registry validates at app startup so schema examples, aliases, and enum coverage cannot silently drift. Legacy tags are still accepted: `[CLICK:x,y]`, `[DOUBLE_CLICK:x,y]`, `[TYPE:text]`, `[KEY:cmd+s]`, `[SCROLL:up:3]`, `[OPEN_APP:Safari]`, `[OPEN_URL:https://example.com]`, `[MUSIC:play]`, `[VOLUME:up:2]`, `[BRIGHTNESS:down:3]`, `[CALENDAR:today]`, and `[REMINDER:send invoice]`. `PaceActionTagParser` extracts them; `CompanionManager` asks for user approval only for higher-risk non-undoable or external actions when the `Approve Risky Actions` preference is on, and the approval alert defaults to Cancel. Routine local actions such as click, scroll, window snap, app/URL open, media, volume, brightness, clipboard read, and undo can execute without the popup; blocking preflight issues still surface before execution. `PaceActionApproval` keeps the allow/cancel decision testable; `PaceActionExecutor` posts events after TTS playback starts. Single-clicks try `PaceAXTargeter` first (AX-tree press), falling back to CGEvent. AX set-value edits are logged in a session-local mutation log and can be restored with `Undo.last` / "undo that". App launch/URLs use `NSWorkspace`; volume, brightness, media, and clipboard reads use local macOS APIs; Calendar/Reminders use EventKit for reads and creation. Mail recipient names can resolve through Contacts before composing a draft. Finder/Notes/Mail/Things/Shortcuts/Messages have first-pass local integrations. `download_file` downloads a user-named http(s) URL into `~/Downloads` with URL validation, filename sanitization, and Finder-style collision suffixes — always approval-gated. Gated by `EnableActions=true` in Info.plist. Runtime smoke hooks are disabled unless `PACE_ENABLE_SMOKE_HOOKS=1`.
- **Trust surfaces (visible undo + reply replay + failure narration)**: every reversible mutation (`createNote`, `appendNote`, `createReminder`, `createCalendarEvent`, `composeMail`, `createThingsToDo`, `runShortcut`, `downloadFile`, `recordFlow`/`runFlow`, `setTextValue`/`editSelectedText`, `openMessages` with body, and generic `mcp`) raises a 5-second floating `PaceUndoBanner` next to the cursor; tapping it submits `Undo.last` through the executor. After every assistant turn finishes speaking, the notch panel renders a 30-second reply-replay button driven by the same post-processed string that flowed through TTS — replay does NOT re-stream the planner. Plain-language failures (planner offline, missing permission, click missed, sidecar TTS offline, MCP server not configured, cloud-bridge upstream error) are composed deterministically by `PaceFailureNarrator` (no LLM call) and routed through `PaceRestraintGate.decide(...)` so failure speech stays silent during a Zoom/active call. Reversibility uses the same set as `PaceActionApprovalPolicy.actionIsReversibleMutation` returns true for, and the click-missed narrator is wired to the all-fail click observation in `PaceActionExecutor`.
- **Typed automation catalog**: Versioned manifests under `Resources/automations/` contain canonical Pace tool calls, provenance, execution mode, required preferences, invocation examples, and ordered step groups. The indexed 17-entry starter library covers Notes templates, Calendar reads, timers, Finder locations, current-window layouts, bounded media control, weekly review, and end-of-day reset. `PaceAutomationDefinitionLibrary` validates every manifest at startup against `PaceToolRegistry`, rejects input injection, network/download, destructive, nested-flow, and opaque Shortcut capabilities, and calls `PaceAutomationCompiler` to reject partial parses. The compiler feeds the existing `PaceActionTagParser`, so typed automations reuse normal preflight, approval, executor, observation, and audit behavior. `PaceAutomationCatalog` builds a non-persisted view over typed automations (`deterministicLocal`), bounded Pace Programs (`deterministicProgram`), recorded flows (`deterministicReplay`), skills (`plannerGrounded`), and installed Shortcuts (`externalOpaque`). Explicit list/run commands still resolve locally by unique exact normalized name. Ordinary completed text then uses exact authored invocation aliases or the local Nomic/Apple embedding chain; token overlap never authorizes execution. A unique semantic score above both the absolute threshold and winner-margin gate dispatches directly. Close candidates can be resolved by a fresh typed-output Apple Foundation Models session that runs fully on-device, may choose only from the embedding shortlist, validates an opaque candidate identifier, and can decline or ask for clarification. Unavailable models and weak or failed matches preserve the original planner fallthrough. Catalog metadata is never added to any off-device planner prompt or conversation memory.
- **Programmable teachable skills**: `PaceProgramDefinition` is a versioned declarative graph stored under `~/Library/Application Support/Pace/programs/`. It supports ordered typed-action steps, conditions over one pre-run snapshot (weekday, local hour, or frontmost bundle identifier), and literal repetition. The whole graph—including inactive branches—is validated before save, discovery, and each run. Fixed limits are nesting depth 4, 50 source nodes, repeat count 10, and 50 worst-case expanded action steps. Programs cannot contain Lua, JavaScript, shell, imports, network/file APIs, raw Accessibility operations, or branches on action output. The selected graph flattens into a transient typed definition and reuses `PaceAutomationCompiler` plus normal preflight, approval, executor, observation, and audit behavior; no model runs after catalog selection. “Teach a skill …” and “Create an automation …” share a privacy-pinned local authoring ladder: fixed typed automation first, bounded Pace Program second, planner-grounded `.skill.md` last. The local model is an authoring aid, never runtime authority.
- **Teachable skills**: `.skill.md` skills (`PaceSkillLoader`) are the natural-language sibling of typed automations, Pace Programs, and recorded flows — a numbered step list the planner **re-grounds each run** (intent tier), rather than compiling canonical tool calls (typed/program tier) or replaying recorded AX steps verbatim (flows, pixel tier). Bundled skills live in `Resources/skills/&lt;slug&gt;.skill.md`; user skills in `~/Library/Application Support/Pace/skills/`. **Teachable by telling** (PRD `docs/product/prds/teachable-skills.md`): explicit teach/create transcripts use the shared representation ladder above; Settings → Skills → "Teach a skill" remains an explicit planner-grounded prose form. `skillStructuringSystemPrompt` + `skillFromStructuredJSON` use the privacy-pinned LOCAL text planner, with `structureSkillDeterministically` as a no-model fallback. Stored triggers are active catalog invocation phrases. `PaceSkillLoader.save`/`deleteUserSkill`/`listUserSkills` use the atomic temp-file+rename pattern and reuse `PaceFlowStore.slug(for:)`. `PaceSkillsView` surfaces prose skills and deterministic programs in "Your skills." Fully on-device — authored reusable work never leaves the Mac.
- **MCP Integration Layer**: broad external integrations should use OSS Model Context Protocol servers instead of being rebuilt inside Pace. `PaceMCPClient` loads stdio server definitions from `~/.config/pace/mcp-servers.json` or `~/.pace/mcp-servers.json`, accepts common `mcpServers` config shape, runs MCP `initialize` + `tools/call`, and returns observations through the same approval/result loop. Planner MCP calls use either `{"tool":"mcp","server":"altic","name":"notes_create","arguments":{...}}` or `{"tool":"notes_search","server":"altic","query":"..."}` for server-native names. Missing configured server names are surfaced by `PaceToolPreflight` before approval. The bridge speaks newline-delimited JSON-RPC and is validated end-to-end by `PaceMCPClientIntegrationTests` against the in-repo `scripts/mcp-fixture-server.py`; `mcp-servers.example.json` curates popular OSS servers (filesystem, fetch, github, applescript) as ready-made connectors. **Bundled one-tap catalog** (Settings → MCP): `PaceMCPServerCatalog` ships a fixed four-server list (filesystem, fetch, applescript, composio) installable with a click; github/slack/linear now route through the composio OAuth bridge (`PaceMCPServerCatalog.supersededBySlug`). `PaceMCPCatalogInstaller.install/uninstall(_:into:)` performs an atomic JSON merge into `mcp-servers.json` — temp file + rename — that preserves every user-added entry. Catalog is bundled with each Pace release; no remote fetch of the server list at runtime.

## Observation, journals, proactivity

- **Watch mode**: `PaceScreenWatchModeController` is the explicit watch-loop primitive. It samples screenshots, uses `PaceScreenImageDiffer`, and emits typed events only when a screen has meaningful visual change (`majorScreenChange`, `contentUpdate`, `focusedRegionChange`). The companion panel exposes a `Watch Mode` toggle, and explicit voice commands such as "watch my screen" / "stop watching" route through `PaceWatchModeCommandParser` before the planner. Watch events also journal into the local retrieval index (`PaceScreenWatchJournal`, source `screenWatchHistory`) so "what did I do today?" questions answer from local history.
- **Time understanding (journals)**: two passive retrieval sources power Dayflow-style recall. The screen watch journal records watch-mode events (timestamp, change category, frontmost app, cached VLM description when fresh) into day-per-screen documents; the app usage journal (`PaceAppUsageTracker` + `PaceAppUsageJournal`, source `appUsageHistory`) tracks foreground app minutes and switch counts through permission-free NSWorkspace notifications even when watch mode is off. Both keep 7 days, dedupe/cap entries, rehydrate across restarts, and honor per-source enable/clear in Settings. A third recall source, `researchHistory` (`PaceResearchJournal`), captures each research-lane turn as a day-bucketed `{question, answer}` document (30-day/100-entry retention, same-day dedupe) — recorded by a strictly `isResearchTurn`-guarded, non-blocking, fire-once hook at the agent-loop turn finalization (question = original transcript, answer = final spoken text) — so "what did I research about X?" answers from history and the turns are browsable in Settings → Memory → Past research.
- **Proactive features (opt-in)**: the menu-bar app exposes a small set of proactive surfaces gated by `PaceRestraintGate` (active-call check, proactive cooldown, intent confidence). Posture watch (`PacePostureMonitor`), focus-fatigue nudges, calendar pre-meeting nudges, watch-mode observation nudges, and the daily weekday **morning brief** (`PaceMorningTriageScheduler` + `PaceMorningBriefBuilder`) all default OFF and become inert until the user enables them in Settings. When restraint says "stay quiet" the morning brief is parked on a panel card so the user never misses it.
- **On-device meeting notes**: `PaceMeetingModeController` (in `PaceSystemAudioCapture.swift`) captures mic + system audio as two separate tracks via `PaceMeetingAudioRecorder`, segments them into attributed turns via `PaceMeetingTurnSegmenter` (Accelerate RMS + hysteresis + echo trimming), transcribes each turn on-device via `PaceAudioFileTranscriber.transcribeAudioFileSegmented` (WhisperKit → Apple Speech fallback), synthesizes structured notes (summary + action items + decisions) via `PaceMeetingNotesBuilder` using a privacy-pinned LOCAL planner (`BuddyPlannerClientFactory.makeLocalOnlyPlannerForPrivacyPinnedFeatures()` — never the CLI bridge or Direct API tier, regardless of the user's tier selection), and journals them via `PaceMeetingNotesJournal` into the `meetingNotes` retrieval source so "what did we decide in standup?" answers from local history. Fully on-device — no cloud STT, no cloud LLM. The wedge against Granola/Otter/Fireflies (all cloud). Recording files use `.part` → atomic rename with RIFF-header crash repair. See PRD `docs/product/prds/on-device-meeting-notes.md` and ADR `decisions/0001-meeting-audio-capture.md`. **Adaptive note profiles** (meetily-informed, Pace-native — see `openspec/changes/archive/2026-07-12-adaptive-meeting-notes`): notes are shaped by a selectable `PaceMeetingNoteProfile` (bundled `Resources/meeting-note-profiles/&lt;slug&gt;.json` — general/standup/one-on-one — plus user overrides in `~/Library/Application Support/Pace/meeting-note-profiles/`, loaded/validated by `PaceMeetingNoteProfileLibrary`). The `general` profile renders byte-for-byte identical to the legacy prompt (compat anchor, default). Selection precedence: explicit selection (voice command or per-meeting panel pick) → non-general default preference → optional local inference (a silent on-device classify call, default OFF) → general. **Voice-triggered**: "start my one-on-one recording" / "record this standup" starts a meeting with that profile pinned — `PaceMeetingModeCommandParser` routes the profile name before the recording starts.
- **Deeplinks (`pace://`)**: external launchers (Raycast, Shortcuts) can trigger Pace via `pace://listen`, `pace://chat?text=...`, `pace://watch?enabled=true|false`, and `pace://panel`. `PaceDeepLinkParser` is a pure reject-on-ambiguity parser (500-char chat cap); commands are dropped unless the voice state is idle, and chat/listen turns run the normal intent/approval pipeline, so a deeplink can do nothing the user's own voice couldn't. URL types registered via `CFBundleURLTypes`; handled in `CompanionAppDelegate.application(_:open:)` with pre-launch buffering.

## Loop, routing, latency

- **Intent routing**: `PaceIntentClassifier` is a tiny rule-based local classifier. It routes chitchat to a canned response, pure-knowledge to a text-only planner path, and labels screen-read, tool-action, phone-large-model, and unknown turns for the full pipeline.
- **Plan-act-observe loop**: `CompanionManager.sendTranscriptToPlannerWithScreenshot` runs a multi-step loop. Each step re-screenshots, re-invokes the VLM (heuristic permitting) and the planner, executes grouped tool calls/actions, returns tool observations to the next planner step, and continues until the planner emits `[DONE]`, emits no tool calls/action tags, or hits `AgentMaxSteps` (default 8).
- **Speculative planner race (first step only)**: on a screen-action / screen-description FIRST step, when `CompanionManager.speculativeRaceShouldFire` passes (race toggle on, local VLM configured, Apple FM available), `performFirstStepSpeculativePlannerRace` runs `PaceSpeculativePlannerRace.raceSpeculative`: the in-process Apple FM "lite" planner (transcript + thread-memory only, NO VLM) races the full VLM-fed planner. The full path's VLM+OCR+AX prep is deferred into the race's lazy builder so it runs concurrently with lite — lite produces audio in ~150 ms while a cold VLM (2–3 s) is still resolving. Whichever streams first wins TTS; the full path supersedes the lite stream if its first token lands within 500 ms / 60 spoken chars (`prepareForSupersedingStreamWithinTurn` resets the pipeline so the new stream replays cleanly). **Action parsing ALWAYS uses the full planner's complete text, never lite** — the lite path is spoken-feedback only and can't emit a real action; when lite wins the audio, the lite text becomes the spoken/bubble/memory string so `flushFinal` can't double-speak. `bothFailed` routes to `PaceFailureNarrator`. Gate false (and every multi-step turn) keeps the single-planner path byte-identical. Toggle: `PaceUserPreferencesStore.enableSpeculativePlannerRace` (default ON, opt-out in Settings → Planner).
- **Walking avatar (optional)**: `PaceAvatarOverlay` paints a small SwiftUI character at the bottom of the cursor screen in its own tiny `NSPanel`, but it is not attached at app launch. The default always-visible surface is the menu-bar/notch capsule.

## Key architecture decisions

**Native Surface Pattern**: `PaceOnboardingWindowManager` hosts a versioned, resumable first-run state machine that migrates the legacy Boolean completion flag, consolidates live permission setup, and reaches one typed or spoken request through `submitChatTranscriptFromDeepLink`. A truthful response or completed action contracts the in-window signal toward a notch-shaped anchor before the window closes; failure and blocked outcomes do not play success. `PaceMenuBarOverlayManager` derives the closed Living Notch frame from `NSScreen.safeAreaInsets` and the two auxiliary top areas and matches the physical housing exactly at idle. Hover and active states add compact horizontal wings at menu-bar height, with state left of the reserved housing width and signal right of it. A deliberate click resizes that same clipped `NSPanel` equally left, right, and downward, embedding `PacePanelChatView` inside the surface instead of opening a visually joined second window. `MenuBarPanelManager` routes show, hide, shortcut, deep-link, and smoke-test requests into the integrated notch on supported displays while retaining the standalone panel fallback. The geometry recalculates after wake and display changes and draws nothing when macOS reports no housing. No visible `NSStatusItem` is created. `PaceWindowLayering` keeps the optional screen-edge activity glow below both the system menu bar and Living Notch while deliberate teaching annotations remain topmost; the glow defaults off and is transparent while idle. Delayed onboarding transitions are cancellable and generation-guarded so stale work cannot advance a newer scene.

**Command Center Pattern**: `PaceMainWindowManager` owns one normal resizable `NSWindow` for every durable management surface. `PaceCommandCenterRouter` groups primary destinations under Use Pace, Activity & Privacy, Customize, and Help, while optional integrations and developer controls use one searchable Advanced disclosure. It honors exact requested routes and restores the last valid selection. `PaceSettingsWindowManager` is a compatibility facade into the same window rather than a second host. The notch panel remains a present-turn surface; its gear routes to Customize → General. Pace still runs as `LSUIElement=true`, and the Command Center explicitly activates the app only when opened.

**Cursor Overlay**: A full-screen transparent `NSPanel` hosts the blue cursor companion. It's non-activating, joins all Spaces, and never steals focus. Cursor flight, response text placement, and pointing animations render in this overlay via SwiftUI through `NSHostingView`; listening/thinking animation lives in the notch bar.

**Global Push-To-Talk Shortcut**: Background push-to-talk uses a listen-only `CGEvent` tap instead of an AppKit global monitor so modifier-based shortcuts like `ctrl + option` are detected more reliably while the app is running in the background.

**Transient Cursor Mode**: When "Show Pace" is off, pressing the hotkey fades in the cursor overlay for the duration of the interaction (recording → response → TTS → optional pointing), then fades it out automatically after 1 second of inactivity.

## Local-mode setup

See [SETUP_LOCAL.md](https://github.com/HeyPace/pace/blob/main/SETUP_LOCAL.md) for the full recipe. The complete Info.plist switch reference (local VLM / planner / TTS-sidecar / transcription-provider knobs and their defaults) lives in [`../development/info-plist-switches.md`](../development/info-plist-switches.md).
