plans/2026-04-30-onboarding-ux-overhaul.md
Three surfaces, one product voice, one first-success moment. Each phase is self-contained and can be executed in a fresh chat with /do.
Pull the user toward this single moment: open the viewer in a browser, do anything in Claude Code, watch an observation appear within seconds. All three surfaces aim at it from different angles.
bun test. Test command: npm run test. Tests live in tests/. Pattern templates: tests/sqlite/observations.test.ts:1-60 (in-memory SQLite + bun:test), tests/install-non-tty.test.ts:1-95 (regex assertions over install.ts source).npm run build-and-sync runs full build (banner frames + plugin manifests + scripts/build-hooks.js) → marketplace sync → worker restart. Viewer compiles via esbuild to plugin/ui/viewer-bundle.js; HTML template (which holds ALL CSS) at src/ui/viewer-template.html.src/shared/SettingsDefaultsManager.ts:70-131. Merge logic at loadFromFile() lines 161-205 — missing keys auto-pick up new defaults, explicit values are respected. Forward-compatible.CLAUDE_MEM_WELCOME_HINT_ENABLED already defaults to 'true' (SettingsDefaultsManager.ts:104). Single reader at SearchRoutes.ts:294. Goal 5 from the brief is already done — we replace "flip the default" with "pin it with a regression test."Memory injection starts on your second session in a project.Everything stays in ~/.claude-mem on this machine.Discovery already completed. Allowed APIs and signatures established:
src/npx-cli/commands/install.ts)log helper at lines 41-46 — methods info | success | warn | error, conditionally routes to p.log.* (interactive) vs console.log/warn/error (non-interactive, 2-space indent).p is * as p from '@clack/prompts'. Used: p.note(body, title), p.outro(msg), p.intro, p.log.*, p.tasks, p.spinner, p.select/multiselect/confirm/password, p.isCancel, p.cancel.pc is picocolors default import. Available: pc.cyan/green/yellow/red/bold/underline/dim/bgCyan/black. pc.dim exists (already in use at line 663).getSetting('CLAUDE_MEM_WORKER_PORT') returns string; convert with Number() when needed.fetch('http://127.0.0.1:${port}/api/health', { signal: AbortSignal.timeout(3000) }), non-throwing.summaryLines block (826-841) and nextSteps block (866-896) — both have parallel interactive (p.note) and non-interactive (console.log) branches.src/shared/SettingsDefaultsManager.ts)CLAUDE_MEM_WELCOME_HINT_ENABLED: 'true' at line 104.SettingsRoutes.ts:84-117 — flag is NOT in the user-updatable allowlist (read-only via UI).tests/install-non-tty.test.ts (regex over source); SettingsDefaultsManager has no dedicated test file — would be created if needed.src/services/worker/http/routes/SearchRoutes.ts)WELCOME_HINT_TEMPLATE at lines 14-27. Used at line 301: WELCOME_HINT_TEMPLATE.replace('{viewer_url}', viewerUrl).hintEnabled && !full && observationCount === 0.additionalContext via the SessionStart hook (src/cli/handlers/context.ts).src/ui/viewer/)useSSE() at src/ui/viewer/hooks/useSSE.ts:1-148 exposes { observations, summaries, prompts, projects, sources, projectsBySource, isProcessing, queueDepth, isConnected }. Auto-reconnects; new observations prepended via 'new_observation' SSE event.WelcomeCard mounted in src/ui/viewer/App.tsx:128-130, currently receives only onDismiss. App has access to all SSE state (lines 51-67).src/ui/viewer-template.html; existing .welcome-card* styles at lines 1443-1561; existing .status-dot + @keyframes pulse at lines 754-764./api/stats (DataRoutes.ts:204-242) returns {database: {observations, sessions, summaries}, worker: {...}}. /api/projects (DataRoutes.ts:244-260) returns ProjectCatalog. No firstObservationAt field currently — Phase 4 adds it./api/how-it-works is NOT a static explainer — it queries observations tagged with the 'how-it-works' concept (SearchManager.ts:836-884). Useless on a fresh install. Phase 1 adds a true static explainer.plugin/skills/)SKILL.md and YAML frontmatter (name, description). No central registry — discovered by directory convention.plugin/skills/mem-search/SKILL.md./api/how-it-works for an onboarding explainer — wrong endpoint.src/ui/viewer-template.html./api/stats instead.CLAUDE_MEM_WELCOME_HINT_ENABLED in install.ts — defaults already handle it./learn-codebase is available if…").Why: All three surfaces need a single source of truth for the 90-second "what is this" explainer. /api/how-it-works does not serve this purpose. We'll create a real static explainer and link to it from everywhere.
Create src/services/worker/onboarding-explainer.md — single canonical content. ~150 words, three sections:
Add new route GET /api/onboarding/explainer in src/services/worker/http/routes/SearchRoutes.ts:
cachedSkillMd pattern in Server.ts:18-33).text/markdown; charset=utf-8.setupRoutes() next to the other /api/context/* routes.Create plugin/skills/how-it-works/SKILL.md:
plugin/skills/mem-search/SKILL.md:1-4.name: how-it-worksdescription: Explain how claude-mem captures observations, when memory injection kicks in, and where data lives. Use when the user asks "how does claude-mem work?" or "what is this thing doing?"./api/onboarding/explainer at runtime).scripts/build-hooks.js verification list (lines 336-348) so build fails if the file is missing.npm run build-and-sync succeeds; new SKILL.md present in plugin/skills/how-it-works/.curl http://localhost:$PORT/api/onboarding/explainer returns the markdown./api/how-it-works. It serves a different (concept-tagged search) purpose.Why: Current copy reads as a marketing intercept inside Claude's context, leads with imperatives Claude tries to execute, and doesn't set the truthful "today seeds, tomorrow injects" expectation.
Rewrite WELCOME_HINT_TEMPLATE at src/services/worker/http/routes/SearchRoutes.ts:14-27. Target:
# claude-mem status
This project has no memory yet. The current session will seed it; subsequent sessions will receive auto-injected context for relevant past work.
Memory injection starts on your second session in a project.
`/learn-codebase` is available if the user wants to front-load the entire repo into memory in a single pass (~5 minutes on a typical repo, optional). Otherwise memory builds passively as work happens.
Live activity: {viewer_url}
How it works: `/how-it-works`
This message disappears once the first observation lands.
Constraints: third-person narration referring to "the user", not imperatives directed at Claude. Title is "status", not "Welcome".
Pin the default with a test. In a new file tests/shared/welcome-hint-default.test.ts:
SettingsDefaultsManager.getAllDefaults().CLAUDE_MEM_WELCOME_HINT_ENABLED === 'true'.'true'.'false' is preserved through loadFromFile.No install.ts seeding change — defaults already flow through.
Audit existing welcome-hint tests (memory note: "4/4 tests pass"). Likely in tests/worker/SearchManager.timeline-anchor.test.ts per discovery; if those tests assert the old template body verbatim, update them to match the new copy. If they only assert the gating logic, leave alone.
bun test tests/shared/welcome-hint-default.test.ts passes.bun test tests/worker/ (or whichever file holds the welcome-hint tests) passes.{viewer_url} and /how-it-works.Why: Current 4-bullet menu treats /learn-codebase, /mem-search, and /knowledge-agent as parallel options. They aren't — /learn-codebase is the only first-session move and even it's optional. Lead with proof (live viewer), give two paths, defuse the privacy concern.
Replace the nextSteps array at src/npx-cli/commands/install.ts:866-878. Target body when worker is ready:
${pc.green('✓')} Worker running at ${pc.underline(`http://localhost:${actualPort}`)}
${pc.bold('First success:')} keep that URL open in a browser, then open Claude Code in any project. Observations stream in as Claude reads, edits, and runs commands.
${pc.bold('Two paths from here:')}
${pc.cyan('A.')} Just start working. Memory builds passively from your first prompt. (Recommended.)
${pc.cyan('B.')} Front-load it: open Claude Code and run ${pc.bold('/learn-codebase')} to ingest the whole repo (~5 min, optional).
Memory injection starts on your second session in a project.
Everything stays in ${pc.cyan('~/.claude-mem')} on this machine.
${pc.dim('How it works: /how-it-works · Disable first-session hint: CLAUDE_MEM_WELCOME_HINT_ENABLED=false')}
${pc.dim('Note: close all Claude Code sessions before uninstalling, or ~/.claude-mem will be recreated by active hooks.')}
Worker-not-ready branch: keep the existing pc.yellow('!') warning + retry hint, then append the same "First success" / "Two paths" / timing / privacy lines (substituting workerPort for actualPort).
Drop /mem-search and /knowledge-agent lines from this surface entirely. (They reappear in WelcomeCard for users who do open the viewer.)
Keep both isInteractive (uses p.note(nextSteps.join('\n'), 'Next Steps')) and non-interactive (console.log per line, 2-space indent) branches in sync. The array shape stays the same — only the strings change.
Verify pc.dim renders correctly under the clack p.note box (it does — line 663 already uses it).
npm run build succeeds.npx claude-mem install in a fresh dir shows the new Next Steps block inside the clack box.CI=true npx claude-mem install (or pipe through cat) shows the same content with 2-space indent and no clack boxes.tests/install-non-tty.test.ts regex assertions to match the new strings (existing pattern: expect(installSource).toContain(...))./api/stats with firstObservationAtWhy: The viewer micro-stat row (Phase 5) needs a "since [date]" value. No HTTP endpoint currently exposes the earliest observation timestamp. Smallest possible backend change to enable Phase 5.
Add a firstObservationAt: string | null field to the stats response in src/services/worker/http/routes/DataRoutes.ts:204-242 (handleGetStats).
Add a SQL helper next to getRecentObservations (src/services/sqlite/observations/recent.ts:6-20):
export function getFirstObservationCreatedAt(db: SessionStore): string | null {
// SELECT created_at FROM observations ORDER BY created_at_epoch ASC LIMIT 1
}
Match the existing prepared-statement pattern in that directory.
Wire the helper into handleGetStats and surface as ISO string (or null if no observations). Verify the existing TypeScript type for the stats response is updated.
bun test tests/sqlite/observations.test.ts still passes.tests/sqlite/observations.test.ts (or a new file) covering getFirstObservationCreatedAt for empty + non-empty DB.curl http://localhost:$PORT/api/stats returns the new field./api/stats payload.Why: Current card is generic and doesn't differentiate the empty state (the moment the user is asking "is anything happening?") from the data state (the moment the user is asking "what can I do here?").
App.tsx wiring (src/ui/viewer/App.tsx:128-130). Pass new props to WelcomeCard:
<WelcomeCard
onDismiss={...}
observationCount={allObservations.length}
projectCount={projects.length}
isConnected={isConnected}
firstObservationAt={stats.firstObservationAt} // new — fetched from /api/stats
/>
If a stats fetch hook doesn't already exist, add one (useStats() at src/ui/viewer/hooks/useStats.ts) that polls /api/stats on mount and on each new SSE observation.
WelcomeCard.tsx rewrite (src/ui/viewer/components/WelcomeCard.tsx):
claude-mem-welcome-dismissed-v2 (keep helpers in same file). v1 dismissals should NOT carry over — the card is meaningfully different.observationCount === 0:
<span class="welcome-card-status-dot" data-connected={isConnected ? 'true' : 'false'} /> and label "Connected to worker · waiting for activity" / "Reconnecting…" based on isConnected.${observationCount} observations · ${projectCount} projects · since ${formatDate(firstObservationAt)}.<code>ask:</code> did we already solve X?<code>/mem-search</code> dig into past work/api/onboarding/explainer (opens in new tab as raw markdown — acceptable for v1; or a small modal showing the markdown rendered).CSS additions in src/ui/viewer-template.html next to .welcome-card-* styles (lines 1443-1561). Reuse existing @keyframes pulse (line 754). Add:
.welcome-card-status-dot (8×8 circle, error color + pulse when disconnected, success color + no animation when data-connected="true")..welcome-card-stats (single-row, dim text, dot separators using ·)..welcome-card-empty adjustments (slightly larger lede, status row layout).Auto-dismiss on first observation: in App.tsx, add an effect that flips the card from empty→has-data view automatically when observationCount crosses 0→1. The card should NOT auto-dismiss permanently on the first observation — it just transitions states. The user explicitly dismisses with the X.
npm run build-and-sync succeeds; viewer bundle rebuilds.getStoredWelcomeDismissed / setStoredWelcomeDismissed against the new v2 key (extend the helper logic — pure functions are easy to test even without React Testing Library).viewer-template.html./api/stats on every render — once on mount + on 'new_observation' SSE event is enough./api/onboarding/explainer.Why: Three surfaces, two verbatim lines, one explainer source. Catch any divergence before it ships.
Grep for the timing line and assert it appears verbatim in:
src/services/worker/http/routes/SearchRoutes.ts (Phase 2)src/npx-cli/commands/install.ts (Phase 3)src/services/worker/onboarding-explainer.md (Phase 1)grep -rn "Memory injection starts on your second session in a project" src/ plugin/
# expect 3+ matches
Same for the privacy line (Everything stays in ~/.claude-mem on this machine.).
Confirm /how-it-works slash reference appears in install.ts and SearchRoutes.ts; SKILL.md exists at plugin/skills/how-it-works/SKILL.md.
Confirm WelcomeCard does NOT inline the explainer body — only the link.
Confirm no surface still says "/knowledge-agent" or "/mem-search" in install.ts post-install copy.
/how-it-works link are the only repeated content.Why: The acceptance criterion in the brief is a single coherent flow. This phase walks it.
Fresh install:
rm -rf ~/.claude-mem
npx claude-mem install
Verify: install Next Steps shows the new "Two paths" + first-success + timing + privacy + /how-it-works block.
Open the viewer at the printed URL. Verify: empty state shows, dot is green (connected) or red+pulsing (disconnected briefly).
Open Claude Code in any project. Type a prompt that causes one Read.
End the session. Start a second Claude Code session in the same project.
observationCount > 0).Click the "How it works" link from the viewer card. Verify: it loads /api/onboarding/explainer markdown.
All four observable beats in the acceptance criterion happen as described, in order, without any surface contradicting another on facts (timing, privacy, command names).
/api/stats extension) — unblocks Phase 5; tiny isolated backend change, do it early.Phases 2, 3, and 4 are independent and could be parallelized in three short sessions if desired.
/learn-codebase, /mem-search, /knowledge-agent skill internals — only how we reference them./api/onboarding/explainer and the firstObservationAt field on /api/stats.docs/public/ — covered by the existing Mintlify deploy; only update if a doc page directly contradicts the new copy.