[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build an AI-Powered Customer Onboarding Flow

Build an AI-Powered Customer Onboarding Flow

Replace a 14-step signup wizard with an AI-driven conversational onboarding that learns user intent, pre-fills forms, and cuts time-to-value from 25 minutes to under 4 minutes — doubling trial-to-paid conversion.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 7 skills

Avg quality 93/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

nextjs

v1.1.0

Assists with building production-grade React applications using Next.js 16. Use when working with the App Router, Server Components, Server Actions, the proxy file (formerly middleware), Cache Components, or deploying to Vercel or self-hosted environments. Trigger words: nextjs, next.js, app router, server components, server actions, use cache, proxy.ts, react framework, ssr, isr.

100/100 quality
5.29× impact
SAFE· stale score
View skill
>

ai-sdk

v1.1.0

The Vercel AI SDK is a TypeScript toolkit for calling LLMs from Node.js, Next.js, Nuxt, SvelteKit and edge runtimes through one API. It covers text generation, streaming, structured output with Zod, tool calling, agents (ToolLoopAgent) and chat UIs with useChat. Use when asked to "add AI to my Next.js app", "stream a chat response", "get typed JSON from an LLM", "switch between OpenAI, Anthropic and Gemini", or "build a tool-calling agent".

77/100 quality
5.47× impact
SAFE· stale score
View skill
>

prisma

v1.1.0

Prisma ORM is a TypeScript database toolkit with a declarative schema, a generated type-safe client, and a migration system for PostgreSQL, MySQL/MariaDB, SQLite, SQL Server, CockroachDB and MongoDB. Use when someone asks to "set up Prisma", "write a Prisma schema", "run a Prisma migration", "query with Prisma Client", "fix Prisma 7 adapter errors", or "upgrade from prisma-client-js".

100/100 quality
3.80× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
>

tailwindcss

v1.1.0

Tailwind CSS is a utility-first CSS framework: you style pages with small classes in the markup and ship only the CSS you use. Covers Tailwind v4 (CSS-first config with @theme, @import "tailwindcss", Vite and PostCSS plugins), responsive layouts, dark mode, custom animations, and the v3 to v4 renames. Use when someone asks to "style with Tailwind", "set up Tailwind v4", "upgrade Tailwind to v4", "add dark mode", or "fix Tailwind classes that stopped working".

93/100 quality
2.28× impact
SAFE· stale score
View skill
$

The Problem

Mei is head of growth at a B2B analytics SaaS. Their onboarding flow has 14 steps: account creation, company details, team invites, data source connections, dashboard configuration, and notification preferences. Analytics show 62% of users drop off before completing setup. Average completion time is 25 minutes. The ones who do finish wait 3 days before getting value because they misconfigure dashboards. Support tickets in the first week are 80% onboarding-related. Competitors with simpler products are winning deals because "it just works."

Mei needs:

  • Conversational onboarding — AI asks questions naturally, fills out configs behind the scenes
  • Intent detection — understand what the user actually wants to accomplish, not just collect form fields
  • Smart defaults — pre-configure dashboards based on the user's role, industry, and goals
  • Progressive disclosure — only ask what's needed now, defer the rest
  • Handoff to human — escalate to customer success when AI confidence is low
  • A/B testable — run the new flow against the old wizard with proper metrics

Step 1: Define the Onboarding State Machine

Model onboarding as a state machine, not a linear wizard. AI can jump between states based on what it learns.

typescript
// src/onboarding/state-machine.ts
// Onboarding states — AI navigates between them based on conversation

import { z } from 'zod';

export const OnboardingState = z.enum([
  'greeting',          // Initial — understand who they are
  'goal_discovery',    // What are they trying to accomplish?
  'company_profile',   // Industry, size, tech stack
  'data_sources',      // Connect their data
  'dashboard_config',  // Set up their first dashboard
  'team_setup',        // Invite teammates (optional, defer if solo)
  'notifications',     // Alert preferences
  'complete',          // Ready to use
  'handoff',           // Escalate to human CS
]);

export const UserProfile = z.object({
  role: z.enum(['founder', 'engineering', 'product', 'marketing', 'data', 'other']).optional(),
  companySize: z.enum(['solo', '2-10', '11-50', '51-200', '201-1000', '1000+']).optional(),
  industry: z.string().optional(),
  primaryGoal: z.string().optional(),
  techStack: z.array(z.string()).default([]),
  experienceLevel: z.enum(['beginner', 'intermediate', 'advanced']).optional(),
});

export const OnboardingContext = z.object({
  userId: z.string().uuid(),
  state: OnboardingState,
  profile: UserProfile,
  completedSteps: z.array(OnboardingState),
  skippedSteps: z.array(OnboardingState),
  confidence: z.number().min(0).max(1).default(1),
  conversationHistory: z.array(z.object({
    role: z.enum(['user', 'assistant', 'system']),
    content: z.string(),
    timestamp: z.string().datetime(),
  })),
  configuredResources: z.object({
    dataSources: z.array(z.string()).default([]),
    dashboards: z.array(z.string()).default([]),
    teamMembers: z.array(z.string()).default([]),
  }),
  startedAt: z.string().datetime(),
});

export type OnboardingContext = z.infer<typeof OnboardingContext>;
export type OnboardingState = z.infer<typeof OnboardingState>;

// State transition rules — which states can follow which
export const transitions: Record<string, string[]> = {
  greeting: ['goal_discovery', 'company_profile'],
  goal_discovery: ['company_profile', 'data_sources', 'dashboard_config'],
  company_profile: ['data_sources', 'dashboard_config', 'goal_discovery'],
  data_sources: ['dashboard_config', 'team_setup', 'notifications'],
  dashboard_config: ['team_setup', 'notifications', 'complete'],
  team_setup: ['notifications', 'complete'],
  notifications: ['complete'],
  complete: [],
  handoff: [],
};

Step 2: AI Onboarding Agent

The agent uses structured output to decide what to ask, what to configure, and when to advance.

typescript
// src/onboarding/agent.ts
// AI agent that drives the onboarding conversation

import { generateObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
import type { OnboardingContext } from './state-machine';
import { transitions } from './state-machine';

const AgentAction = z.object({
  response: z.string().describe('What to say to the user'),
  extractedInfo: z.object({
    role: z.string().optional(),
    companySize: z.string().optional(),
    industry: z.string().optional(),
    primaryGoal: z.string().optional(),
    techStack: z.array(z.string()).optional(),
  }).describe('Info extracted from user message'),
  nextState: z.string().optional().describe('State to transition to, if ready'),
  actionsToTake: z.array(z.object({
    type: z.enum(['create_dashboard', 'connect_source', 'invite_member', 'set_preference', 'skip_step']),
    params: z.record(z.string(), z.unknown()),
  })).describe('Background actions to perform'),
  confidence: z.number().min(0).max(1).describe('Confidence in understanding user needs'),
  shouldHandoff: z.boolean().describe('True if human CS should take over'),
});

export async function processMessage(
  ctx: OnboardingContext,
  userMessage: string
): Promise<{
  response: string;
  updatedContext: OnboardingContext;
  actions: Array<{ type: string; params: Record<string, unknown> }>;
}> {
  const systemPrompt = buildSystemPrompt(ctx);

  const { object: action } = await generateObject({
    model: openai('gpt-4o-mini'),  // fast + cheap for onboarding
    schema: AgentAction,
    system: systemPrompt,
    messages: [
      ...ctx.conversationHistory.map(m => ({
        role: m.role as 'user' | 'assistant',
        content: m.content,
      })),
      { role: 'user', content: userMessage },
    ],
    temperature: 0.3,  // low temperature for consistent extraction
  });

  // Update context with extracted info
  const updatedProfile = { ...ctx.profile };
  if (action.extractedInfo.role) updatedProfile.role = action.extractedInfo.role as any;
  if (action.extractedInfo.companySize) updatedProfile.companySize = action.extractedInfo.companySize as any;
  if (action.extractedInfo.industry) updatedProfile.industry = action.extractedInfo.industry;
  if (action.extractedInfo.primaryGoal) updatedProfile.primaryGoal = action.extractedInfo.primaryGoal;
  if (action.extractedInfo.techStack?.length) {
    updatedProfile.techStack = [...new Set([...updatedProfile.techStack, ...action.extractedInfo.techStack])];
  }

  // Determine next state
  let nextState = ctx.state;
  if (action.shouldHandoff) {
    nextState = 'handoff';
  } else if (action.nextState && transitions[ctx.state]?.includes(action.nextState)) {
    nextState = action.nextState as any;
  }

  const updatedContext: OnboardingContext = {
    ...ctx,
    state: nextState,
    profile: updatedProfile,
    confidence: action.confidence,
    completedSteps: action.nextState && action.nextState !== ctx.state
      ? [...ctx.completedSteps, ctx.state]
      : ctx.completedSteps,
    conversationHistory: [
      ...ctx.conversationHistory,
      { role: 'user', content: userMessage, timestamp: new Date().toISOString() },
      { role: 'assistant', content: action.response, timestamp: new Date().toISOString() },
    ],
  };

  return {
    response: action.response,
    updatedContext,
    actions: action.actionsToTake,
  };
}

function buildSystemPrompt(ctx: OnboardingContext): string {
  return `You are an onboarding assistant for DataPulse, a B2B analytics platform.

Current state: ${ctx.state}
User profile so far: ${JSON.stringify(ctx.profile, null, 2)}
Completed steps: ${ctx.completedSteps.join(', ') || 'none'}
Possible next states: ${transitions[ctx.state]?.join(', ') || 'none'}

Your job:
1. Have a natural conversation — don't interrogate with a list of questions
2. Extract user info from their messages (role, company, goals, tech stack)
3. When you have enough context, pre-configure their workspace behind the scenes
4. Skip steps that aren't relevant (solo founder doesn't need team invites)
5. If the user seems confused or frustrated, set shouldHandoff=true
6. Keep responses short (2-3 sentences max) and actionable

Key behaviors:
- After learning their goal, suggest a pre-built dashboard template
- If they mention a data source (PostgreSQL, Stripe, etc.), offer to connect it
- Don't ask about notifications until dashboards are set up
- If company size is "solo" or "2-10", skip team_setup entirely`;
}

Step 3: Smart Defaults Engine

Pre-configure dashboards and settings based on the user's profile instead of making them choose from scratch.

typescript
// src/onboarding/smart-defaults.ts
// Generates pre-configured dashboards based on user profile

import type { UserProfile } from './state-machine';

interface DashboardTemplate {
  name: string;
  description: string;
  widgets: Array<{
    type: string;
    title: string;
    dataSource: string;
    metric: string;
    visualization: string;
  }>;
}

export function generateDefaultDashboards(profile: UserProfile): DashboardTemplate[] {
  const dashboards: DashboardTemplate[] = [];

  // Every user gets a KPI overview
  dashboards.push({
    name: 'KPI Overview',
    description: 'Your key metrics at a glance',
    widgets: [
      { type: 'metric', title: 'Active Users', dataSource: 'auto', metric: 'active_users', visualization: 'number' },
      { type: 'chart', title: 'Revenue Trend', dataSource: 'auto', metric: 'revenue', visualization: 'line' },
      { type: 'metric', title: 'Churn Rate', dataSource: 'auto', metric: 'churn_rate', visualization: 'gauge' },
    ],
  });

  // Role-specific dashboards
  if (profile.role === 'engineering' || profile.role === 'founder') {
    dashboards.push({
      name: 'Engineering Health',
      description: 'Deployment frequency, error rates, performance',
      widgets: [
        { type: 'chart', title: 'Deploy Frequency', dataSource: 'auto', metric: 'deploys_per_day', visualization: 'bar' },
        { type: 'chart', title: 'Error Rate', dataSource: 'auto', metric: 'error_rate_5xx', visualization: 'line' },
        { type: 'metric', title: 'P95 Latency', dataSource: 'auto', metric: 'p95_latency_ms', visualization: 'number' },
        { type: 'chart', title: 'Uptime', dataSource: 'auto', metric: 'uptime_percent', visualization: 'area' },
      ],
    });
  }

  if (profile.role === 'product' || profile.role === 'marketing') {
    dashboards.push({
      name: 'Product Analytics',
      description: 'User behavior, funnels, retention',
      widgets: [
        { type: 'chart', title: 'Signup Funnel', dataSource: 'auto', metric: 'signup_funnel', visualization: 'funnel' },
        { type: 'chart', title: 'Feature Usage', dataSource: 'auto', metric: 'feature_usage', visualization: 'heatmap' },
        { type: 'chart', title: 'Retention Cohorts', dataSource: 'auto', metric: 'retention_weekly', visualization: 'cohort' },
      ],
    });
  }

  if (profile.role === 'marketing') {
    dashboards.push({
      name: 'Marketing Performance',
      description: 'Campaign ROI, acquisition channels, CAC',
      widgets: [
        { type: 'chart', title: 'Acquisition Channels', dataSource: 'auto', metric: 'signups_by_channel', visualization: 'pie' },
        { type: 'metric', title: 'CAC', dataSource: 'auto', metric: 'customer_acquisition_cost', visualization: 'number' },
        { type: 'chart', title: 'Campaign ROI', dataSource: 'auto', metric: 'campaign_roi', visualization: 'bar' },
      ],
    });
  }

  // Industry-specific additions
  if (profile.industry?.toLowerCase().includes('ecommerce') || profile.industry?.toLowerCase().includes('retail')) {
    dashboards.push({
      name: 'E-Commerce Metrics',
      description: 'GMV, AOV, cart abandonment',
      widgets: [
        { type: 'metric', title: 'GMV', dataSource: 'auto', metric: 'gross_merchandise_value', visualization: 'number' },
        { type: 'metric', title: 'Average Order Value', dataSource: 'auto', metric: 'aov', visualization: 'number' },
        { type: 'chart', title: 'Cart Abandonment', dataSource: 'auto', metric: 'cart_abandonment_rate', visualization: 'line' },
      ],
    });
  }

  return dashboards;
}

// Suggest data sources based on tech stack mentioned in conversation
export function suggestDataSources(profile: UserProfile): string[] {
  const suggestions: string[] = [];
  const stack = profile.techStack.map(s => s.toLowerCase());

  if (stack.some(s => s.includes('postgres'))) suggestions.push('postgresql');
  if (stack.some(s => s.includes('mysql'))) suggestions.push('mysql');
  if (stack.some(s => s.includes('mongo'))) suggestions.push('mongodb');
  if (stack.some(s => s.includes('stripe'))) suggestions.push('stripe');
  if (stack.some(s => s.includes('segment'))) suggestions.push('segment');
  if (stack.some(s => s.includes('amplitude'))) suggestions.push('amplitude');
  if (stack.some(s => s.includes('mixpanel'))) suggestions.push('mixpanel');
  if (stack.some(s => s.includes('google') || s.includes('ga'))) suggestions.push('google-analytics');
  if (stack.some(s => s.includes('shopify'))) suggestions.push('shopify');
  if (stack.some(s => s.includes('hubspot'))) suggestions.push('hubspot');

  return suggestions;
}

Step 4: Action Executor

Processes the AI's background actions — creating dashboards, connecting sources, etc.

typescript
// src/onboarding/action-executor.ts
// Executes background actions decided by the AI agent

import { PrismaClient } from '@prisma/client';
import { generateDefaultDashboards, suggestDataSources } from './smart-defaults';
import type { OnboardingContext } from './state-machine';

const prisma = new PrismaClient();

interface Action {
  type: string;
  params: Record<string, unknown>;
}

export async function executeActions(
  ctx: OnboardingContext,
  actions: Action[]
): Promise<OnboardingContext> {
  let updated = { ...ctx };

  for (const action of actions) {
    switch (action.type) {
      case 'create_dashboard': {
        const templates = generateDefaultDashboards(ctx.profile);
        const template = action.params.template as string;
        const match = templates.find(t =>
          t.name.toLowerCase().includes(template?.toLowerCase() ?? '')
        ) ?? templates[0];

        if (match) {
          const dashboard = await prisma.dashboard.create({
            data: {
              userId: ctx.userId,
              name: match.name,
              description: match.description,
              config: JSON.stringify(match.widgets),
              isDefault: updated.configuredResources.dashboards.length === 0,
            },
          });
          updated.configuredResources.dashboards.push(dashboard.id);
        }
        break;
      }

      case 'connect_source': {
        const sourceType = action.params.type as string;
        // Create a pending connection — user will complete OAuth/credentials later
        const source = await prisma.dataSource.create({
          data: {
            userId: ctx.userId,
            type: sourceType,
            status: 'pending_auth',
            config: JSON.stringify(action.params),
          },
        });
        updated.configuredResources.dataSources.push(source.id);
        break;
      }

      case 'invite_member': {
        const email = action.params.email as string;
        if (email) {
          await prisma.teamInvite.create({
            data: {
              invitedBy: ctx.userId,
              email,
              role: (action.params.role as string) ?? 'viewer',
              status: 'pending',
            },
          });
          updated.configuredResources.teamMembers.push(email);
        }
        break;
      }

      case 'skip_step': {
        const step = action.params.step as string;
        if (step && !updated.skippedSteps.includes(step as any)) {
          updated.skippedSteps.push(step as any);
        }
        break;
      }

      case 'set_preference': {
        await prisma.userPreference.upsert({
          where: { userId_key: { userId: ctx.userId, key: action.params.key as string } },
          update: { value: JSON.stringify(action.params.value) },
          create: {
            userId: ctx.userId,
            key: action.params.key as string,
            value: JSON.stringify(action.params.value),
          },
        });
        break;
      }
    }
  }

  return updated;
}

Step 5: Conversational UI Component

typescript
// src/components/onboarding-chat.tsx
// Conversational onboarding interface

'use client';

import { useState, useRef, useEffect } from 'react';
import type { OnboardingContext } from '@/onboarding/state-machine';

export function OnboardingChat({ initialContext }: { initialContext: OnboardingContext }) {
  const [ctx, setCtx] = useState(initialContext);
  const [input, setInput] = useState('');
  const [loading, setLoading] = useState(false);
  const messagesEnd = useRef<HTMLDivElement>(null);

  useEffect(() => {
    messagesEnd.current?.scrollIntoView({ behavior: 'smooth' });
  }, [ctx.conversationHistory.length]);

  async function sendMessage() {
    if (!input.trim() || loading) return;
    setLoading(true);

    const res = await fetch('/api/onboarding/message', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ context: ctx, message: input }),
    });

    const { response, updatedContext, actions } = await res.json();
    setCtx(updatedContext);
    setInput('');
    setLoading(false);
  }

  const progress = (ctx.completedSteps.length / 6) * 100;

  return (
    <div className="flex flex-col h-screen max-w-2xl mx-auto">
      {/* Progress bar — subtle, not a numbered stepper */}
      <div className="h-1 bg-gray-200">
        <div
          className="h-full bg-blue-500 transition-all duration-500"
          style={{ width: `${progress}%` }}
        />
      </div>

      {/* Messages */}
      <div className="flex-1 overflow-y-auto p-6 space-y-4">
        {ctx.conversationHistory.map((msg, i) => (
          <div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
            <div className={`max-w-md px-4 py-3 rounded-2xl ${
              msg.role === 'user'
                ? 'bg-blue-500 text-white'
                : 'bg-gray-100 text-gray-900'
            }`}>
              {msg.content}
            </div>
          </div>
        ))}

        {loading && (
          <div className="flex justify-start">
            <div className="bg-gray-100 px-4 py-3 rounded-2xl">
              <span className="animate-pulse">Thinking...</span>
            </div>
          </div>
        )}
        <div ref={messagesEnd} />
      </div>

      {/* Input */}
      {ctx.state !== 'complete' && (
        <div className="p-4 border-t">
          <div className="flex gap-2">
            <input
              value={input}
              onChange={(e) => setInput(e.target.value)}
              onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
              placeholder="Type your message..."
              className="flex-1 px-4 py-3 border rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500"
              disabled={loading}
            />
            <button
              onClick={sendMessage}
              disabled={loading || !input.trim()}
              className="px-6 py-3 bg-blue-500 text-white rounded-full disabled:opacity-50"
            >
              Send
            </button>
          </div>
          <button
            onClick={() => sendMessage()}
            className="mt-2 text-sm text-gray-500 hover:text-gray-700"
          >
            Skip to dashboard →
          </button>
        </div>
      )}

      {/* Completion screen */}
      {ctx.state === 'complete' && (
        <div className="p-8 text-center">
          <h2 className="text-2xl font-bold mb-2">You're all set! 🎉</h2>
          <p className="text-gray-600 mb-6">
            We've configured {ctx.configuredResources.dashboards.length} dashboard(s)
            based on your goals.
          </p>
          <a
            href="/dashboard"
            className="inline-block px-8 py-3 bg-blue-500 text-white rounded-full font-medium"
          >
            Go to Dashboard
          </a>
        </div>
      )}
    </div>
  );
}

