- Added a new onboarding wizard that runs on the first app launch post-login. - Collects user personalization data (job role, industry, primary use case, tone preference, language) and stores it in encrypted core memory. - Auto-detects and saves formatting preferences (timezone, time format, date format) in local electron-store. - Normalizes user free-text inputs via a backend LLM call before persisting. - Introduced new backend routes for memory updates and normalization. - Updated frontend components to support the onboarding flow with a chat-bubble aesthetic. - Added settings section for profile editing and re-running the onboarding process. - Ensured that the onboarding process is skippable and editable in the settings. - Implemented verification steps to ensure proper functionality and data handling. chore: update submodules for waitlist and website
24 KiB
RALPH LOOP PROMPT — First-Run Onboarding Wizard
How to run:
/ralph-loop "Implement the onboarding wizard exactly as specified in docs/PROMPT-onboarding.md. Output <promise>ONBOARDING COMPLETE</promise> when all phases pass lint." --max-iterations 25 --completion-promise "ONBOARDING COMPLETE"
INSTRUCTIONS FOR CLAUDE
You are implementing a first-run onboarding wizard for the adiuvAI Electron app. This is a multi-file, multi-iteration task. On each iteration:
- Read this file in full.
- Inspect which tasks are already done by checking if the target files exist and contain the expected code.
- Pick the next incomplete task (always in phase order: Phase 1 → 2 → 3 → 4).
- Implement it, then run the relevant lint command before exiting.
- When ALL phases are complete AND both lint commands pass, output
<promise>ONBOARDING COMPLETE</promise>.
DO NOT skip phases. DO NOT implement out of order — backend must exist before the FE can call it.
LINT COMMANDS (run after each phase):
- Backend:
cd api && ruff check . --fix - Frontend:
cd adiuvAI && npx eslint . --fix
WHAT THIS FEATURE DOES
After login, new users see a chat-styled wizard that collects 5 fields:
job_role,industry,primary_use_case,tone_preference,language
These are stored encrypted in MemoryCore (backend) so the AI agents personalize responses. Three formatting prefs (timezone, date_format, time_format) are auto-detected from the OS and stored in electron-store (FE only) — the LLM never sees them. The FE formats all timestamp columns in tool-result rows before sending them back to the backend.
Storage split:
| Field | Where | Why |
|---|---|---|
| job_role, industry, primary_use_case, tone_preference, language | MemoryCore (backend, encrypted) |
LLM needs these for personalization |
| timezone, date_format, time_format | electron-store (FE) | FE formatter only — LLM must never see raw timestamps |
Key architectural fact: memory_middleware.py enrich_context() already injects core_memory into every orchestrator call. Writing to MemoryCore is sufficient — no system-prompt changes needed.
PHASE 1 — Backend (api/)
TASK 1.1: Alembic migration — onboarding_completed_at column
File: api/alembic/versions/XXX_add_onboarding_completed_at.py (new)
Create a new Alembic migration that adds:
ALTER TABLE users ADD COLUMN onboarding_completed_at TIMESTAMPTZ NULL;
Use the existing migrations in api/alembic/versions/ as a pattern reference. The revision ID should be sequential (check the latest existing migration number and increment).
Done signal: File exists in api/alembic/versions/ with the column add.
TASK 1.2: Add column to User model
File: api/app/models.py
Find the User class (around line 63-94). Add:
onboarding_completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
Import DateTime from sqlalchemy if not already imported.
Done signal: User model has onboarding_completed_at field.
TASK 1.3: Extend UserProfile schema
File: api/app/schemas.py
Find UserProfile (around line 27-33). Add two fields:
onboarding_completed_at: int | None = None # epoch ms, null = not onboarded
memory: dict[str, str] = Field(default_factory=dict) # decrypted core memory k/v
Done signal: UserProfile has both new fields.
TASK 1.4: Extend get_current_user to return memory + onboarding flag
File: api/app/api/middleware/auth.py
In get_current_user(), after fetching the user row and resolving the tier:
- Read
user.onboarding_completed_at— convert to epoch ms (int) or None. - Use
MemoryMiddleware(db).enrich_context(user.id)to load decrypted core memory. Extract thecoredict →{label: value}pairs. - Return
UserProfile(..., onboarding_completed_at=..., memory=...).
This requires get_current_user to also receive the db: AsyncSession dependency. Check if it already does — if not, add Depends(get_session).
Done signal: GET /api/v1/auth/me returns onboarding_completed_at and memory fields.
TASK 1.5: New route — PUT /auth/me/memory
File: api/app/api/routes/auth.py
Add a new route (do NOT modify _UpdateProfileRequest):
class _UpdateMemoryRequest(BaseModel):
memory: dict[str, str] = Field(default_factory=dict)
mark_onboarded: bool = False
@router.put("/me/memory", response_model=UserProfile)
async def update_memory(
body: _UpdateMemoryRequest,
current_user: UserProfile = Depends(get_current_user),
db: AsyncSession = Depends(get_session),
) -> UserProfile:
mw = MemoryMiddleware(db)
for key, value in body.memory.items():
await mw.update_core(current_user.id, key, value)
if body.mark_onboarded:
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one()
user.onboarding_completed_at = datetime.now(timezone.utc)
await db.commit()
# Re-fetch profile and return
return await get_current_user(...) # use same logic as GET /me
Also add a companion route to reset onboarding (for "Re-run onboarding" in Settings):
@router.post("/me/onboarding/reset")
async def reset_onboarding(
current_user: UserProfile = Depends(get_current_user),
db: AsyncSession = Depends(get_session),
):
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one()
user.onboarding_completed_at = None
await db.commit()
return {"status": "reset"}
Done signal: Both routes exist and are syntactically correct.
TASK 1.6: New route — POST /auth/onboarding/normalize
File: api/app/api/routes/auth.py
class _NormalizeRequest(BaseModel):
inputs: dict[str, str] # {"job_role": "i build websites"}
class _NormalizeResponse(BaseModel):
normalized: dict[str, str]
@router.post("/onboarding/normalize", response_model=_NormalizeResponse)
async def normalize_onboarding(
body: _NormalizeRequest,
current_user: UserProfile = Depends(get_current_user),
) -> _NormalizeResponse:
"""One-shot LLM normalization for free-text onboarding answers."""
if not body.inputs:
return _NormalizeResponse(normalized={})
try:
llm = get_llm("gpt-4o-mini", temperature=0)
prompt = (
"You normalize user onboarding answers into clean, ≤3-word canonical labels.\n"
"Return a JSON object with the same keys and normalized values.\n"
"Examples: 'i build websites' → 'Web Developer', 'tech-ish stuff' → 'Technology'\n"
f"Input: {json.dumps(body.inputs)}"
)
response = await llm.ainvoke(
[{"role": "system", "content": "You normalize user inputs. Return JSON only."},
{"role": "user", "content": prompt}],
)
normalized = json.loads(response.content)
return _NormalizeResponse(normalized=normalized)
except Exception:
# LLM failure must never block onboarding — return inputs unchanged
return _NormalizeResponse(normalized=body.inputs)
Use get_llm from app.core.llm. Use json stdlib. The try/except is critical — flaky LLM must never block the wizard.
Done signal: Route exists, has the safety try/except, returns inputs on failure.
TASK 1.7: Backend lint check
Run: cd api && ruff check . --fix
Fix any issues before proceeding to Phase 2.
Done signal: ruff check . exits 0.
PHASE 2 — Electron Main Process (adiuvAI/src/main/)
TASK 2.1: Extend UserProfileSchema
File: adiuvAI/src/shared/api-types.ts
Find UserProfileSchema (Zod schema). Add:
onboardingCompletedAt: z.number().int().nullable().optional(),
memory: z.record(z.string(), z.string()).default({}),
Done signal: Schema has both fields.
TASK 2.2: Add formatPrefs to electron-store
File: adiuvAI/src/main/store.ts
Extend the AppSettings interface:
formatPrefs: {
timezone: string;
dateFormat: string; // 'dd/MM/yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd'
timeFormat: '12h' | '24h';
} | null;
Default to null in the store defaults.
Add helpers:
export function getFormatPrefs(): FormatPrefs | null {
return getStore().get('formatPrefs', null);
}
export function setFormatPrefs(prefs: FormatPrefs): void {
getStore().set('formatPrefs', prefs);
}
Export FormatPrefs as a type.
Done signal: getFormatPrefs() and setFormatPrefs() exported from store.ts.
TASK 2.3: Create locale-defaults helper
File: adiuvAI/src/main/auth/locale-defaults.ts (new)
import { app } from 'electron';
export interface FormatPrefs {
timezone: string;
dateFormat: string;
timeFormat: '12h' | '24h';
}
export function detectFormatPrefs(): FormatPrefs {
const locale = app.getLocale();
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const hour12 = Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12;
const timeFormat = hour12 ? '12h' : '24h';
const dateFormat = inferDateFormat(locale);
return { timezone, timeFormat, dateFormat };
}
export function detectLanguage(): string {
return app.getLocale(); // e.g. 'it-IT', 'en-US'
}
function inferDateFormat(locale: string): string {
// MDY locales
const mdyLocales = ['en-US', 'en-PH', 'en-BZ'];
if (mdyLocales.some(l => locale.startsWith(l))) return 'MM/dd/yyyy';
// YMD locales (CJK, ISO-oriented)
const ymdPrefixes = ['ja', 'zh', 'ko', 'hu', 'lt', 'sv', 'fi'];
if (ymdPrefixes.some(p => locale.startsWith(p))) return 'yyyy-MM-dd';
// Default: DMY (most of the world)
return 'dd/MM/yyyy';
}
Done signal: File exists with detectFormatPrefs() and detectLanguage() exported.
TASK 2.4: Create format-row helper
File: adiuvAI/src/main/api/format-row.ts (new)
import type { FormatPrefs } from '../auth/locale-defaults';
const TIMESTAMP_COLUMNS = new Set([
'createdAt', 'updatedAt', 'dueDate', 'date', 'endDate',
'lastRunAt', 'startedAt', 'completedAt',
]);
export function formatRows<T extends Record<string, unknown>>(
rows: T[],
prefs: FormatPrefs,
): T[] {
return rows.map(row => formatRow(row, prefs));
}
export function formatRow<T extends Record<string, unknown>>(
row: T,
prefs: FormatPrefs,
): T {
const result = { ...row };
for (const col of TIMESTAMP_COLUMNS) {
if (col in result && typeof result[col] === 'number') {
(result as Record<string, unknown>)[col] = formatTimestamp(
result[col] as number,
prefs,
);
}
}
return result;
}
function formatTimestamp(epochMs: number, prefs: FormatPrefs): string {
const date = new Date(epochMs);
const hour12 = prefs.timeFormat === '12h';
const opts: Intl.DateTimeFormatOptions = {
timeZone: prefs.timezone,
hour12,
hour: '2-digit',
minute: '2-digit',
};
const timePart = date.toLocaleTimeString('en-US', opts);
const day = date.toLocaleDateString('en-CA', { timeZone: prefs.timezone, day: '2-digit' }).slice(-2);
const month = date.toLocaleDateString('en-CA', { timeZone: prefs.timezone, month: '2-digit' }).slice(-2);
const year = date.toLocaleDateString('en-CA', { timeZone: prefs.timezone, year: 'numeric' }).slice(0, 4);
let datePart: string;
switch (prefs.dateFormat) {
case 'MM/dd/yyyy': datePart = `${month}/${day}/${year}`; break;
case 'yyyy-MM-dd': datePart = `${year}-${month}-${day}`; break;
default: datePart = `${day}/${month}/${year}`; break;
}
return `${datePart} ${timePart}`;
}
Done signal: File exists with formatRow and formatRows exported.
TASK 2.5: Wire format-row into drizzle-executor
File: adiuvAI/src/main/api/drizzle-executor.ts
Import formatRows, formatRow from ./format-row and getFormatPrefs from ../store and detectFormatPrefs from ../auth/locale-defaults.
Find every place that returns { rows } or { row } results. Wrap them:
rows→formatRows(rows, getFormatPrefs() ?? detectFormatPrefs())row→formatRow(row, getFormatPrefs() ?? detectFormatPrefs())
The ?? detectFormatPrefs() fallback handles the edge case where executor runs before first auth.status seed.
Important: Only format on handleList, handleGet, handleInsert, handleUpdate return paths — NOT on delete. Do not mutate the original rows — formatRow returns a new object.
Done signal: All select/get/insert/update returns pass through formatRow(s).
TASK 2.6: Add auth methods to auth-manager
File: adiuvAI/src/main/auth/auth-manager.ts
Add two methods to the AuthManager class:
async updateMemory(
memory: Record<string, string>,
markOnboarded = false,
): Promise<UserProfile> {
return this.put('/api/v1/auth/me/memory', {
memory,
mark_onboarded: markOnboarded,
});
}
async normalizeOnboarding(
inputs: Record<string, string>,
): Promise<Record<string, string>> {
const res = await this.post('/api/v1/auth/onboarding/normalize', { inputs });
return res.normalized;
}
async resetOnboarding(): Promise<void> {
await this.post('/api/v1/auth/me/onboarding/reset', {});
}
Use the existing this.put() / this.post() helpers (they handle auth headers and camelCase/snakeCase conversion).
Done signal: All three methods exist on AuthManager.
TASK 2.7: Extend tRPC authRouter
File: adiuvAI/src/main/router/index.ts
In the authRouter, add these procedures:
updateMemory: t.procedure
.input(z.object({
memory: z.record(z.string(), z.string()),
markOnboarded: z.boolean().optional().default(false),
}))
.mutation(async ({ input }) => {
return authManager.updateMemory(input.memory, input.markOnboarded);
}),
normalizeOnboarding: t.procedure
.input(z.object({
inputs: z.record(z.string(), z.string()),
}))
.mutation(async ({ input }) => {
return authManager.normalizeOnboarding(input.inputs);
}),
resetOnboarding: t.procedure
.mutation(async () => {
return authManager.resetOnboarding();
}),
Also, in the existing auth.status procedure, add silent auto-seeding logic:
- After fetching the profile, if
getFormatPrefs()returns null → callsetFormatPrefs(detectFormatPrefs()). - If
profile.memory.languageis missing/empty → callauthManager.updateMemory({ language: detectLanguage() })silently (fire-and-forget, don't block the return).
Done signal: Three new procedures exist. Status procedure auto-seeds format prefs and language.
TASK 2.8: Add settings routes for formatPrefs
File: adiuvAI/src/main/router/index.ts
In settingsRouter (or create one if it doesn't exist), add:
getFormatPrefs: t.procedure.query(() => {
return getFormatPrefs();
}),
setFormatPrefs: t.procedure
.input(z.object({
timezone: z.string(),
dateFormat: z.string(),
timeFormat: z.enum(['12h', '24h']),
}))
.mutation(({ input }) => {
setFormatPrefs(input);
return input;
}),
Done signal: Both procedures exist.
TASK 2.9: Frontend lint check
Run: cd adiuvAI && npx eslint . --fix
Fix any TypeScript/ESLint issues before proceeding to Phase 3.
Done signal: ESLint exits 0.
PHASE 3 — Electron Renderer (adiuvAI/src/renderer/)
TASK 3.1: Create onboarding chip options
File: adiuvAI/src/renderer/components/onboarding/onboardingOptions.ts (new)
export const JOB_ROLES = ['Developer', 'Designer', 'Consultant', 'Founder', 'Project Manager'] as const;
export const INDUSTRIES = ['Tech', 'Design', 'Consulting', 'Legal', 'Marketing', 'Education'] as const;
export const USE_CASES = ['Solo freelancer', 'Client manager', 'Team lead', 'Personal productivity'] as const;
export const TONES = ['Casual', 'Formal', 'Concise', 'Detailed'] as const;
Done signal: File exists with all four arrays exported.
TASK 3.2: Create OnboardingFlow component
File: adiuvAI/src/renderer/components/onboarding/OnboardingFlow.tsx (new)
This is the most complex file. Key requirements:
State machine:
type Step = 'welcome' | 'jobRole' | 'industry' | 'useCase' | 'tone' | 'language' | 'reviewing' | 'done';
Props:
interface OnboardingFlowProps {
profile: UserProfile;
}
Visual style — must match AIChatPanel:
- Chat bubble layout: AI messages in
rounded-2xlbubbles with a Sparkles icon (from lucide-react). - Use glassmorphism:
bg-white/5 backdrop-blur-md border border-white/10. - Spring transitions (framer-motion) for each step entering.
- Use shadcn components:
Button,Input,Card.
Each wizard step shows:
- An "AI bubble" with the question text.
- 3–6 preset chip buttons (from onboardingOptions.ts).
- An optional "Type your own" text input (for
job_roleandindustryonly). - A "Skip" link at the bottom.
- Previous answers appear above as "user bubbles" (right-aligned).
Step details:
| Step | Question | Chips | Free text? |
|---|---|---|---|
| welcome | "Hi {name}! I'm your AI assistant. Let me learn a few things about you so I can help better." | Just a "Let's go" button | No |
| jobRole | "What's your role?" | JOB_ROLES | Yes |
| industry | "What industry do you work in?" | INDUSTRIES | Yes |
| useCase | "How will you mainly use adiuvAI?" | USE_CASES | No |
| tone | "How should I talk to you?" | TONES | No |
| language | "I'll respond in {detected}. Want to change it?" | Show detected language pre-selected; allow typing a different one | Yes |
| reviewing | Review screen (see below) | — | — |
| done | Redirect (never renders) | — | — |
Reviewing step logic:
- Partition answers: chip selections (already clean) vs free-text answers (need normalization).
- If free-text map is non-empty → call
trpc.auth.normalizeOnboarding.useMutation. Show "Tidying up…" spinner on those fields only. - Show a Card titled "Here's what I'll save" with all 5 fields as rows.
- Each row has an Edit pencil icon → converts to inline input → Enter saves → back to read-only.
- If LLM changed a value, show grey hint:
auto-tidied from "original text". - Primary button: "Looks good — save" → calls
trpc.auth.updateMemory.useMutation({ memory: finalMap, markOnboarded: true })→utils.auth.status.invalidate(). - Secondary link: "Back to wizard" → resets to
jobRolestep with values pre-filled. - Failure modes:
- Normalization fails → show raw values + banner "Couldn't auto-tidy — review and save". Save still works.
- Save fails → toast error, stay on review screen.
Skip behavior: Clicking Skip on any step → calls updateMemory({}, markOnboarded: true) with empty map → wizard closes. Language was already auto-seeded by auth.status.
Done signal: Component file exists, renders a multi-step wizard, handles reviewing + save.
TASK 3.3: Gate OnboardingFlow in AppShell
File: adiuvAI/src/renderer/components/layout/AppShell.tsx
After the authenticated === false → LoginForm branch, add:
if (
authStatusQuery.data?.profile &&
authStatusQuery.data.profile.onboardingCompletedAt == null
) {
return <OnboardingFlow profile={authStatusQuery.data.profile} />;
}
Import OnboardingFlow from ../onboarding/OnboardingFlow.
Done signal: AppShell conditionally renders OnboardingFlow when onboardingCompletedAt is null.
TASK 3.4: Add 'profile' to settings sections
File: adiuvAI/src/renderer/components/settings/types.ts
Add 'profile' to the SectionId type and { id: 'profile', label: 'Profile' } to SECTIONS array — insert it before 'account'.
Done signal: SECTIONS includes 'profile' as the first or second entry.
TASK 3.5: Create ProfileSection component
File: adiuvAI/src/renderer/components/settings/ProfileSection.tsx (new)
Plain form (no chat aesthetic — this is Settings, not the wizard). Two cards:
Card 1 — "About you" (writes to MemoryCore via auth.updateMemory):
- Fields: job_role, industry, primary_use_case (select from USE_CASES), tone_preference (select from TONES), language (text input).
- Pre-populate from
authStatusQuery.data.profile.memory. - Save button →
trpc.auth.updateMemory.useMutation. - "Re-run onboarding" button →
trpc.auth.resetOnboarding.useMutation→utils.auth.status.invalidate()(triggers wizard via AppShell gate).
Card 2 — "Display preferences" (writes to electron-store via trpc.settings.setFormatPrefs):
- Timezone: searchable select, populated from
Intl.supportedValuesOf('timeZone'). - Date format: select with options:
dd/MM/yyyy,MM/dd/yyyy,yyyy-MM-dd. - Time format: radio group — 12h / 24h.
- Pre-populate from
trpc.settings.getFormatPrefsquery. - Save button →
trpc.settings.setFormatPrefs.useMutation.
Use shadcn Card, Input, Select, Button, Label, RadioGroup components.
Done signal: Component exists with both cards, save functionality wired.
TASK 3.6: Wire ProfileSection into settings route
File: adiuvAI/src/renderer/routes/settings.tsx
Add the import and the conditional render:
{section === 'profile' && <ProfileSection />}
Done signal: ProfileSection renders when section=profile.
TASK 3.7: Final lint check
Run both:
cd api && ruff check . --fix
cd adiuvAI && npx eslint . --fix
Done signal: Both exit 0.
PHASE 4 — Completion
TASK 4.1: Verify all files exist
Check that these files exist:
api/alembic/versions/*_add_onboarding_completed_at.pyadiuvAI/src/main/auth/locale-defaults.tsadiuvAI/src/main/api/format-row.tsadiuvAI/src/renderer/components/onboarding/onboardingOptions.tsadiuvAI/src/renderer/components/onboarding/OnboardingFlow.tsxadiuvAI/src/renderer/components/settings/ProfileSection.tsx
Check that these files were modified:
api/app/models.py(hasonboarding_completed_at)api/app/schemas.py(UserProfile hasmemory+onboarding_completed_at)api/app/api/middleware/auth.py(get_current_user returns memory)api/app/api/routes/auth.py(3 new routes)adiuvAI/src/shared/api-types.ts(UserProfileSchema extended)adiuvAI/src/main/store.ts(formatPrefs + helpers)adiuvAI/src/main/auth/auth-manager.ts(3 new methods)adiuvAI/src/main/router/index.ts(5 new procedures + auto-seed in status)adiuvAI/src/main/api/drizzle-executor.ts(formatRow wiring)adiuvAI/src/renderer/components/layout/AppShell.tsx(onboarding gate)adiuvAI/src/renderer/components/settings/types.ts(profile section)adiuvAI/src/renderer/routes/settings.tsx(ProfileSection render)
TASK 4.2: Output completion promise
If everything above is done and lint passes:
<promise>ONBOARDING COMPLETE</promise>
REFERENCE — Existing patterns to reuse
DO NOT reinvent these. Copy their patterns:
| Pattern | Source file | Reuse for |
|---|---|---|
| Chat bubble + Sparkles + glass | src/renderer/components/ai/AIChatPanel.tsx |
OnboardingFlow bubbles |
| Stepper state machine | InlineAgentCreationStepper in renderer |
Wizard step transitions |
| MemoryMiddleware.update_core | api/app/core/memory_middleware.py:137-173 |
PUT /me/memory route |
| get_llm() | api/app/core/llm.py |
Normalize route |
| electron-store helpers | src/main/store.ts (getDeviceId pattern) |
getFormatPrefs/setFormatPrefs |
| tRPC procedure pattern | src/main/router/index.ts (auth.status) |
New procedures |
| shadcn form components | Existing settings sections | ProfileSection |
| toCamelCase / toSnakeCase | auth-manager.ts proxy helpers |
Automatic key conversion |
DO NOT
- Add features not described here (no avatar upload, no i18n framework, no animation library beyond framer-motion if already installed).
- Modify the orchestrator or system prompts — MemoryCore injection is already handled.
- Add foreign key constraints to the migration.
- Store formatting prefs in MemoryCore.
- Let the LLM normalization route throw on failure — it MUST return inputs unchanged.
- Skip the reviewing step in the wizard.
- Run both lint checks and fix issues before claiming completion.