Corteksa

Onboarding Agent

Onboarding Agent

This document covers the onboarding agent end to end, for everyone:

  • Business / product → the Overview explains what it is, why it exists, and the value it delivers.
  • Frontend / integration → from Auth & base path onward is the technical contract: endpoints, what to send, what comes back.

It is a server-driven onboarding form: the backend decides every question and its render type; the frontend is a thin renderer — it draws the widget for step.type and never hard-codes the question list or order. Adding or removing a question is a backend change only.

The transport is WebSocket-only: a single Socket.IO namespace drives the whole flow as a live conversation. There are no REST endpoints — connecting the socket delivers everything (the greeting + first question, or the terminal outcome if onboarding is already done, or a rejection if the flag is off / the user isn't the owner), so no pre-socket status call is needed.

Overview — what this is & why it exists

When someone signs up and lands in a brand-new, empty workspace, they hit a blank page and don't know what to do. The onboarding agent removes that friction: an AI-guided first-run experience that greets the new owner, asks a few short questions one at a time, and turns their answers into a ready-to-use workspace.

What it does for the business

  • Faster activation — the user goes from signup to a usable, personalized CRM in a couple of minutes instead of staring at an empty screen.
  • Captures context once — industry, role, team size, and current tools are collected up front, so the workspace (and later the AI assistant) can be tailored to how they actually work.
  • Guides the setup choice — at the end the user picks how to build their workspace: a ready template, build with AI, or start empty. That choice is handed off to the workspace builder.
  • Account setup — they verify their WhatsApp number (required, for alerts and sign-in) and can invite teammates, all in the same flow.

How it works (high level)

  1. The user is greeted by name, then the AI asks a question → the user answers → the next question appears — one at a time, like a chat. They can step back to revise a previous answer.
  2. Along the way: verify WhatsApp (we send a code, they enter it — required, but skippable if the code can't be delivered) and optionally invite teammates by email.
  3. At the end the user chooses how to set up the workspace; onboarding records the choice, hands it off to the builder, and marks itself done so it never shows again.
  4. The user can skip the questions at any point to jump toward that setup choice; the skip stops at the required WhatsApp step until it's verified (or a failed send releases it). Choosing "Start empty" there is the "I'll do this later" path.

The AI is used where it adds value — understanding free-text/"Other" answers and writing the friendly confirmation summary — while the questions themselves are a fast, reliable guided form. The rest of this document is the technical contract the frontend builds against.

📡 WebSocket-only. Onboarding is driven entirely over the Socket.IO namespace below — there is no REST controller, so it does not appear in the backend's /docs (that scan covers HTTP routes only). This document is the contract; the event protocol below is the source of truth.

Auth & base path

  • The connection requires the workspace owner's admin Bearer token in the handshake (see below). On connect the server rejects with an onboarding:error
    • disconnect: non-owner → FORBIDDEN; missing/invalid token → AUTH_MISSING / AUTH_INVALID; feature flag AI_V2_ONBOARDING_ENABLED off → FEATURE_DISABLED.
  • Event payloads are the raw data shapes described below (no HTTP envelope).

Real-time over WebSocket (primary transport)

A Socket.IO namespace that drives the whole flow as a live conversation: connect, get greeted, answer, advance — no polling.

  • Namespace: /ai-v2/onboarding (Socket.IO; websocket transport only).
  • Auth: pass the owner's admin Bearer token in the handshake — any of auth: { token } (preferred), ?token=…, or the Authorization: Bearer … header. The gates apply on connect: feature flag off → an onboarding:error (FEATURE_DISABLED) then disconnect; a non-owner → onboarding:error (FORBIDDEN) then disconnect; bad/missing token → AUTH_INVALID/AUTH_MISSING then disconnect.
  • On connect the server first emits onboarding:ready { agent: 'onboarding' } — the mode signal, so a single shared AI component knows it is talking to the onboarding agent (vs the general assistant on /ai/assistant) and switches its UI accordingly. It then immediately emits the current view — either an onboarding:question (with the greeting on Q1) or, if onboarding is already finished, an onboarding:outcome. You don't ask for the first question; it arrives. This is fully resumable: reconnecting re-emits ready + the current step.

Client → server events

EventPayloadEffect
onboarding:answer{ step_key, value }Validate + advance (drives phone/OTP too)
onboarding:back(none)Go to the previous question
onboarding:skip(none)Jump to the setup choice (template)
onboarding:resend(none)Resend the WhatsApp OTP (on the code step)
onboarding:confirm(none)Ask for the AI profile summary
onboarding:refresh(none)Re-emit the current view (explicit resync)

Server → client events

EventPayload
onboarding:ready{ agent: 'onboarding' } — mode signal, emitted on connect
onboarding:question{ done:false, greeting?, message, step, answer?, progress }
onboarding:outcome{ done:true, status, path, next }
onboarding:summary{ summary, profile } (reply to onboarding:confirm)
onboarding:error{ code, message } (message is user-safe)

Error codes: INVALID_PAYLOAD, INVALID_ANSWER, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, FEATURE_DISABLED, AUTH_MISSING, AUTH_INVALID, ERROR.

The render loop

  1. Connect the socket (token in the handshake). On success the server emits onboarding:ready { agent: 'onboarding' } (the mode signal — switch the single AI component into onboarding), immediately followed by the current view: onboarding:question (start/resume the form) or onboarding:outcome if onboarding is already finished (route into the app — it never re-asks question 1). A rejection (onboarding:error + disconnect) means the flag is off or the user isn't the owner → go to the assistant. No separate status call is needed.
  2. Render onboarding:question — draw step by step.type, show message as the assistant bubble and progress. On the first question the payload also carries greeting — a personalized welcome ("Hi Layla 👋 Welcome to Corteksa AI…"); render it as the opening bubble. Present only on question 1.
  3. User answers → emit onboarding:answer { step_key, value } (see value shapes below) → listen for the next onboarding:question or the terminal onboarding:outcome.
  4. To revise the last answer, emit onboarding:back → the previous question (clamped at question 1, steps back over the OTP step). The payload carries the answer the user gave for that step — re-render it so nothing is lost (the client keeps no history; the server is the source of truth). answer rides on every onboarding:question, so a resume/refresh also re-shows prior answers.
  5. Repeat until an onboarding:outcome arrives, then route by its next.type.
  6. At any point emit onboarding:skip to jump toward the setup choice (the template chooser), keeping any answers. WhatsApp verification is required, so the jump clamps at the phone step — it only passes phone once verified, or once a code failed to send (the "or skip" the failure message offers). "Start empty" at the setup choice is the "I'll do this later" path.
  7. Other events: onboarding:resend (resend the WhatsApp OTP on the code step), onboarding:confirmonboarding:summary (the profile summary before the final choice), onboarding:refresh → re-emit the current view.

Reconnecting re-emits onboarding:ready + the current view, so the flow always resumes where it left off.

What to send as value (per step.type)

step.typeExample step keysvalue to send
textname, phone_otp"Layla" (a string). For phone_otp it's the 4-digit numeric code (e.g. "4821") — render a 4-box PIN input (step.maxLength is 4, placeholder is "4-digit code")
radioindustry, role, team_size, setup_choice"Real Estate" (an option value). If step.allowOther and the user typed their own: { "value": "Other", "other": "free text" }
checkboxtool_integrations["Excel", "WhatsApp"] (array of option values)
phonephone"+905551112233" — required; a submitted number must be non-empty (empty is rejected). Only becomes skippable if the code fails to send (see the sub-flow below)
inviteinvite["a@x.com"] or [{ "email": "a@x.com", "role": "Sales" }] — or [] to skip

Any skippable step (step.skippable === true) accepts an empty value ("", [], or omit) to skip it.

Phone verification sub-flow

  • WhatsApp verification is requiredphone and phone_otp are not freely skippable, and step.skippable is false on both.
  • phone and phone_otp reject an empty submitted value (400) and keep the cursor on the step.
  • Submitting phone with a number makes the server send a WhatsApp OTP and return the phone_otp step.
  • If the code fails to send (a transport error, not an invalid-number 400), the server returns "We could not send a code to that number. Check it and try again, or skip." and unlocks the skip — from that point onboarding:skip passes the phone step, so a user whose code never arrives is not stuck. An invalid number (structured 400) is not skippable: fix the number and retry.
  • The sent code is a 4-digit numeric OTP (10009999) that expires in 3 minutes. Submit it as the phone_otp answer; it must verify to advance. A wrong or missing code returns 400 — stay on the step and let the user retry.
  • Emitting onboarding:resend resends the code (valid only while on phone_otp).

Response payloads

Question (onboarding:question)

{
  "done": false,
  "message": "What kind of business are you running?",
  "step": {
    "key": "industry",
    "type": "radio",
    "prompt": "What kind of business are you running?",
    "required": false,
    "skippable": true,
    "allowOther": true,
    "options": [
      { "value": "Real Estate", "label": "Real Estate" },
      { "value": "Retail", "label": "Retail / E-commerce" }
    ],
    "placeholder": null,
    "maxLength": null,
    "defaultCountry": null,
    "verify": null,
    "maxRows": null,
    "roleDefault": null
  },
  "answer": { "kind": "radio", "value": "Retail" },
  "progress": { "current": 2, "total": 9 }
}

step is the full render contract — render only the fields relevant to type (options for radio/checkbox, placeholder/maxLength for text/phone, maxRows/roleDefault for invite, defaultCountry/verify for phone). On the first question only, the payload also includes a top-level greeting string (the welcome bubble) — absent on every later question.

answer is the value already stored for this step, present when the user has answered it before (e.g. after stepping back, or on a resume) and absent otherwise. Its shape is the stored-answer union keyed by kind: { kind:'text', value }, { kind:'radio', value, other? }, { kind:'checkbox', values }, or { kind:'invite', invites }. Use it to pre-fill the control. The side-effect phone/phone_otp steps are never recorded, so they never carry answer.

Outcome (onboarding:outcome — terminal, after setup_choice or onboarding:skip)

{
  "done": true,
  "status": "handed_off_pending",
  "path": "template",
  "next": { "type": "pending" }
}

Route by next.type:

  • builder — go to the Workspace Builder (when that agent is live). For path: "template" this is where the user browses & installs an object template (see The template path).
  • pending — Builder not ready yet; offer "start empty / we'll set up shortly".
  • empty_workspace — open today's empty CRM (chose "start empty" or skipped).

statuscompleted | handed_off | handed_off_pending | skipped. pathtemplate | ai_build | zero | null (null when skipped).

On connect, if the onboarding_setup row is already at a terminal value (completed | handed_off | handed_off_pending | skipped) the server emits onboarding:outcome instead of a question — so the client learns "onboarding is done" from the connect itself, with no separate status call. While the form is in progress no row exists yet (it's written only at the terminal step).

The template path — installing a ready object template

The final setup_choice question offers three options; template ("Use a ready template") is the recommended one. It does not build anything inside onboarding — onboarding just records the choice and hands off:

  • Picking template records path: "template" and hands the profile off to the Workspace Builder, exactly like ai_build does (both are building paths; only zero lands in an empty workspace). The terminal onboarding:outcome carries path: "template" with next.type = builder (Builder live) or pending (Builder off — offer "start empty").
  • The onboarding layer records which path was chosen but does not itself pick or install a template — the actual browse-and-install happens after the handoff, in the Builder's "From templates" flow, against the object-template catalog below.

What an "object template" is

A published, reusable workspace blueprint in a super-admin-curated catalog: a named schema of CRM objects (Contact, Deal, …), their fields, relations, and views. Templates are authored/snapshotted by super-admins (and can be shared out of a completed AI build — see the Workspace Builder doc), but stay hidden until a super-admin publishes them. Only published templates are offered to users.

Catalog endpoints (REST, not WebSocket)

Unlike the onboarding form, the template catalog is plain REST. The user-facing reads and the install action are:

Method & pathAuthPurpose
GET /api/v1/object-template/suggestionsuser JWTList published templates the user can install ({ name, slug, category, description }, ordered by popularity).
GET /api/v1/object-template/suggestions/:sluguser JWTFull published template (incl. its schema) for a preview.
POST /api/v1/object-template/:slug/installadmin + object.createInstall the template into the caller's live workspace.

Installing (POST /object-template/:slug/install)

Creates the template's objects/fields/relations in the caller's workspace using the same runtime services normal object creation uses, so everything is immediately usable. It is additive and best-effort:

  • An object whose name already exists is skipped, never merged or overwritten.
  • The default fields every object gets automatically (name, status, description, tag) are not re-created.
  • A single field/relation failure is isolated and logged — it does not abort the whole install.
  • Every write runs in the request's workspace scope (RLS-pinned to the caller on hyper-tenant), which is why it carries the same object.create gate as manual object creation (super-admins bypass) rather than being a catalog-write.

Response is a count summary (not entities), so the client can show "installed N objects":

{
  "message": "Template installed successfully",
  "data": {
    "template_slug": "real-estate-crm",
    "template_name": "Real Estate CRM",
    "objects_created": 4,
    "objects_skipped": 1,
    "fields_created": 23,
    "relations_created": 3
  }
}

Known limitation: because an existing object is skipped and its default fields are not re-applied, a template's customized values on a default field (e.g. custom status options) are not reapplied onto an object that already exists — only the brand-new objects it adds install in full.

The ai_build path — building a workspace with AI

ai_build ("Build with AI") is the second building path. Like template it does not build anything inside onboarding: picking it records path: "ai_build" and hands the profile off to the Workspace Builder, and the terminal onboarding:outcome carries path: "ai_build" with the same next.type (builder when the Builder is live, pending when it's off).

Where template installs a pre-made catalog schema, ai_build generates a fresh schema from a description — the user describes the system they need and the AI proposes objects, fields, relations, and views tailored to their business.

The Builder starts from the user's own words

Choosing ai_build routes the user into the conversational Builder, which opens on a welcome that asks them to describe their business — the user types what they run, and the AI interviews and proposes from that. The fields onboarding already collected (industry / role / team size) are not auto-injected into the Builder: the user states their business in their own words, so the plan reflects exactly what they ask for rather than a guess from the signup form.

Note: at the handoff level template and ai_build are handled identically — both flip the onboarding row to handed_off/handed_off_pending. The recorded path tells the frontend which experience to route into (browse the catalog vs. the conversational builder); the distinction is a routing decision on path, not different onboarding behavior.

The build itself (Workspace Builder, inline on /ai-v2/chat)

After the handoff the owner lands in the normal AI chat (/ai-v2/chat), where the Workspace Builder runs as an inline agent — there is no separate builder socket and no builder:* events (that standalone channel was retired). A build request routes to workspace_builder, which interviews the user, proposes an editable plan, and on approval builds the workspace — all through the one inline assistant:* contract:

  • interview questions and the plan ride assistant:confirmation_required (type: 'questions' / type: 'plan'); the user answers with assistant:send { answers } and confirms with assistant:confirm { approved, plan? },
  • the build streams assistant:tool_start / assistant:tool_result per write step and ends with a share gate (type: 'share') offering to publish the result as a template,
  • the plan and questions persist as content blocks on the conversation, so a reload of GET /ai-v2/conversations/:slug/messages re-renders them.

The build is a server-side promise that runs to completion regardless of disconnects, so a refresh just reconnects to /ai-v2/chat and replays the transcript — nothing to re-send.

The onboarding handoff only decides whether the builder owns the next step: with AI_V2_ENABLED on the row flips to handed_off and the user lands in chat; offhanded_off_pending and the user is offered "start empty" (FR-336).

Full contract: the inline wire protocol is in AI v2 INTEGRATION.md §5; the agent's behavior (interview strategy, plan schema, deterministic build, share → publish → install) is in the Workspace Builder doc (agents/workspace-builder/doc.md).

Confirm (onboarding:summary, reply to onboarding:confirm) — optional, show before setup_choice

{
  "summary": "Got it — a Real Estate business, you're in Sales, team of 6–10.",
  "profile": {
    "industry": "Real Estate",
    "role": "Sales",
    "teamSize": "6-10",
    "toolIntegrations": ["Excel", "WhatsApp"],
    "invites": [{ "email": "mate@x.com", "role": "Member" }]
  }
}

The question script (current order)

name → industry → role → team_size → tool_integrations → phone → phone_otp → invite → setup_choice

name, phone, phone_otp, and setup_choice are required; the rest are skippable. (phone/phone_otp only become skippable if a code fails to send — see the phone sub-flow.) The frontend should not hard-code this — render whatever type comes back on each onboarding:question event.

Idempotency notes (safe to retry)

  • onboarding:skip and onboarding:back are not terminal — they only move the cursor (skip jumps toward setup_choice, keeping any answers, but clamps at the required phone step until it is verified or a failed send releases it; back steps one question back). Onboarding ends only when setup_choice is answered. Both are safe to emit repeatedly; once onboarding is already finished they return the recorded outcome instead.
  • Re-submitting setup_choice returns the same recorded outcome — no duplicate workspace, no second handoff.
  • Once onboarding is terminal, is_new_user is cleared, so the app routes past onboarding on the next login.

On this page