- 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
First-Run User Onboarding (Profile → Core Memory + Format Prefs)
Context
Today, after sign-up or login, users land directly on the home chat with no introduction. Signup only collects name, surname, email. The backend AI agents have no idea who they're talking to — generic answers, generic tone, no language match, raw timestamps.
This change adds a one-time wizard that runs the first time a user opens the app post-login. It:
- Seeds the user's core memory (encrypted, server-side) with personalization data the AI should reason about (
job_role,industry,primary_use_case,tone_preference,language). - Auto-detects and stores formatting preferences (
timezone,time_format,date_format) on the FE as electron-store settings — not in core memory, because the LLM should never see raw timestamps or have to reason about format strings. Instead, the FE applies these to tool-result rows before they're sent back to the backend. - Optionally normalizes user free-text answers via a single backend LLM call before persisting, so messy inputs like "i build websites" become clean values like "Web Developer".
Because memory_middleware.py:53-94 already auto-injects core_memory into every orchestrator call (see device_ws.py:213 and device_ws.py:282), no system-prompt code changes — writing to MemoryCore is enough for agents to "see" the data on their next call.
Decisions made with the user:
- UI style: hybrid chat-styled wizard (looks like AIChatPanel — bubbles, chips — but pre-scripted, no LLM calls per step)
- Storage split: AI-relevant fields in encrypted
MemoryCore; formatting prefs in FE-local electron-store - Skippable + editable in a new Settings → Profile section
- OS-derived defaults: language, timezone, time format, date format auto-detected from the OS. Language is shown in the wizard for confirmation; the three formatting prefs are seeded silently and editable in Settings.
- Avatar: comes from Google OAuth (already supported via
users.avatar_url). Not in this wizard. A manual upload control in Settings → Profile is a nice-to-have but out of scope for this change. - LLM normalization: yes, but only on free-text answers, in one batch call at the final 'Done' step. User sees a "Here's what I saved" review screen and can edit before persisting.
Fields collected
| Key | Lives in | Source | In wizard? | Editable in Settings? | Used by |
|---|---|---|---|---|---|
job_role |
MemoryCore (BE, encrypted) |
User (chip + free text) | Yes | Yes | LLM |
industry |
MemoryCore |
User (chip + free text) | Yes | Yes | LLM |
primary_use_case |
MemoryCore |
User (chip) | Yes | Yes | LLM |
tone_preference |
MemoryCore |
User (chip) | Yes | Yes | LLM |
language |
MemoryCore |
OS app.getLocale() → user confirms |
Yes — confirm/change | Yes | LLM (response language) + future UI i18n |
timezone |
electron-store (FE) | Intl.DateTimeFormat().resolvedOptions().timeZone |
No — silent | Yes | FE formatter |
time_format |
electron-store (FE) | Derived from locale (12h/24h) | No — silent | Yes | FE formatter |
date_format |
electron-store (FE) | Derived from locale (e.g. dd/MM/yyyy) | No — silent | Yes | FE formatter |
The split is the load-bearing decision: the LLM never sees raw timestamps or format prefs. Instead, the FE's drizzle executor formats every timestamp column in tool-result rows using the user's preferences before sending the tool_result frame back to the backend.
Architecture notes
-
AI orchestration is fully delegated to the backend via WebSocket — see orchestrator.ts:87-117. The Electron client never builds a system prompt. So all LLM-relevant personalization must live on the backend (in
MemoryCore). -
Tool calls are FE-executed: backend sends
WsToolCall→ FE drizzle-executor.ts runs the SELECT and returns{ rows }→ FE backend-client.ts:652-658 wraps it as atool_resultframe. The formatting hook goes between the executor returning rows and the frame being sent — this is where rawdueDatenumbers become"15/04/2026 14:30"strings. -
Format prefs are per-device:
timezoneis inherently per-device (your laptop and phone may be in different cities). For consistency we keep all three format prefs FE-local. If the user wants cross-device sync later, this can migrate toMemoryCorewithout breaking the wire format — but that's not v1.
Files to change
Backend (api/)
-
api/alembic/versions/<new>_add_onboarded_flag.py— new Alembic migration:ALTER TABLE users ADD COLUMN onboarding_completed_at TIMESTAMPTZ NULLThe five LLM-relevant values live in the existingmemory_coretable — no new columns.
-
api/app/models.py:63-94 — add
onboarding_completed_at: Mapped[datetime | None]toUser. -
api/app/schemas.py:27-33 — extend
UserProfile:class UserProfile(BaseModel): id: str email: str name: str | None = None surname: str | None = None tier: BillingTier avatar_url: str | None = None onboarding_completed_at: int | None = None # epoch ms memory: dict[str, str] = Field(default_factory=dict) -
api/app/api/middleware/auth.py:74-79 — extend
get_current_user:- Read
onboarding_completed_atfrom the user row. - Use
MemoryMiddleware(db).list_core_blocks(user_id)to load decrypted core blocks →{label: value}dict, attach asmemory.
- Read
-
api/app/api/routes/auth.py — add a new route. Do not extend
_UpdateProfileRequest(keep name/surname separate).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: memory = MemoryMiddleware(db) for key, value in body.memory.items(): await memory.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 via get_current_user-style logic and return UserProfile. -
api/app/api/routes/auth.py— new normalization route:class _NormalizeRequest(BaseModel): inputs: dict[str, str] # e.g. {"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."""Implementation: build a small system prompt ("You normalize user onboarding answers. Return JSON only. Each key maps to a clean, ≤3-word canonical label."), call
get_llm("gpt-4o-mini", temperature=0)from api/app/core/llm.py withresponse_format={"type": "json_object"}, parse, return. Must short-circuit and return the inputs unchanged on any LLM error so the wizard never blocks on a flaky model call. Rate-limited by the existingTierRateLimitermiddleware. -
No orchestrator / prompt changes needed.
MemoryMiddleware.enrich_context()already injectscore_memoryinto every chat call. This is the whole point of usingMemoryCoreinstead of system-prompt injection.
Electron main (adiuvAI/src/main/)
-
src/shared/api-types.ts:26-34 — extend
UserProfileSchemawithonboardingCompletedAt: z.number().int().nullable().optional()andmemory: z.record(z.string(), z.string()).default({}). -
src/main/store.ts:23-38 — add a
formatPrefsblock toAppSettings:formatPrefs: { timezone: string; // 'Europe/Rome' dateFormat: string; // 'dd/MM/yyyy' | 'MM/dd/yyyy' | 'yyyy-MM-dd' timeFormat: '12h' | '24h'; } | null; // null = not yet seededDefault to
null. Add helpersgetFormatPrefs()andsetFormatPrefs(prefs). -
New:
src/main/auth/locale-defaults.ts— small helper:export function detectFormatPrefs(): FormatPrefs { const locale = app.getLocale(); // 'it-IT' const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; const timeFormat = Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12 ? '12h' : '24h'; const dateFormat = inferDateFormatFromLocale(locale); // small lookup: 'en-US'→MM/dd/yyyy, 'en-GB'/'it-IT'/...→dd/MM/yyyy, 'ja-JP'/...→yyyy-MM-dd return { timezone, timeFormat, dateFormat }; } export function detectLanguage(): string { return app.getLocale(); } // 'it-IT' -
New:
src/main/api/format-row.ts— pure function called by the executor:const TIMESTAMP_COLUMNS = new Set([ 'createdAt', 'updatedAt', 'dueDate', 'date', 'endDate', 'lastRunAt', 'startedAt', 'completedAt', ]); export function formatRow<T extends Record<string, unknown>>(row: T, prefs: FormatPrefs): T; export function formatRows<T extends Record<string, unknown>>(rows: T[], prefs: FormatPrefs): T[];For each known timestamp column whose value is a
number, replace it withformatInstant(value, prefs)whereformatInstantusesIntl.DateTimeFormat(locale, { timeZone: prefs.timezone, hour12: prefs.timeFormat === '12h', ... })and thedateFormatsetting. Returns a new object — does not mutate.The set of timestamp columns is hard-coded against the Drizzle schema; if a new timestamp column is added, this set must be updated. (Acceptable for v1 — the schema is small. If it grows, we can derive the set from the Drizzle schema's
integer('...', { mode: 'number' })columns at startup.) -
src/main/api/drizzle-executor.ts:204-263 — wrap the executor's
select/get/insert/updatereturn paths so thatrows/rowget passed throughformatRow(s)(..., getFormatPrefs() ?? detectFormatPrefs()). The?? detect…fallback handles the edge case where the executor runs before the first auth.status seed call (e.g. background tool calls during login). -
src/main/auth/auth-manager.ts:170-174 — add two methods:
async updateMemory(memory: Record<string, string>, markOnboarded = false): Promise<UserProfile> async normalizeOnboarding(inputs: Record<string, string>): Promise<Record<string, string>>Both call the new backend routes via the existing
put/posthelpers. -
src/main/router/index.ts:1059-1098 — extend
authRouter:- Add
auth.updateMemorymutation: input{ memory, markOnboarded? }. - Add
auth.normalizeOnboardingmutation: input{ inputs: Record<string, string> }. - Extend
auth.statusso that immediately after fetching the profile, ifgetFormatPrefs()isnull, it callssetFormatPrefs(detectFormatPrefs())(silent FE seed). Ifprofile.memory.languageis missing, it also callsauthManager.updateMemory({ language: detectLanguage() })(silent BE seed). Both run only on first launch — subsequent calls find the values present and short-circuit.
- Add
Electron renderer (adiuvAI/src/renderer/)
-
src/renderer/components/layout/AppShell.tsx:79-119 — add the first-run gate. After the
authStatusQuery.data?.authenticated === falsebranch:if (authStatusQuery.data?.profile && authStatusQuery.data.profile.onboardingCompletedAt == null) { return <OnboardingFlow profile={authStatusQuery.data.profile} />; } -
New:
src/renderer/components/onboarding/OnboardingFlow.tsx— the wizard. Internal state machine:type Step = 'welcome' | 'jobRole' | 'industry' | 'useCase' | 'tone' | 'language' | 'reviewing' | 'done';Renders chat-bubble layout matching AIChatPanel.tsx — Sparkles icon,
rounded-2xl, glassmorphism, spring transitions per the design context inadiuvAI/.claude/CLAUDE.md. Each step shows an "AI" bubble with the question, 3–6 chip presets, an optional "type your own" input, and a Skip link.The
languagestep pre-selects the value already inprofile.memory.language(auto-seeded). User confirms or picks a different one.reviewingstep (the LLM normalization gate):- On entry, partition the user's answers into two groups:
- Chip selections — already canonical, skip the LLM entirely.
- Free-text answers — bundle into a
{key: rawText}map.
- If the free-text map is non-empty, call
trpc.auth.normalizeOnboarding.useMutationwith it. Show a small inline loader on those fields only ("Tidying up…", ~1-2s).
Review screen UX — single card titled "Here's what I'll save", listing all five fields as rows:
Row appearance When Read-only label + value Chip-selected values ( use_case,tone, etc.) — checkmark iconRead-only label + value + small grey hint auto-tidied from "i build websites"Free-text values that the LLM normalized Read-only label + value Free-text values that the LLM did NOT change Each row has a small Edit pencil icon on the right. Clicking it converts that row in-place into a text input (or a Select for chip-based fields like
tone/use_case, populated with the same chip presets plus a free-text "Other" option). The user types the new value, presses Enter or clicks Save → the row goes back to read-only with the new value as-typed.Edited values are stored verbatim — no re-normalization. Rationale: the LLM normalization exists to clean up the initial messy answer; once the user has seen the suggestion and chosen to override it, re-running the LLM would either no-op (their text is already clean) or fight them. The user is the final arbiter. The "auto-tidied from…" hint disappears once a row is edited (the new value is no longer LLM-derived).
Bottom of the card: a single primary "Looks good — save" button → calls
trpc.auth.updateMemory.useMutationwith the final map +markOnboarded: true→utils.auth.status.invalidate()→ AppShell remounts into the normal app. A secondary "Back to wizard" link drops the user back to the first wizard step (jobRole) with all current values pre-filled — used when the review reveals the answers are wrong enough that re-running the wizard is faster than five inline edits.Failure modes:
- Normalization HTTP call fails → review screen shows raw values with a small banner "Couldn't auto-tidy — review and save". Save still works.
- User clicks "Looks good — save" and
updateMemoryfails → toast error, user stays on the review screen, can retry.
Skip behaviour: clicking Skip on any step calls
updateMemory({}, markOnboarded: true)— empty map, just the flag. We don't re-prompt next launch. - On entry, partition the user's answers into two groups:
-
New:
src/renderer/components/onboarding/onboardingOptions.ts— preset chip lists:export const JOB_ROLES = ['Developer', 'Designer', 'Consultant', 'Founder', 'Project Manager']; export const INDUSTRIES = ['Tech', 'Design', 'Consulting', 'Legal', 'Marketing', 'Education']; export const USE_CASES = ['Solo freelancer', 'Client manager', 'Team lead', 'Personal productivity']; export const TONES = ['Casual', 'Formal', 'Concise', 'Detailed']; -
src/renderer/components/settings/types.ts:3-9 — add
'profile'toSectionIdand{ id: 'profile', label: 'Profile' }toSECTIONS(before'account'). -
New:
src/renderer/components/settings/ProfileSection.tsx— Settings → Profile editor. Plain form (no chat aesthetic in Settings). Two cards:- "About you" (writes to
MemoryCoreviaauth.updateMemory): job_role, industry, primary_use_case, tone_preference, language. "Re-run onboarding" button → small backend routePOST /auth/onboarding/reset(or just an extension ofupdate_memorywithclear_onboarded: true) that nullsusers.onboarding_completed_at, thenauth.status.invalidate()remounts the wizard. - "Display preferences" (writes to electron-store via a new
trpc.settings.setFormatPrefsmutation): timezone (select populated fromIntl.supportedValuesOf('timeZone')), date_format (select: dd/MM/yyyy, MM/dd/yyyy, yyyy-MM-dd), time_format (radio: 12h / 24h).
- "About you" (writes to
-
src/main/router/index.ts —
settingsRouter: addgetFormatPrefsquery andsetFormatPrefsmutation that read/write to electron-store viagetFormatPrefs()/setFormatPrefs(). -
src/renderer/routes/settings.tsx:55-58 — add
{section === 'profile' && <ProfileSection />}.
Patterns to reuse (do not duplicate)
- Stepper state:
InlineAgentCreationStepper—useState<...>plus conditional rendering. - Chat bubble aesthetic: copy bubble + Sparkles + glass styling from AIChatPanel.tsx. Do not invent a new chat shell.
- Form components: shadcn
Field/Input/Select/Button/Cardalready used in existing settings sections. MemoryMiddleware.update_core(memory_middleware.py:137-173) — already used bydeep_agent.py:343. We just expose it via REST.get_llm()from api/app/core/llm.py — for the normalization route. Usegpt-4o-miniwithtemperature=0and JSON response format.toCamelCase/toSnakeCaseinauth-manager— handlesmark_onboarded↔markOnboardedautomatically.- electron-store helpers (store.ts:62-98) — same pattern as
getDeviceId/getLocalAgents.
Verification
-
Backend migration + tests:
cd api && alembic upgrade head pytest tests/test_auth.py -k "memory or normalize"Manually
curl PUT /api/v1/auth/me/memorywith{"memory": {"job_role":"Developer"}, "mark_onboarded": true}and confirm round-trip viaGET /api/v1/auth/me. -
LLM normalization route:
curl POST /api/v1/auth/onboarding/normalizewith{"inputs": {"job_role": "i build websites", "industry": "tech-ish stuff"}}.- Expect
{"normalized": {"job_role": "Web Developer", "industry": "Technology"}}(or similar — exact phrasing varies). - Stop the LLM provider (or use an invalid
OPENAI_API_KEY) and re-run — must return inputs unchanged, never 500.
-
Locale auto-seed (FE + BE):
- Fresh user, fresh electron-store. Log in via Electron.
getFormatPrefs()should now return the detected{timezone, dateFormat, timeFormat}.memory_coreshould have one row:language.- Reload app → no second seed call (idempotent).
-
First-run wizard (golden path with chips only):
- Reset: backend
UPDATE users SET onboarding_completed_at=NULL; DELETE FROM memory_core WHERE user_id=.... FE: clear electron-storeformatPrefs. npm start→ log in → land onOnboardingFlow.- Pick a chip on every step (no free text). Confirm language. Land on review screen — should not show a loading spinner (no normalization needed). Click Confirm.
auth.statusinvalidates, AppShell mounts the home chat.SELECT key FROM memory_core WHERE user_id=...→ 5 keys (job_role, industry, primary_use_case, tone_preference, language).- Reload app → does not re-prompt.
- Reset: backend
-
First-run wizard (free-text path):
- Reset. Walk through wizard typing free text on
job_roleandindustry(e.g. "i build websites", "tech-ish stuff"). - On final step, see ~1-2s "Tidying up…" spinner, then a review screen showing the normalized values plus the chip-selected use_case/tone/language.
- Edit one normalized value manually. Confirm. The edited value is what lands in
memory_core.
- Reset. Walk through wizard typing free text on
-
AI uses the data (proof the wiring works):
- With an onboarded user whose
tone_preference="Formal"andlanguage="it-IT", ask the home chat "draft a quick status email". - Response should be in Italian and read formal. If language doesn't match,
enrich_contextis not feedingcore_memoryinto the prompt as expected — investigate before declaring done. This is the single most likely failure point because we don't modify it.
- With an onboarded user whose
-
Format prefs reach the LLM as strings:
- From the home chat, ask "what tasks are due this week?".
- Inspect the network/log of the
tool_resultframe the FE sends back. EverydueDatefield must be a formatted string like"15/04/2026 14:30", not a numeric timestamp. - The AI's response must reference dates in the user's preferred format.
- Change
time_formatfrom 24h to 12h in Settings → Profile. Re-ask. Times should now be2:30 PMstyle.
-
Skip flow:
- Reset, log in, click Skip on step 1.
users.onboarding_completed_atset;memory_coreonly haslanguage(from auto-seed).- Reload → no re-prompt.
-
Re-run onboarding: Settings → Profile → "Re-run onboarding" → wizard mounts immediately.
-
Lint:
cd adiuvAI && source ~/.nvm/nvm.sh && npm run lint cd api && ruff check .
Out of scope (deferred)
- UI internationalisation framework: storing
languageenables future i18n, but no translation library is added. Wizard copy hardcoded in English for v1. - Avatar upload control in Settings → Profile: avatar already comes from Google OAuth via
users.avatar_url. A manual upload UI is a nice-to-have follow-up. - Working hours, top goals (free-text seeds): same
MemoryCorepattern — easy to add later. - Cross-device sync of format prefs: v1 stores them per-device in electron-store. Migrating to
MemoryCorelater doesn't break the wire format. - Schema-bump re-prompting: when we add a new wizard question later we'll need a
core_memory["__onboarding_version__"]key and a guard. Not needed now. - Animated typing effect on AI bubbles.