Step 6: A/B Test the New Flow

typescript
// src/lib/ab-test.ts
// Routes users to conversational vs wizard onboarding with tracking

import { Redis } from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);

export async function assignOnboardingVariant(userId: string): Promise<'conversational' | 'wizard'> {
  // Check if already assigned
  const existing = await redis.get(`ab:onboarding:${userId}`);
  if (existing) return existing as any;

  // 50/50 split
  const variant = Math.random() < 0.5 ? 'conversational' : 'wizard';
  await redis.setex(`ab:onboarding:${userId}`, 86400 * 30, variant);

  // Track assignment for analysis
  await redis.hincrby('ab:onboarding:counts', variant, 1);

  return variant;
}

export async function trackOnboardingEvent(
  userId: string,
  event: 'started' | 'completed' | 'dropped_off' | 'time_to_value',
  metadata?: Record<string, unknown>
): Promise<void> {
  const variant = await redis.get(`ab:onboarding:${userId}`);
  await redis.lpush(`ab:onboarding:events`, JSON.stringify({
    userId, variant, event, metadata, timestamp: Date.now(),
  }));
}

Results

After 6 weeks of A/B testing (1,200 users per variant):

  • Onboarding completion rate: 89% (conversational) vs 38% (wizard) — 2.3x improvement
  • Time to complete: 3.8 minutes (conversational) vs 25 minutes (wizard)
  • Trial-to-paid conversion: 24% (conversational) vs 11% (wizard) — 2.2x improvement
  • First-week support tickets: 12 per 100 users (conversational) vs 64 per 100 (wizard)
  • Dashboard relevance: 91% of users kept AI-configured dashboards vs 34% of self-configured
  • Handoff rate: 4% of conversations escalated to human CS (mostly enterprise SSO questions)
  • AI cost: $0.003 per onboarding session (GPT-4o-mini, ~8 messages average)
  • Steps completed: average 4.2 steps (conversational, with skips) vs 14 mandatory (wizard)

Related use cases