# 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-only (`LSUIElement=true`), no dock icon or main window
- **Framework**: SwiftUI (macOS native) with AppKit bridging for menu-bar panel, notch capsule 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**: Right-end sound animation in `PaceMenuBarOverlay.swift`; active voice turns show audio-reactive bars in the right icon slot, or static active-state bars when macOS Reduce Motion is enabled. The old cursor-local voice pill is retained as a reusable view but is not the active conversation surface.
- **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`.
- **Recipe library**: Pace ships a small library of pre-built `PaceRecordedFlow` definitions ("recipes") under `Resources/recipes/&lt;slug&gt;.json` — morning-standup-setup, weekly-review-draft, email-zero, focus-mode-on, end-of-day-shutdown. Voice commands ("install the morning standup recipe") or the Settings → Flows tab copy a recipe into `PaceFlowStore` so the existing `run_flow` tool can execute it. `PaceRecipeLibrary` validates bundled JSON at startup via `PaceToolRegistry.validateForAppStartup`; `PaceRecipeCommandParser` routes install/uninstall/list voice commands before the planner. Recipes that need user state (e.g. preferred focus playlist) declare `requiredPreferences` and refuse install with a clear "set this preference first" message.
- **Teachable skills**: `.skill.md` skills (`PaceSkillLoader`) are the natural-language sibling of recipes/flows — a numbered step list the planner **re-grounds each run** (intent tier), rather than 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`): "teach a skill …" (parsed by `PaceSkillCommandParser.create`, checked before list/install/run) or Settings → Skills → "Teach a skill" form. Voice descriptions are structured into a `PaceSkillFile` by the privacy-pinned LOCAL planner (`makeLocalOnlyPlannerForPrivacyPinnedFeatures()`, same on-device pin as meeting notes) via `skillStructuringSystemPrompt` + `skillFromStructuredJSON`, with `structureSkillDeterministically` as a no-model fallback so teaching never hard-fails; the typed form uses `skillFromForm` (deterministic). `PaceSkillLoader.save`/`deleteUserSkill`/`listUserSkills` use the atomic temp-file+rename pattern and reuse `PaceFlowStore.slug(for:)`. `PaceSkillsView` surfaces taught skills in a "Your skills" section. Fully on-device — a taught skill 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

**Menu Bar Panel Pattern**: `PaceMenuBarOverlayManager` owns the visible black menu-bar/notch capsule in a top-level non-activating `NSPanel`. `MenuBarPanelManager` owns the floating companion panel and anchors it to the notch overlay frame, with a centered top-of-screen fallback for launch-time onboarding. No visible `NSStatusItem` is created. This gives full control over appearance and avoids the standard macOS menu/popover chrome. The panel is non-activating so it doesn't steal focus. A global event monitor auto-dismisses it on outside clicks.

**Settings Window Pattern**: `PaceSettingsWindowManager` owns a normal titled `NSWindow` for configuration that has outgrown the notch panel: MCP server config, permissions, voice, preferences, memory (incl. a Past-research history list), scheduled tasks (the Tasks tab lists `PaceCronScheduler` recurring tasks with last-run + delete), and action history. The notch panel keeps a gear button that opens this window. Pace still runs as `LSUIElement=true`; the settings window is AppKit-managed and explicitly activates the app when shown.

**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).
