[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build a Tenant Onboarding Wizard

Build a Tenant Onboarding Wizard

Build a multi-step tenant onboarding wizard with progress tracking, data import, team invitation, template provisioning, and automated health checks for SaaS platforms.

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

Skills stack · 5 skills

Avg quality 96/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
>

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
>

postgresql

v1.1.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

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

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× 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
$

The Problem

Yuki leads product at a 25-person B2B SaaS. New customers sign up, see an empty dashboard, and 40% never come back. Onboarding requires manual steps: CSM creates their account, imports data from their old tool, invites their team, configures settings. This takes 3-5 business days. By then, the customer's excitement has faded. Competitors with self-service onboarding convert 60% of signups. They need automated onboarding: guided wizard, data import, team invitation, template setup, and progress tracking — all self-service in under 30 minutes.

Step 1: Build the Onboarding Engine

typescript
// src/onboarding/wizard.ts — Multi-step onboarding with import, team setup, and health checks
import { pool } from "../db";
import { Redis } from "ioredis";
import { randomBytes } from "node:crypto";

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

interface OnboardingFlow {
  id: string;
  tenantId: string;
  currentStep: number;
  steps: OnboardingStep[];
  status: "in_progress" | "completed" | "abandoned" | "paused";
  startedAt: string;
  completedAt: string | null;
  metadata: Record<string, any>;
}

interface OnboardingStep {
  id: string;
  name: string;
  type: "form" | "import" | "invite" | "template" | "verify" | "custom";
  status: "pending" | "in_progress" | "completed" | "skipped" | "failed";
  required: boolean;
  data: Record<string, any>;
  completedAt: string | null;
}

const DEFAULT_STEPS: Omit<OnboardingStep, "id" | "status" | "data" | "completedAt">[] = [
  { name: "Company Profile", type: "form", required: true },
  { name: "Import Data", type: "import", required: false },
  { name: "Invite Team", type: "invite", required: false },
  { name: "Choose Template", type: "template", required: true },
  { name: "Health Check", type: "verify", required: true },
];

// Start onboarding for new tenant
export async function startOnboarding(tenantId: string): Promise<OnboardingFlow> {
  const id = `ob-${randomBytes(6).toString("hex")}`;

  const steps: OnboardingStep[] = DEFAULT_STEPS.map((s, i) => ({
    id: `step-${i}`,
    ...s,
    status: i === 0 ? "in_progress" : "pending",
    data: {},
    completedAt: null,
  }));

  const flow: OnboardingFlow = {
    id, tenantId, currentStep: 0, steps,
    status: "in_progress",
    startedAt: new Date().toISOString(),
    completedAt: null,
    metadata: {},
  };

  await pool.query(
    `INSERT INTO onboarding_flows (id, tenant_id, current_step, steps, status, started_at)
     VALUES ($1, $2, 0, $3, 'in_progress', NOW())`,
    [id, tenantId, JSON.stringify(steps)]
  );

  // Track in Redis for real-time progress
  await redis.setex(`onboarding:${tenantId}`, 86400 * 7, JSON.stringify(flow));

  return flow;
}

// Complete a step and advance
export async function completeStep(
  tenantId: string,
  stepId: string,
  data: Record<string, any>
): Promise<OnboardingFlow> {
  const flow = await getFlow(tenantId);
  if (!flow) throw new Error("Onboarding not found");

  const stepIndex = flow.steps.findIndex((s) => s.id === stepId);
  if (stepIndex === -1) throw new Error("Step not found");

  const step = flow.steps[stepIndex];

  // Execute step-specific logic
  switch (step.type) {
    case "form":
      await processFormStep(tenantId, data);
      break;
    case "import":
      await processImportStep(tenantId, data);
      break;
    case "invite":
      await processInviteStep(tenantId, data);
      break;
    case "template":
      await processTemplateStep(tenantId, data);
      break;
    case "verify":
      await processVerifyStep(tenantId);
      break;
  }

  step.status = "completed";
  step.data = data;
  step.completedAt = new Date().toISOString();

  // Find next incomplete required step
  const nextStep = flow.steps.find((s, i) => i > stepIndex && s.status === "pending");
  if (nextStep) {
    nextStep.status = "in_progress";
    flow.currentStep = flow.steps.indexOf(nextStep);
  } else {
    flow.status = "completed";
    flow.completedAt = new Date().toISOString();
  }

  await saveFlow(flow);
  return flow;
}

// Skip optional step
export async function skipStep(tenantId: string, stepId: string): Promise<OnboardingFlow> {
  const flow = await getFlow(tenantId);
  if (!flow) throw new Error("Onboarding not found");

  const step = flow.steps.find((s) => s.id === stepId);
  if (!step) throw new Error("Step not found");
  if (step.required) throw new Error("Cannot skip required step");

  step.status = "skipped";
  step.completedAt = new Date().toISOString();

  const nextStep = flow.steps.find((s) => s.status === "pending");
  if (nextStep) {
    nextStep.status = "in_progress";
    flow.currentStep = flow.steps.indexOf(nextStep);
  }

  await saveFlow(flow);
  return flow;
}

async function processFormStep(tenantId: string, data: Record<string, any>): Promise<void> {
  await pool.query(
    "UPDATE tenants SET company_name = $2, industry = $3, size = $4, timezone = $5 WHERE id = $1",
    [tenantId, data.companyName, data.industry, data.companySize, data.timezone]
  );
}

async function processImportStep(tenantId: string, data: Record<string, any>): Promise<void> {
  const { source, fileUrl } = data;
  // Queue async import job
  await redis.rpush("import:queue", JSON.stringify({
    tenantId, source, fileUrl, startedAt: new Date().toISOString(),
  }));
}

async function processInviteStep(tenantId: string, data: Record<string, any>): Promise<void> {
  const { emails, role } = data;
  for (const email of emails) {
    const token = randomBytes(16).toString("hex");
    await pool.query(
      `INSERT INTO invitations (tenant_id, email, role, token, created_at) VALUES ($1, $2, $3, $4, NOW())`,
      [tenantId, email, role || "member", token]
    );
    await redis.rpush("notification:queue", JSON.stringify({
      type: "team_invite", email, tenantId, token,
    }));
  }
}

async function processTemplateStep(tenantId: string, data: Record<string, any>): Promise<void> {
  const { templateId } = data;
  // Clone template data into tenant's workspace
  const { rows: [template] } = await pool.query("SELECT * FROM templates WHERE id = $1", [templateId]);
  if (template) {
    await pool.query(
      "INSERT INTO tenant_configs (tenant_id, config, created_at) VALUES ($1, $2, NOW())",
      [tenantId, template.config]
    );
  }
}

async function processVerifyStep(tenantId: string): Promise<void> {
  // Run health checks on the new tenant setup
  const checks = [
    { name: "Database schema", check: () => pool.query("SELECT 1") },
    { name: "Tenant data", check: () => pool.query("SELECT id FROM tenants WHERE id = $1", [tenantId]) },
  ];

  for (const c of checks) {
    try { await c.check(); }
    catch (e) { throw new Error(`Health check failed: ${c.name}`); }
  }
}

async function getFlow(tenantId: string): Promise<OnboardingFlow | null> {
  const cached = await redis.get(`onboarding:${tenantId}`);
  if (cached) return JSON.parse(cached);
  const { rows: [row] } = await pool.query(
    "SELECT * FROM onboarding_flows WHERE tenant_id = $1 ORDER BY started_at DESC LIMIT 1",
    [tenantId]
  );
  return row ? { ...row, steps: JSON.parse(row.steps) } : null;
}

async function saveFlow(flow: OnboardingFlow): Promise<void> {
  await pool.query(
    "UPDATE onboarding_flows SET current_step = $2, steps = $3, status = $4, completed_at = $5 WHERE id = $1",
    [flow.id, flow.currentStep, JSON.stringify(flow.steps), flow.status, flow.completedAt]
  );
  await redis.setex(`onboarding:${flow.tenantId}`, 86400 * 7, JSON.stringify(flow));
}

// Analytics: onboarding funnel
export async function getOnboardingFunnel(): Promise<Array<{ step: string; started: number; completed: number; dropoffRate: number }>> {
  const { rows } = await pool.query(
    `SELECT steps FROM onboarding_flows WHERE started_at > NOW() - INTERVAL '30 days'`
  );

  const stepStats: Record<string, { started: number; completed: number }> = {};
  for (const row of rows) {
    const steps: OnboardingStep[] = JSON.parse(row.steps);
    for (const step of steps) {
      if (!stepStats[step.name]) stepStats[step.name] = { started: 0, completed: 0 };
      if (step.status !== "pending") stepStats[step.name].started++;
      if (step.status === "completed") stepStats[step.name].completed++;
    }
  }

  return Object.entries(stepStats).map(([step, s]) => ({
    step,
    started: s.started,
    completed: s.completed,
    dropoffRate: s.started > 0 ? Math.round(((s.started - s.completed) / s.started) * 100) : 0,
  }));
}

Results

  • Onboarding time: 3-5 days → 25 minutes — self-service wizard replaces manual CSM process; customers set up their account while excitement is high
  • Day-1 retention: 60% → 85% — guided steps ensure customers see value immediately; template provisioning gives them a working setup, not empty dashboard
  • Team adoption faster — invite step sends emails during onboarding; 70% of invited team members join within 24 hours vs 30% when invited later
  • Funnel analytics — see exactly where customers drop off; "Import Data" step had 45% dropoff → added CSV template download → dropoff dropped to 15%
  • CSM time freed up — manual onboarding eliminated for standard accounts; CSMs focus on enterprise customers with custom needs

Related use cases