diff --git a/.planning/phases/30-hardware-detection-mode-selection/30-RESEARCH.md b/.planning/phases/30-hardware-detection-mode-selection/30-RESEARCH.md
new file mode 100644
index 00000000..708121be
--- /dev/null
+++ b/.planning/phases/30-hardware-detection-mode-selection/30-RESEARCH.md
@@ -0,0 +1,659 @@
+# Phase 30: Hardware Detection + Mode Selection - Research
+
+**Researched:** 2026-04-02
+**Domain:** Server-side hardware probing (Node.js `os` + `systeminformation` v5), unauthenticated Express route, Apple Silicon unified memory, model catalog extension, Nexus mode state, NexusOnboardingWizard multi-step preparation
+**Confidence:** HIGH
+
+## Summary
+
+Phase 30 is the foundation of the v1.5 onboarding stack. It adds four things: (1) an unauthenticated hardware probe endpoint that works before any board auth token exists; (2) Apple Silicon unified memory handling with the 0.75 multiplier and correct copy; (3) an extended model recommendation catalog keyed to hardware tier (GPU / Apple Silicon / CPU-only); and (4) a mode selector (Personal AI Assistant / Project Builder / Both) whose choice is persisted and gates downstream UI.
+
+The existing codebase has a solid base: `os.totalmem()` is already used in `ollamaRoutes` and `getRecommendedModel()`, the 0.75 multiplier is already applied in `getRecommendedModel()`, and the Ollama model catalog is an on-disk JSON file that can be extended. Two gaps need to be closed before the next phase: the probe endpoint for hardware detection must work without board auth (Pitfall 14 from PITFALLS.md), and there is no `nexus_mode` persistence layer yet.
+
+The state constraint is hard: no new DB tables. Mode is stored as a Nexus-namespaced key inside a new **file-backed JSON** at `data/nexus-settings.json` in the instance root, read/written by a new `nexusSettingsService`. This avoids touching the `.strict()` Zod schema on `instance_settings.general` (adding a key to that schema would require changes to both `@paperclipai/shared` and the routes — an unnecessary upstream conflict surface). The file-backed approach mirrors the `config.json` pattern already present in the project.
+
+**Primary recommendation:** Add `GET /api/system/providers` (unauthenticated) for hardware probe; create `server/src/services/hardware.ts` using `os` + `systeminformation@5` for GPU detection; extend `ollama-model-catalog.json` with hardware tier + PRD models; add `server/src/services/nexus-settings.ts` for file-backed mode persistence; build `ModeSelector` + `HardwareSummaryStep` as new onboarding step components.
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+All implementation choices are at Claude's discretion — discuss phase was skipped per user setting. Use ROADMAP phase goal, success criteria, and codebase conventions to guide decisions.
+
+### Claude's Discretion
+All implementation choices are at Claude's discretion.
+
+### Deferred Ideas (OUT OF SCOPE)
+None — discuss phase skipped.
+
+
+Additional locked decisions from STATE.md (established at roadmap):
+
+- No DB schema changes — all state in existing JSONB fields or file-backed JSON
+- Apple Silicon: use `os.freemem()` × 0.75 for VRAM estimate; label as "unified memory" not "VRAM"; use `systeminformation` v5 (not v6)
+- Unauthenticated `GET /system/providers` endpoint required for pre-auth hardware probe
+- Mode persisted in `instance_settings.general.nexus` namespace (ARCHITECTURE.md) — however, `.strict()` constraint means a file-backed alternative is safer (see below)
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| ONBD-01 | User can select mode (Personal AI Assistant / Project Builder / Both) during onboarding | New `ModeSelector` component in `NexusOnboardingWizard`; mode persisted via `nexusSettingsService` |
+| ONBD-02 | System auto-detects GPU, RAM, and Apple Silicon unified memory within 5 seconds | New `hardwareService` + `GET /api/system/providers` (unauthenticated); `systeminformation@5` for GPU on Linux/macOS; Apple Silicon flagged via CPU brand string |
+| ONBD-03 | System recommends best local model from pre-built JSON database based on detected hardware | Extend `ollama-model-catalog.json` with PRD models (Bonsai, Qwen 3) and tier field; update `getRecommendedModel()` to use hardware tier |
+| ONBD-07 | Local AI framed as privacy premium ("runs entirely on your machine, no accounts, works offline") | `HardwareSummaryStep` component renders PRD copy verbatim; copy is gated to local AI path only |
+
+
+---
+
+## Standard Stack
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| Node.js `os` | built-in | `totalmem()`, `freemem()`, `cpus()` for RAM + CPU brand | Already used in `ollamaRoutes`; zero-cost |
+| `systeminformation` | 5.31.5 (latest v5) | `graphics()` for GPU name + VRAM on Linux/macOS/Windows | STATE.md locked to v5 (not v6 — API breakage risk); not yet installed |
+| React | project version | UI components | Project standard |
+| Zod | project version | Schema validation for new settings | Already used throughout |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| `@tanstack/react-query` | project version | `useQuery` for hardware info hook | All other API queries use this |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| `systeminformation` | `system_profiler` shell out (macOS only) | `systeminformation` works cross-platform; shell out is macOS-only and adds timeout logic |
+| File-backed `nexus-settings.json` | `instance_settings.general.nexus` key | `.strict()` Zod schema blocks adding new keys without upstream changes; file-backed is safer for Nexus-only state |
+
+**Installation:**
+```bash
+pnpm --filter server add systeminformation@5
+```
+
+**Version verification:** `npm view systeminformation version` → `5.31.5` (confirmed 2026-04-02). v6 exists but STATE.md explicitly locks to v5.
+
+---
+
+## Architecture Patterns
+
+### Recommended Project Structure
+
+Changes for this phase:
+
+```
+server/src/
+├── services/
+│ ├── hardware.ts # NEW — hardwareService: detect GPU/RAM/Apple Silicon
+│ └── nexus-settings.ts # NEW — nexusSettingsService: file-backed mode persistence
+├── routes/
+│ └── hardware.ts # NEW — GET /api/system/providers (unauthenticated)
+├── data/
+│ └── ollama-model-catalog.json # MODIFIED — add tier field + PRD models (Bonsai, Qwen3)
+└── app.ts # MODIFIED — mount hardwareRoutes()
+
+ui/src/
+├── components/
+│ ├── NexusOnboardingWizard.tsx # MODIFIED — add mode selector step + hardware step
+│ └── onboarding/ # NEW directory
+│ ├── ModeSelector.tsx # NEW — Personal AI / Project Builder / Both cards
+│ └── HardwareSummaryStep.tsx # NEW — displays GPU/RAM/unified memory + model rec
+├── api/
+│ └── hardware.ts # NEW — typed fetch wrapper for /api/system/providers
+└── hooks/
+ └── useHardwareInfo.ts # NEW — useQuery wrapper for hardware data
+```
+
+### Pattern 1: Unauthenticated Hardware Probe Route
+
+**What:** `GET /api/system/providers` returns hardware detection results without requiring any auth. This is mounted **before** the `actorMiddleware` check, or with explicit no-auth bypass.
+**When to use:** During initial onboarding before any board auth exists.
+**Key insight:** In `local_trusted` deploymentMode, `actorMiddleware` already sets `req.actor = { type: "board" }` implicitly — so the probe works for free in local installs. For `authenticated` mode (fresh install before board claim), the probe must explicitly allow unauthenticated access, since `req.actor.type === "none"` until login.
+
+**Approach:** Mount a dedicated route before the `api` router that does NOT call `assertBoard`. Return an empty/safe result if hardware detection fails.
+
+```typescript
+// server/src/routes/hardware.ts
+import os from "node:os";
+import { Router } from "express";
+import { hardwareService } from "../services/hardware.js";
+
+export function hardwareRoutes() {
+ const router = Router();
+ const svc = hardwareService();
+
+ // Unauthenticated — intentional. Hardware is a property of the machine, not the user.
+ // Safe: returns read-only system info, no mutation, no secrets.
+ router.get("/system/providers", async (_req, res) => {
+ try {
+ const info = await svc.detect();
+ res.json(info);
+ } catch {
+ // Graceful degradation — return minimal safe info
+ res.json({
+ totalGb: Math.round(os.totalmem() / (1024 ** 3)),
+ freeGb: Math.round(os.freemem() / (1024 ** 3)),
+ platform: os.platform(),
+ gpuName: null,
+ gpuVramGb: null,
+ unifiedMemory: false,
+ hardwareTier: "cpu_only",
+ });
+ }
+ });
+
+ return router;
+}
+```
+
+**Mounting in app.ts** — add BEFORE `app.use("/api", api)`:
+```typescript
+// Unauthenticated probe — must come before the /api router (which requires actorMiddleware)
+app.use("/api", hardwareRoutes());
+```
+
+### Pattern 2: Hardware Detection Service
+
+**What:** `hardwareService` uses Node.js `os` for RAM and `systeminformation` v5 for GPU. Apple Silicon detection via CPU brand string.
+
+**Apple Silicon identification:** `os.cpus()[0].model` on M-series Macs returns `"Apple M1"`, `"Apple M4"`, etc. Check `process.platform === "darwin"` AND `cpuModel.startsWith("Apple")`.
+
+**Unified memory handling:** On Apple Silicon, `os.totalmem()` IS the unified memory (shared CPU+GPU). Use `os.freemem() * 0.75` as usable headroom (matching existing `getRecommendedModel` logic). Label as "unified memory" in UI, never "VRAM".
+
+```typescript
+// server/src/services/hardware.ts
+import os from "node:os";
+import si from "systeminformation";
+
+export type HardwareTier = "gpu" | "apple_silicon" | "cpu_only";
+
+export interface HardwareInfo {
+ totalGb: number;
+ freeGb: number;
+ usableGb: number; // freeGb * 0.75 — budget for model loading
+ platform: NodeJS.Platform;
+ gpuName: string | null;
+ gpuVramGb: number | null;
+ unifiedMemory: boolean; // true on Apple Silicon
+ hardwareTier: HardwareTier;
+ cpuModel: string | null;
+}
+
+export function hardwareService() {
+ let cache: HardwareInfo | null = null;
+ let cacheExpiry = 0;
+ const CACHE_TTL_MS = 5 * 60 * 1000;
+
+ async function detect(): Promise {
+ if (cache && Date.now() < cacheExpiry) return cache;
+
+ const totalBytes = os.totalmem();
+ const freeBytes = os.freemem();
+ const totalGb = totalBytes / (1024 ** 3);
+ const freeGb = freeBytes / (1024 ** 3);
+ const usableGb = freeGb * 0.75;
+ const cpuModel = os.cpus()[0]?.model ?? null;
+
+ const isAppleSilicon =
+ process.platform === "darwin" &&
+ (cpuModel?.startsWith("Apple") ?? false);
+
+ let gpuName: string | null = null;
+ let gpuVramGb: number | null = null;
+
+ if (!isAppleSilicon) {
+ try {
+ const graphics = await si.graphics();
+ const controller = graphics.controllers?.[0];
+ if (controller) {
+ gpuName = controller.model ?? null;
+ // si.graphics returns vram in MB
+ gpuVramGb = controller.vram ? controller.vram / 1024 : null;
+ }
+ } catch {
+ // systeminformation not available or GPU detection failed — graceful
+ }
+ }
+
+ let hardwareTier: HardwareTier;
+ if (isAppleSilicon) {
+ hardwareTier = "apple_silicon";
+ } else if (gpuVramGb && gpuVramGb >= 4) {
+ hardwareTier = "gpu";
+ } else {
+ hardwareTier = "cpu_only";
+ }
+
+ const result: HardwareInfo = {
+ totalGb: Math.round(totalGb * 10) / 10,
+ freeGb: Math.round(freeGb * 10) / 10,
+ usableGb: Math.round(usableGb * 10) / 10,
+ platform: process.platform,
+ gpuName,
+ gpuVramGb: gpuVramGb ? Math.round(gpuVramGb * 10) / 10 : null,
+ unifiedMemory: isAppleSilicon,
+ hardwareTier,
+ cpuModel,
+ };
+
+ cache = result;
+ cacheExpiry = Date.now() + CACHE_TTL_MS;
+ return result;
+ }
+
+ return { detect };
+}
+```
+
+### Pattern 3: Nexus Settings Service (File-Backed Mode Persistence)
+
+**What:** A new file-backed JSON service stores Nexus-specific settings (starting with `nexus_mode`) in `{instanceRoot}/data/nexus-settings.json`. This avoids modifying the `.strict()` Zod schema in `@paperclipai/shared`.
+
+**Why not `instance_settings.general`:** The schema at `packages/shared/src/validators/instance.ts` uses `.strict()`. Adding a new key would require changes in `@paperclipai/shared` (upstream package) to both the Zod schema and the TypeScript interface. That creates rebase conflict surface. File-backed JSON is identical to the existing `config.json` and `ollama-model-catalog.json` patterns.
+
+```typescript
+// server/src/services/nexus-settings.ts
+import fs from "node:fs";
+import path from "node:path";
+import { z } from "zod";
+import { resolvePaperclipInstanceRoot } from "../home-paths.js";
+
+export const NEXUS_MODES = ["personal_ai", "project_builder", "both"] as const;
+export type NexusMode = (typeof NEXUS_MODES)[number];
+
+const nexusSettingsSchema = z.object({
+ mode: z.enum(NEXUS_MODES).default("both"),
+});
+
+export type NexusSettings = z.infer;
+
+function resolveNexusSettingsPath(): string {
+ return path.resolve(resolvePaperclipInstanceRoot(), "data", "nexus-settings.json");
+}
+
+export function nexusSettingsService() {
+ function load(): NexusSettings {
+ const filePath = resolveNexusSettingsPath();
+ try {
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
+ return nexusSettingsSchema.parse(raw);
+ } catch {
+ return nexusSettingsSchema.parse({});
+ }
+ }
+
+ function save(settings: NexusSettings): void {
+ const filePath = resolveNexusSettingsPath();
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, JSON.stringify(settings, null, 2), "utf-8");
+ }
+
+ return {
+ get: () => load(),
+ set: (patch: Partial) => {
+ const current = load();
+ const next = nexusSettingsSchema.parse({ ...current, ...patch });
+ save(next);
+ return next;
+ },
+ };
+}
+```
+
+### Pattern 4: Extended Model Catalog with Hardware Tier
+
+**What:** The existing `ollama-model-catalog.json` needs: (a) PRD models added (Bonsai 1.7B, Qwen 3 8B), (b) a `tier` field per variant so the recommendation service can filter by `hardwareTier`.
+
+**Current catalog gap:** The catalog has `ramGb` and `vramGb` but no `tier` field. The success criteria require the model recommendation to match an entry "for the detected hardware tier (GPU / Apple Silicon / CPU-only)". The catalog must express this.
+
+**Approach:** Add an optional `tier` array to each variant: `"tier": ["gpu", "apple_silicon", "cpu_only"]` (if absent, variant is valid for all tiers). Also add the PRD models missing from current catalog: Bonsai 1.7B (hf.co/unsloth/Bonsai-1.7B-1M-GGUF or custom), Qwen 3 8B.
+
+Note: Bonsai (1-bit quantization) is listed in the PRD but may not be in the official Ollama registry under that name. Use the closest available name or add as a catalog-only entry with a `downloadUrl` field for future use. For Phase 30, the catalog is extended for recommendation display even if the model isn't pullable yet.
+
+### Pattern 5: Mode Selector UI Component
+
+**What:** `ModeSelector.tsx` presents three cards — Personal AI Assistant, Project Builder, Both (default) — using the existing shadcn/ui `Card` pattern. The selected mode is passed up to `NexusOnboardingWizard` and saved via a `POST /api/nexus/settings` call on wizard completion.
+
+```typescript
+// ui/src/components/onboarding/ModeSelector.tsx
+import { cn } from "@/lib/utils";
+
+type NexusMode = "personal_ai" | "project_builder" | "both";
+
+interface ModeSelectorProps {
+ value: NexusMode;
+ onChange: (mode: NexusMode) => void;
+}
+
+const MODES = [
+ {
+ id: "personal_ai" as NexusMode,
+ label: "Personal AI Assistant",
+ description: "Always available, persistent memory, private.",
+ },
+ {
+ id: "project_builder" as NexusMode,
+ label: "Project Builder",
+ description: "Brainstorm → PM → Engineer → shipped product.",
+ },
+ {
+ id: "both" as NexusMode,
+ label: "Both (recommended)",
+ description: "A conversation becomes a project with one click.",
+ },
+];
+
+export function ModeSelector({ value, onChange }: ModeSelectorProps) {
+ return (
+
+ {MODES.map((mode) => (
+
+ ))}
+
+ );
+}
+```
+
+### Pattern 6: Hardware Summary Step
+
+**What:** `HardwareSummaryStep.tsx` calls `GET /api/system/providers`, renders detected hardware, and shows the local AI privacy frame from the PRD.
+
+**ONBD-07 copy requirement** (PRD verbatim, display when local AI is viable):
+```
+Local AI (recommended for privacy)
+Runs entirely on your machine.
+No accounts. No tracking. Works offline.
+```
+
+Display rules:
+- Apple Silicon: show unified memory GB, use "unified memory" label (not VRAM)
+- GPU: show GPU name + VRAM, label as "GPU VRAM"
+- CPU-only: show RAM, warn "slower than GPU-accelerated models", recommend cloud
+
+### Anti-Patterns to Avoid
+
+- **Gating `GET /system/providers` on board auth:** This creates the Pitfall 14 failure — fresh install gets 403, hardware probe silently fails, wizard shows wrong defaults.
+- **Using `os.totalmem()` directly as "available for models":** On Apple Silicon, the OS + apps consume 30–40% of unified memory. Always apply 0.75 multiplier to `freemem()` (not `totalmem()`).
+- **Adding `nexus` key to `instanceGeneralSettingsSchema`:** The schema uses `.strict()` — any extra key throws a Zod validation error. Use the file-backed service instead.
+- **Reporting Apple Silicon VRAM as a separate number from RAM:** Apple M-series chips have unified memory. Do not report `gpuVramGb` for Apple Silicon — set it to `null`, set `unifiedMemory: true`, and use `totalGb`/`usableGb` for recommendations.
+- **Using `systeminformation` v6:** STATE.md explicitly locks to v5. v6 has breaking changes.
+- **Including Bonsai models in the Ollama recommendation if they are not in the Ollama registry:** The catalog can list them for display, but the recommendation engine should only mark a model `recommended: true` if it can actually be pulled via Ollama.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| GPU name + VRAM detection | Custom `/proc/nvidia-smi` or WMI parsing | `systeminformation@5 si.graphics()` | Cross-platform, handles NVIDIA/AMD/Intel; si already handles platform differences |
+| RAM detection | Any third-party RAM library | `os.totalmem()` + `os.freemem()` | Built-in, zero deps, accurate |
+| Mode persistence as new DB table | Drizzle migration + new table | `nexusSettingsService` file-backed JSON | No DB schema changes constraint; file pattern already established |
+| Model recommendation filtering | Custom tier logic | Extend existing `getRecommendedModel()` | Logic already correct; add tier filter as one additional condition |
+| Onboarding step components | Monolithic wizard with inline UI | Sub-components in `ui/src/components/onboarding/` | ARCHITECTURE.md established this pattern; Phase 32 adds more steps |
+
+**Key insight:** The hardware probe and model catalog are the only genuinely new functionality. Mode persistence is a simple file write. Most of the work is wiring existing pieces together correctly and avoiding the auth pitfall.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: Hardware Probe Blocked by Board Auth (Pitfall 14 from PITFALLS.md)
+**What goes wrong:** Fresh install, no board auth token yet. `GET /api/system/providers` returns 403. Wizard falls back to `cpu_only` tier for model recommendation. Mac Mini M4 user is told to use cloud because GPU/unified memory was not detected.
+**Why it happens:** All routes under `/api` in `app.ts` are mounted behind `actorMiddleware`. In `authenticated` deploymentMode, `req.actor.type === "none"` for unauthenticated requests.
+**How to avoid:** Mount the hardware route with `app.use("/api", hardwareRoutes())` **before** `app.use("/api", api)` in app.ts. In the route handler, do NOT call `assertBoard` or check `req.actor`. The route returns read-only machine information only.
+**Warning signs:** Browser network tab shows 403 on `/api/system/providers` during onboarding.
+
+### Pitfall 2: Apple Silicon Reported as "0 GB VRAM"
+**What goes wrong:** `systeminformation` on macOS with Apple Silicon may return `vram: 0` for the GPU controller because there is no discrete VRAM chip — the GPU uses system RAM. The UI shows "0 GB VRAM" or model recommendation uses the wrong memory figure.
+**Why it happens:** `si.graphics()` returns `vram: 0` for Apple Silicon integrated GPU. This is technically correct but misleading for model recommendations.
+**How to avoid:** When `isAppleSilicon` is true, do not call `si.graphics()` at all. Set `gpuVramGb: null`, `unifiedMemory: true`. The recommendation engine uses `usableGb` (from `freemem() * 0.75`) instead of `gpuVramGb`.
+**Warning signs:** UI shows "GPU VRAM: 0 GB" on an M4 Mac Mini.
+
+### Pitfall 3: `.strict()` Schema Blocks Nexus Mode Persistence via instance_settings
+**What goes wrong:** Attempt to store `mode` in `instance_settings.general.nexus` fails with a Zod validation error because the schema is `z.object({ censorUsernameInLogs: z.boolean() }).strict()`. Any key not in the schema is rejected.
+**Why it happens:** The shared package uses `.strict()` on both general and experimental settings schemas to prevent accumulation of unknown keys in the DB.
+**How to avoid:** Use `nexusSettingsService` (file-backed JSON at `{instanceRoot}/data/nexus-settings.json`). Add a `GET /api/nexus/settings` and `PATCH /api/nexus/settings` route. These ARE board-auth-gated (mode setting happens after the user is set up).
+**Warning signs:** Server logs a Zod error when the wizard tries to save mode; `updateGeneral()` silently discards the `nexus` key.
+
+### Pitfall 4: Model Catalog Recommends Bonsai but Ollama Cannot Pull It
+**What goes wrong:** The PRD lists "Bonsai 1.7B (1-bit)" as a model. If added to the catalog with `name: "bonsai:1.7b"` and Ollama has no such model, `getRecommendedModel()` never finds a match (it only marks models the user already has installed as recommended). But if the catalog is used to generate a "we suggest pulling this" recommendation before pull, a non-pullable name breaks the Ollama pull command.
+**Why it happens:** The PRD model list mixes "models in Ollama registry" with "models we wish were in Ollama registry". Bonsai 1-bit quantization may only be available via a Hugging Face GGUF, not via `ollama pull`.
+**How to avoid:** For Phase 30, add Bonsai as a catalog entry with a `source: "huggingface"` flag (or just omit it from the recommendation engine). The catalog is displayed to users but the `getRecommendedModel()` function only recommends models the user has already pulled. Phase 30 does not need to pull models — just display what the hardware can run.
+**Warning signs:** `ollama pull bonsai:1.7b` returns 404; recommendation shows models with pull errors.
+
+### Pitfall 5: 5-Second Timeout Not Met Due to `si.graphics()` on Linux
+**What goes wrong:** The success criterion requires the probe to return within 5 seconds. On Linux, `si.graphics()` may shell out to `lspci` or `nvidia-smi`. If those commands are not installed or produce slow output, the probe hangs.
+**Why it happens:** `systeminformation` uses platform-specific shell commands as fallback on Linux for GPU detection. Slow GPU drivers or missing `lspci` cause timeouts.
+**How to avoid:** Wrap `si.graphics()` in a `Promise.race()` with a 3-second timeout abort. If it times out, return `gpuName: null, gpuVramGb: null, hardwareTier: "cpu_only"` and continue. The 5-second budget for the overall probe response is achievable even with a 3-second GPU probe.
+**Warning signs:** `/api/system/providers` takes 6–10 seconds on Linux; `hardwareTier` always shows `cpu_only` even when a GPU is present.
+
+### Pitfall 6: NexusOnboardingWizard Drift from Upstream OnboardingWizard
+**What goes wrong:** Phase 30 extends `NexusOnboardingWizard.tsx` with new steps. Upstream adds new props or context dependencies to `OnboardingWizard.tsx`. After the next upstream rebase, `NexusOnboardingWizard.tsx` silently misses those changes.
+**Why it happens:** Vite alias `src/components/OnboardingWizard` → `NexusOnboardingWizard` fully replaces the upstream component. Any upstream improvement is silently discarded.
+**How to avoid:** Phase 30 modifications to `NexusOnboardingWizard.tsx` must maintain the same export signature as `OnboardingWizard.tsx`. After each upstream rebase, diff `OnboardingWizard.tsx` for new hook usage.
+**Warning signs:** `pnpm dev` fails with "cannot find module" after rebase; wizard missing features added to upstream.
+
+---
+
+## Code Examples
+
+Verified patterns from existing codebase:
+
+### Existing RAM + Recommendation Pattern (confirm before extending)
+```typescript
+// server/src/services/ollama.ts (existing, confirmed)
+export function getRecommendedModel(models: OllamaModel[], systemRamBytes: number): OllamaModel[] {
+ const usableRamGb = (systemRamBytes / (1024 * 1024 * 1024)) * 0.75;
+ // ... catalog-based matching ...
+}
+// Called in ollamaRoutes.ts:
+const enrichedModels = getRecommendedModel(models, os.totalmem());
+// NOTE: Phase 30 updates this to use os.freemem() for Apple Silicon path
+```
+
+### Mounting Unauthenticated Routes Before the Protected api Router
+```typescript
+// server/src/app.ts (MODIFIED pattern — add before app.use("/api", api))
+// Source: existing health route pattern (health is also accessible without deep auth)
+app.use("/api", hardwareRoutes()); // unauthenticated — must come first
+app.use("/api", api); // authenticated api router
+```
+
+### File-Backed JSON Service Pattern
+```typescript
+// Source: config-file.ts + ollama.ts catalog load pattern (confirmed in codebase)
+import fs from "node:fs";
+import path from "node:path";
+import { resolvePaperclipInstanceRoot } from "../home-paths.js";
+
+function resolveNexusSettingsPath(): string {
+ return path.resolve(resolvePaperclipInstanceRoot(), "data", "nexus-settings.json");
+}
+```
+
+### systeminformation v5 Graphics Call
+```typescript
+// Source: systeminformation v5 npm docs (verified: npm view systeminformation version → 5.31.5)
+import si from "systeminformation";
+
+const graphics = await si.graphics();
+// graphics.controllers[0].model → GPU name string
+// graphics.controllers[0].vram → VRAM in MB (integer)
+// Returns empty array if no GPU detected
+```
+
+### NexusMode Constants (shared between server + UI)
+```typescript
+// server/src/services/nexus-settings.ts
+export const NEXUS_MODES = ["personal_ai", "project_builder", "both"] as const;
+export type NexusMode = (typeof NEXUS_MODES)[number];
+
+// UI: ui/src/api/hardware.ts
+export type NexusMode = "personal_ai" | "project_builder" | "both";
+// (duplicated in UI since @paperclipai/shared is upstream-owned)
+```
+
+### Extended Model Catalog JSON
+```json
+// server/src/data/ollama-model-catalog.json (MODIFIED — add tier + PRD models)
+{
+ "models": [
+ {
+ "family": "qwen2",
+ "variants": [
+ { "name": "qwen2.5-coder:7b", "ramGb": 5, "vramGb": 5, "quality": "fast", "tier": ["gpu", "apple_silicon", "cpu_only"] },
+ { "name": "qwen2.5-coder:14b", "ramGb": 10, "vramGb": 10, "quality": "balanced", "tier": ["gpu", "apple_silicon"] },
+ { "name": "qwen2.5-coder:32b", "ramGb": 22, "vramGb": 22, "quality": "best", "tier": ["gpu"] }
+ ]
+ },
+ {
+ "family": "qwen3",
+ "variants": [
+ { "name": "qwen3:8b", "ramGb": 5, "vramGb": 5, "quality": "balanced", "tier": ["gpu", "apple_silicon", "cpu_only"] }
+ ]
+ },
+ {
+ "family": "llama",
+ "variants": [
+ { "name": "llama3.2:3b", "ramGb": 3, "vramGb": 3, "quality": "fast", "tier": ["gpu", "apple_silicon", "cpu_only"] },
+ { "name": "llama3.1:8b", "ramGb": 6, "vramGb": 6, "quality": "balanced", "tier": ["gpu", "apple_silicon", "cpu_only"] },
+ { "name": "llama3.1:70b", "ramGb": 48, "vramGb": 48, "quality": "best", "tier": ["gpu"] }
+ ]
+ }
+ ]
+}
+```
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| Ollama routes require companyId (no pre-auth probe) | New `GET /api/system/providers` requires no auth | Phase 30 (this phase) | Enables pre-auth hardware detection |
+| `getRecommendedModel` uses `totalmem()` only | Use `freemem() * 0.75` for Apple Silicon, `totalmem() * 0.75` for GPU/CPU | Phase 30 | More accurate for loaded systems |
+| Single-step `NexusOnboardingWizard` | Multi-step with `ModeSelector` + `HardwareSummaryStep` | Phase 30 | Foundation for Phase 32 full wizard |
+| Model catalog: no tier field | Catalog has `tier` array per variant | Phase 30 | Enables tier-filtered recommendations |
+
+**Deprecated/outdated:**
+- `getRecommendedModel()` calling `os.totalmem()` directly — Phase 30 changes the call site to pass `os.freemem()` for Apple Silicon path; existing behavior preserved for non-Apple-Silicon.
+
+---
+
+## Open Questions
+
+1. **Is `qwen3:8b` available in Ollama as of April 2026?**
+ - What we know: Qwen 3 is listed in the PRD. Qwen 2.5 is in the current catalog. The Ollama registry is a moving target.
+ - What's unclear: Whether the exact model tag is `qwen3:8b` or something else.
+ - Recommendation: Add `qwen3:8b` to catalog with a note that the tag should be verified against the Ollama registry at ship time. The recommendation engine only marks models the user has pulled as recommended — a wrong tag just means the model won't be auto-recommended until the user pulls it.
+
+2. **Should the Nexus settings route (`PATCH /api/nexus/settings`) be board-auth-gated?**
+ - What we know: Mode selection happens during onboarding. In `local_trusted` mode, board auth is always present. In `authenticated` mode, the user has logged in by the time they see the wizard.
+ - Recommendation: Yes, gate on board auth. The hardware probe is unauthenticated; mode persistence is not. The wizard saves mode on the final wizard-complete action, not on mode card click.
+
+3. **Does the mode selector need to appear in settings post-onboarding?**
+ - What we know: ROADMAP success criteria say the mode is "persisted" and "assistant-specific UI is hidden when Project Builder-only is chosen."
+ - What's unclear: Whether Phase 30 needs a settings page entry point or just onboarding.
+ - Recommendation: Phase 30 delivers mode selection in the onboarding wizard only. A settings page entry point is deferred to Phase 33 (which introduces `PersonalAssistantPage` and mode-gated UI).
+
+---
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Node.js `os` | RAM/CPU detection | ✓ | built-in | — |
+| `systeminformation` | GPU name + VRAM | ✗ (not installed) | 5.31.5 (latest v5) | Omit GPU name, return `null`, tier defaults to `cpu_only` |
+| `system_profiler` (macOS only) | Apple Silicon GPU model | ✓ on macOS, ✗ on Linux | macOS built-in | Use CPU brand string alone |
+| React | UI components | ✓ | project version | — |
+| Zod | Settings schema | ✓ | project version | — |
+| shadcn/ui `Card`, `Button` | ModeSelector UI | ✓ | project version | — |
+
+**Missing dependencies with no fallback:**
+- None that block execution. `systeminformation` absence degrades gracefully to `cpu_only` tier.
+
+**Missing dependencies with fallback:**
+- `systeminformation`: probe route gracefully omits GPU data if detection fails; hardware tier becomes `cpu_only`; model recommendation still works using RAM budget.
+
+---
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Vitest |
+| Config file | `server/vitest.config.ts` |
+| Quick run command | `pnpm --filter server test --run` |
+| Full suite command | `pnpm --filter server test --run && pnpm --filter ui test --run` |
+
+### Phase Requirements → Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| ONBD-02 | `hardwareService.detect()` returns `unifiedMemory: true` when CPU brand is "Apple M4" | unit | `pnpm --filter server test --run -- 30-hardware-detection` | ❌ Wave 0 |
+| ONBD-02 | `hardwareService.detect()` returns `hardwareTier: "cpu_only"` when no GPU detected | unit | `pnpm --filter server test --run -- 30-hardware-detection` | ❌ Wave 0 |
+| ONBD-02 | `GET /api/system/providers` returns 200 without board auth (unauthenticated request) | unit | `pnpm --filter server test --run -- 30-hardware-detection` | ❌ Wave 0 |
+| ONBD-02 | Probe returns within 5 seconds even when `si.graphics()` is unavailable | unit | `pnpm --filter server test --run -- 30-hardware-detection` | ❌ Wave 0 |
+| ONBD-03 | Extended catalog contains `qwen3:8b` and `tier` field | unit | `pnpm --filter server test --run -- 30-hardware-detection` | ❌ Wave 0 |
+| ONBD-03 | `getRecommendedModel()` with `gpu` tier only recommends GPU-tier models | unit | `pnpm --filter server test --run -- 30-hardware-detection` | ❌ Wave 0 |
+| ONBD-01 | `nexusSettingsService.set({ mode: "personal_ai" })` persists and is readable | unit | `pnpm --filter server test --run -- 30-hardware-detection` | ❌ Wave 0 |
+| ONBD-07 | `HardwareSummaryStep` renders privacy copy when tier is not `cpu_only` | unit (React Testing Library or Vitest) | `pnpm --filter ui test --run -- HardwareSummaryStep` | ❌ Wave 0 |
+
+### Sampling Rate
+- **Per task commit:** `pnpm --filter server test --run`
+- **Per wave merge:** `pnpm --filter server test --run && pnpm --filter ui test --run`
+- **Phase gate:** Full suite green before `/gsd:verify-work`
+
+### Wave 0 Gaps
+- [ ] `server/src/__tests__/30-hardware-detection.test.ts` — covers ONBD-01, ONBD-02, ONBD-03 server-side
+- [ ] `ui/src/components/onboarding/HardwareSummaryStep.test.tsx` — covers ONBD-07 copy render
+
+---
+
+## Sources
+
+### Primary (HIGH confidence)
+- `/opt/nexus/server/src/services/ollama.ts` — existing `getRecommendedModel()`, 0.75 multiplier, `os.totalmem()` usage (confirmed)
+- `/opt/nexus/server/src/routes/ollama.ts` — existing company-scoped ollama routes; confirmed no unauthenticated pattern
+- `/opt/nexus/server/src/middleware/auth.ts` — `actorMiddleware` behavior in `local_trusted` vs `authenticated` mode (confirmed)
+- `/opt/nexus/server/src/app.ts` — route mounting order; confirmed `/api` router structure (confirmed)
+- `/opt/nexus/server/src/services/instance-settings.ts` — `updateGeneral()` uses `.strict()` schema; adding new keys would fail (confirmed)
+- `/opt/nexus/packages/shared/src/validators/instance.ts` — `.strict()` confirmed on line 5
+- `/opt/nexus/server/src/home-paths.ts` — `resolvePaperclipInstanceRoot()` for file-backed JSON path (confirmed)
+- `/opt/nexus/server/src/data/ollama-model-catalog.json` — current catalog structure (confirmed; no tier field, no Bonsai/Qwen3)
+- `/opt/nexus/ui/src/components/NexusOnboardingWizard.tsx` — current single-step wizard; mode selector is absent (confirmed)
+- `/opt/nexus/.planning/STATE.md` — locked decisions: `systeminformation` v5, `freemem() * 0.75`, `GET /system/providers` unauthenticated
+- `/opt/nexus/.planning/research/ARCHITECTURE.md` — component map, `hardwareService` design, `nexus` namespace in instance settings (confirmed architecture intent)
+- `/opt/nexus/.planning/research/PITFALLS.md` — Pitfall 13 (Apple Silicon VRAM), Pitfall 14 (probe auth level)
+- `npm view systeminformation version` → `5.31.5` (confirmed current latest v5)
+
+### Secondary (MEDIUM confidence)
+- `/home/mikkel/upload/nexus-v1.5-prd-onboarding-assistant.md` — PRD model list (Bonsai, Qwen 3, tier scenarios), ONBD-07 copy requirement
+- `systeminformation` v5 npm documentation — `si.graphics()` returns `controllers[].vram` in MB
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — `os` built-in confirmed; `systeminformation` version confirmed via npm; not yet installed (needs `pnpm add`)
+- Architecture: HIGH — all integration points confirmed via direct codebase reading; `.strict()` schema trap confirmed
+- Pitfalls: HIGH — all identified from direct code reading and confirmed PITFALLS.md analysis
+
+**Research date:** 2026-04-02
+**Valid until:** 2026-05-02 (stable domain; `systeminformation` API stable in v5)