diff --git a/src/features/onboarding/components/AgentsStep.tsx b/src/features/onboarding/components/AgentsStep.tsx
new file mode 100644
index 0000000..1d0990e
--- /dev/null
+++ b/src/features/onboarding/components/AgentsStep.tsx
@@ -0,0 +1,88 @@
+/**
+ * AgentsStep — Shows discovered agents after gateway connection.
+ */
+import { Bot, Users, WifiOff } from "lucide-react";
+
+export type AgentsStepProps = {
+ agentCount: number;
+ connected: boolean;
+};
+
+export const AgentsStep = ({ agentCount, connected }: AgentsStepProps) => {
+ if (!connected) {
+ return (
+
+
+
+ Connect to your gateway first to discover agents.
+
+
+ );
+ }
+
+ if (agentCount === 0) {
+ return (
+
+
+
+
+
+
No agents found
+
+ Your gateway is connected, but no agents are configured yet.
+ You can create agents from the Claw3D fleet sidebar after
+ completing this wizard.
+
+
+
+
+
Quick start:
+
+ - 1. Click the + button in the fleet sidebar
+ - 2. Choose a name and model for your agent
+ - 3. Configure skills and personality
+ - 4. Watch your agent appear at their desk!
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {agentCount} agent{agentCount !== 1 ? "s" : ""} discovered
+
+
+ Your AI team is ready and waiting in the office.
+
+
+
+
+
+
+ What you can do with agents:
+
+
+ {[
+ { label: "Chat", desc: "Send messages and get responses" },
+ { label: "Approve", desc: "Review and approve exec commands" },
+ { label: "Configure", desc: "Edit brain files and settings" },
+ { label: "Monitor", desc: "Watch runtime activity in real time" },
+ ].map(({ label, desc }) => (
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/features/onboarding/components/CompleteStep.tsx b/src/features/onboarding/components/CompleteStep.tsx
new file mode 100644
index 0000000..c848329
--- /dev/null
+++ b/src/features/onboarding/components/CompleteStep.tsx
@@ -0,0 +1,38 @@
+/**
+ * CompleteStep — Final wizard screen before entering the office.
+ */
+import { Building2, Rocket } from "lucide-react";
+
+export const CompleteStep = () => (
+
+
+
+
+
+
+
+ Welcome to your AI office
+
+
+ Your gateway is connected and your agents are ready. Step inside and
+ explore the 3D workspace where your AI team operates.
+
+
+
+
+
+
+
+
Explore the Office
+
+ Navigate rooms, watch agents, and interact
+
+
+
+
+
+
+ You can always re-run onboarding from Studio settings.
+
+
+);
diff --git a/src/features/onboarding/components/ConnectStep.tsx b/src/features/onboarding/components/ConnectStep.tsx
new file mode 100644
index 0000000..7443839
--- /dev/null
+++ b/src/features/onboarding/components/ConnectStep.tsx
@@ -0,0 +1,139 @@
+/**
+ * ConnectStep — Gateway connection form within the onboarding wizard.
+ *
+ * Reuses the same fields as GatewayConnectScreen but in a more compact
+ * layout suited for the wizard modal.
+ */
+import { useState } from "react";
+import { CheckCircle2, Eye, EyeOff, Loader2, Wifi, WifiOff } from "lucide-react";
+
+export type ConnectStepProps = {
+ gatewayUrl: string;
+ token: string;
+ onGatewayUrlChange: (value: string) => void;
+ onTokenChange: (value: string) => void;
+ onConnect: () => void;
+ connected: boolean;
+ connecting: boolean;
+ error: string | null;
+};
+
+export const ConnectStep = ({
+ gatewayUrl,
+ token,
+ onGatewayUrlChange,
+ onTokenChange,
+ onConnect,
+ connected,
+ connecting,
+ error,
+}: ConnectStepProps) => {
+ const [showToken, setShowToken] = useState(false);
+
+ if (connected) {
+ return (
+
+
+
+
+
Connected!
+
+ Your gateway is live. Click Next to continue.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+ Local? Use{" "}
+ ws://localhost:18789
+
+
+ Tailscale? Use{" "}
+ wss://your-host.ts.net
+
+
+ SSH tunnel? Forward port
+ 18789 first, then use localhost.
+
+
+
+ );
+};
diff --git a/src/features/onboarding/components/OnboardingWizard.tsx b/src/features/onboarding/components/OnboardingWizard.tsx
new file mode 100644
index 0000000..e2dbba5
--- /dev/null
+++ b/src/features/onboarding/components/OnboardingWizard.tsx
@@ -0,0 +1,213 @@
+/**
+ * OnboardingWizard — Step-based onboarding flow for new Claw3D users.
+ *
+ * Renders a modal overlay with step navigation, progress indicator,
+ * and content slots for each onboarding phase. Designed to be mounted
+ * at the app root and dismissed once complete or skipped.
+ */
+import { useCallback, useMemo, useState } from "react";
+import { ArrowLeft, ArrowRight, X } from "lucide-react";
+
+import {
+ getNextStep,
+ getPrevStep,
+ getStepIndex,
+ ONBOARDING_STEPS,
+ type OnboardingStepId,
+} from "@/features/onboarding/types";
+import { WelcomeStep } from "@/features/onboarding/components/WelcomeStep";
+import { PrerequisitesStep } from "@/features/onboarding/components/PrerequisitesStep";
+import { ConnectStep } from "@/features/onboarding/components/ConnectStep";
+import { AgentsStep } from "@/features/onboarding/components/AgentsStep";
+import { CompleteStep } from "@/features/onboarding/components/CompleteStep";
+
+export type OnboardingWizardProps = {
+ /** Whether the gateway is currently connected. */
+ gatewayConnected: boolean;
+ /** Number of agents discovered. */
+ agentCount: number;
+ /** Gateway URL (for the connect step). */
+ gatewayUrl: string;
+ /** Gateway token (for the connect step). */
+ token: string;
+ /** Callbacks for the connect step. */
+ onGatewayUrlChange: (value: string) => void;
+ onTokenChange: (value: string) => void;
+ onConnect: () => void;
+ /** Called when the user finishes or dismisses the wizard. */
+ onComplete: () => void;
+ /** Connection error message, if any. */
+ connectionError: string | null;
+ /** Whether we're currently connecting. */
+ connecting: boolean;
+};
+
+export const OnboardingWizard = ({
+ gatewayConnected,
+ agentCount,
+ gatewayUrl,
+ token,
+ onGatewayUrlChange,
+ onTokenChange,
+ onConnect,
+ onComplete,
+ connectionError,
+ connecting,
+}: OnboardingWizardProps) => {
+ const [currentStep, setCurrentStep] = useState
("welcome");
+ const [completedSteps, setCompletedSteps] = useState>(
+ new Set(),
+ );
+
+ const stepIndex = useMemo(() => getStepIndex(currentStep), [currentStep]);
+ const currentStepDef = ONBOARDING_STEPS[stepIndex];
+ const totalSteps = ONBOARDING_STEPS.length;
+
+ const markComplete = useCallback(
+ (stepId: OnboardingStepId) => {
+ setCompletedSteps((prev) => {
+ const next = new Set(prev);
+ next.add(stepId);
+ return next;
+ });
+ },
+ [],
+ );
+
+ const goNext = useCallback(() => {
+ markComplete(currentStep);
+ const next = getNextStep(currentStep);
+ if (next) {
+ setCurrentStep(next);
+ } else {
+ onComplete();
+ }
+ }, [currentStep, markComplete, onComplete]);
+
+ const goPrev = useCallback(() => {
+ const prev = getPrevStep(currentStep);
+ if (prev) setCurrentStep(prev);
+ }, [currentStep]);
+
+ const canGoNext = useMemo(() => {
+ // Connect step requires gateway connection before proceeding
+ if (currentStep === "connect" && !gatewayConnected) return false;
+ return true;
+ }, [currentStep, gatewayConnected]);
+
+ const renderStepContent = () => {
+ switch (currentStep) {
+ case "welcome":
+ return ;
+ case "prerequisites":
+ return ;
+ case "connect":
+ return (
+
+ );
+ case "agents":
+ return ;
+ case "complete":
+ return ;
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ {currentStepDef?.title ?? "Onboarding"}
+
+
+ {currentStepDef?.description}
+
+
+
+
+
+ {/* Progress bar */}
+
+ {ONBOARDING_STEPS.map((step, idx) => (
+
+ ))}
+
+
+ {/* Step content */}
+
{renderStepContent()}
+
+ {/* Footer navigation */}
+
+
+ {stepIndex > 0 ? (
+
+ ) : null}
+
+
+
+ {stepIndex + 1} / {totalSteps}
+
+ {currentStep === "complete" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+};
diff --git a/src/features/onboarding/components/PrerequisitesStep.tsx b/src/features/onboarding/components/PrerequisitesStep.tsx
new file mode 100644
index 0000000..55dd2fe
--- /dev/null
+++ b/src/features/onboarding/components/PrerequisitesStep.tsx
@@ -0,0 +1,90 @@
+/**
+ * PrerequisitesStep — Tells users what they need before connecting.
+ */
+import { CheckCircle2, ExternalLink } from "lucide-react";
+
+const prerequisites = [
+ {
+ label: "OpenClaw installed",
+ detail: "Install via npm, pnpm, or from source",
+ link: "https://docs.openclaw.ai",
+ linkLabel: "Installation docs",
+ },
+ {
+ label: "Gateway running",
+ detail: "Start with: openclaw gateway start",
+ command: "openclaw gateway start",
+ },
+ {
+ label: "Gateway URL and token",
+ detail: "Found in ~/.openclaw/openclaw.json or your remote setup",
+ },
+ {
+ label: "Node.js 20+",
+ detail: "Required for running Claw3D locally",
+ link: "https://nodejs.org",
+ linkLabel: "Download Node.js",
+ },
+] as const;
+
+export const PrerequisitesStep = () => (
+
+
+ Make sure you have these ready before connecting. If you already have
+ OpenClaw running, you can skip this step.
+
+
+
+ {prerequisites.map(({ label, detail, ...rest }) => (
+
+ ))}
+
+
+
+ Need help? Check{" "}
+
+ docs.openclaw.ai
+ {" "}
+ or{" "}
+
+ join Discord
+
+ .
+
+
+);
diff --git a/src/features/onboarding/components/WelcomeStep.tsx b/src/features/onboarding/components/WelcomeStep.tsx
new file mode 100644
index 0000000..0710f45
--- /dev/null
+++ b/src/features/onboarding/components/WelcomeStep.tsx
@@ -0,0 +1,61 @@
+/**
+ * WelcomeStep — First onboarding screen introducing Claw3D.
+ */
+import { Building2, Eye, MessageSquare, Users } from "lucide-react";
+
+const features = [
+ {
+ icon: Eye,
+ title: "Watch agents work",
+ description: "See your AI agents in real time in a shared 3D office",
+ },
+ {
+ icon: Users,
+ title: "Manage your fleet",
+ description: "Create, configure, and monitor agents from one place",
+ },
+ {
+ icon: MessageSquare,
+ title: "Chat and approve",
+ description: "Talk to agents, approve exec commands, review their work",
+ },
+ {
+ icon: Building2,
+ title: "Build your office",
+ description: "Customize rooms, desks, and the whole office layout",
+ },
+] as const;
+
+export const WelcomeStep = () => (
+
+
+
+ Claw3D turns your AI automation into a{" "}
+ visual workplace — an
+ office where your OpenClaw agents collaborate, code, test, and execute
+ tasks in a shared 3D environment.
+
+
+ This wizard will help you connect to your OpenClaw gateway and get
+ started in about two minutes.
+
+
+
+
+ {features.map(({ icon: Icon, title, description }) => (
+
+
+
+ {title}
+
+
+ {description}
+
+
+ ))}
+
+
+);
diff --git a/src/features/onboarding/index.ts b/src/features/onboarding/index.ts
new file mode 100644
index 0000000..347a18d
--- /dev/null
+++ b/src/features/onboarding/index.ts
@@ -0,0 +1,15 @@
+/**
+ * Onboarding feature module.
+ *
+ * Usage:
+ * import { OnboardingWizard, useOnboardingState } from "@/features/onboarding";
+ */
+export { OnboardingWizard } from "@/features/onboarding/components/OnboardingWizard";
+export type { OnboardingWizardProps } from "@/features/onboarding/components/OnboardingWizard";
+export { useOnboardingState } from "@/features/onboarding/useOnboardingState";
+export type { OnboardingStateReturn } from "@/features/onboarding/useOnboardingState";
+export {
+ ONBOARDING_STEPS,
+ type OnboardingStepId,
+ type OnboardingStep,
+} from "@/features/onboarding/types";
diff --git a/src/features/onboarding/types.ts b/src/features/onboarding/types.ts
new file mode 100644
index 0000000..da0d317
--- /dev/null
+++ b/src/features/onboarding/types.ts
@@ -0,0 +1,85 @@
+/**
+ * Onboarding wizard types.
+ *
+ * The wizard is step-based and extensible: new steps can be added by
+ * extending `OnboardingStepId` and registering a component in the
+ * step registry.
+ */
+
+export type OnboardingStepId =
+ | "welcome"
+ | "prerequisites"
+ | "connect"
+ | "agents"
+ | "complete";
+
+export type OnboardingStep = {
+ id: OnboardingStepId;
+ title: string;
+ description: string;
+ /** Whether the step can be skipped. */
+ skippable: boolean;
+};
+
+export type OnboardingState = {
+ currentStep: OnboardingStepId;
+ completedSteps: Set;
+ /** Whether the user has dismissed the wizard entirely. */
+ dismissed: boolean;
+ /** Gateway connection state passed from the parent. */
+ gatewayConnected: boolean;
+ /** Number of agents discovered after connection. */
+ agentCount: number;
+};
+
+export const ONBOARDING_STEPS: OnboardingStep[] = [
+ {
+ id: "welcome",
+ title: "Welcome to Claw3D",
+ description: "Your AI office in 3D",
+ skippable: false,
+ },
+ {
+ id: "prerequisites",
+ title: "Before You Start",
+ description: "What you'll need",
+ skippable: true,
+ },
+ {
+ id: "connect",
+ title: "Connect Your Gateway",
+ description: "Link to your OpenClaw instance",
+ skippable: false,
+ },
+ {
+ id: "agents",
+ title: "Your Agents",
+ description: "Meet your AI team",
+ skippable: true,
+ },
+ {
+ id: "complete",
+ title: "You're All Set",
+ description: "Start exploring",
+ skippable: false,
+ },
+];
+
+export const getStepIndex = (stepId: OnboardingStepId): number =>
+ ONBOARDING_STEPS.findIndex((s) => s.id === stepId);
+
+export const getNextStep = (
+ currentId: OnboardingStepId,
+): OnboardingStepId | null => {
+ const idx = getStepIndex(currentId);
+ if (idx < 0 || idx >= ONBOARDING_STEPS.length - 1) return null;
+ return ONBOARDING_STEPS[idx + 1].id;
+};
+
+export const getPrevStep = (
+ currentId: OnboardingStepId,
+): OnboardingStepId | null => {
+ const idx = getStepIndex(currentId);
+ if (idx <= 0) return null;
+ return ONBOARDING_STEPS[idx - 1].id;
+};
diff --git a/src/features/onboarding/useOnboardingState.ts b/src/features/onboarding/useOnboardingState.ts
new file mode 100644
index 0000000..e05d219
--- /dev/null
+++ b/src/features/onboarding/useOnboardingState.ts
@@ -0,0 +1,60 @@
+/**
+ * useOnboardingState — Tracks whether onboarding has been completed.
+ *
+ * Uses localStorage so the wizard only shows once per browser.
+ * The key is scoped to the Claw3D app to avoid collisions.
+ */
+import { useCallback, useState } from "react";
+
+const STORAGE_KEY = "claw3d:onboarding:completed";
+
+const readCompleted = (): boolean => {
+ if (typeof window === "undefined") return false;
+ try {
+ return window.localStorage.getItem(STORAGE_KEY) === "true";
+ } catch {
+ return false;
+ }
+};
+
+const writeCompleted = (value: boolean): void => {
+ if (typeof window === "undefined") return;
+ try {
+ if (value) {
+ window.localStorage.setItem(STORAGE_KEY, "true");
+ } else {
+ window.localStorage.removeItem(STORAGE_KEY);
+ }
+ } catch {
+ // Storage might be unavailable in some environments.
+ }
+};
+
+export type OnboardingStateReturn = {
+ /** Whether the wizard should be shown. */
+ showOnboarding: boolean;
+ /** Mark onboarding as complete (hides the wizard). */
+ completeOnboarding: () => void;
+ /** Reset onboarding (shows the wizard again). */
+ resetOnboarding: () => void;
+};
+
+export const useOnboardingState = (): OnboardingStateReturn => {
+ const [completed, setCompleted] = useState(readCompleted);
+
+ const completeOnboarding = useCallback(() => {
+ setCompleted(true);
+ writeCompleted(true);
+ }, []);
+
+ const resetOnboarding = useCallback(() => {
+ setCompleted(false);
+ writeCompleted(false);
+ }, []);
+
+ return {
+ showOnboarding: !completed,
+ completeOnboarding,
+ resetOnboarding,
+ };
+};
diff --git a/src/features/retro-office/RetroOffice3D.tsx b/src/features/retro-office/RetroOffice3D.tsx
index 7939d1d..aa199e4 100644
--- a/src/features/retro-office/RetroOffice3D.tsx
+++ b/src/features/retro-office/RetroOffice3D.tsx
@@ -1892,6 +1892,7 @@ export function RetroOffice3D({
onVoiceRepliesSpeedChange,
onVoiceRepliesPreview,
onGatewayDisconnect,
+ onOpenOnboarding,
atmAnalytics = null,
feedEvents = [],
gatewayStatus = "disconnected",
@@ -1949,6 +1950,7 @@ export function RetroOffice3D({
onVoiceRepliesSpeedChange?: (speed: number) => void;
onVoiceRepliesPreview?: (voiceId: string | null, voiceName: string) => void;
onGatewayDisconnect?: () => void;
+ onOpenOnboarding?: () => void;
atmAnalytics?: OfficeUsageAnalyticsParams | null;
feedEvents?: {
id: string;
@@ -6049,6 +6051,10 @@ export function RetroOffice3D({
onGatewayDisconnect?.();
setSettingsModalOpen(false);
}}
+ onOpenOnboarding={() => {
+ onOpenOnboarding?.();
+ setSettingsModalOpen(false);
+ }}
officeTitle={officeTitle}
officeTitleLoaded={officeTitleLoaded}
onOfficeTitleChange={(title) => onOfficeTitleChange?.(title)}
diff --git a/tests/unit/onboardingState.test.ts b/tests/unit/onboardingState.test.ts
new file mode 100644
index 0000000..6640b1d
--- /dev/null
+++ b/tests/unit/onboardingState.test.ts
@@ -0,0 +1,63 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { useOnboardingState } from "@/features/onboarding/useOnboardingState";
+
+describe("useOnboardingState", () => {
+ afterEach(() => {
+ // Clean up localStorage between tests
+ try {
+ window.localStorage.removeItem("claw3d:onboarding:completed");
+ } catch {
+ // noop
+ }
+ });
+
+ it("shows onboarding by default when localStorage is empty", () => {
+ const { result } = renderHook(() => useOnboardingState());
+ expect(result.current.showOnboarding).toBe(true);
+ });
+
+ it("hides onboarding after completeOnboarding is called", () => {
+ const { result } = renderHook(() => useOnboardingState());
+ expect(result.current.showOnboarding).toBe(true);
+
+ act(() => {
+ result.current.completeOnboarding();
+ });
+
+ expect(result.current.showOnboarding).toBe(false);
+ });
+
+ it("persists completion to localStorage", () => {
+ const { result } = renderHook(() => useOnboardingState());
+
+ act(() => {
+ result.current.completeOnboarding();
+ });
+
+ expect(window.localStorage.getItem("claw3d:onboarding:completed")).toBe(
+ "true",
+ );
+ });
+
+ it("reads completion state from localStorage on mount", () => {
+ window.localStorage.setItem("claw3d:onboarding:completed", "true");
+ const { result } = renderHook(() => useOnboardingState());
+ expect(result.current.showOnboarding).toBe(false);
+ });
+
+ it("resets onboarding when resetOnboarding is called", () => {
+ const { result } = renderHook(() => useOnboardingState());
+
+ act(() => {
+ result.current.completeOnboarding();
+ });
+ expect(result.current.showOnboarding).toBe(false);
+
+ act(() => {
+ result.current.resetOnboarding();
+ });
+ expect(result.current.showOnboarding).toBe(true);
+ expect(window.localStorage.getItem("claw3d:onboarding:completed")).toBeNull();
+ });
+});
diff --git a/tests/unit/onboardingTypes.test.ts b/tests/unit/onboardingTypes.test.ts
new file mode 100644
index 0000000..36c87b5
--- /dev/null
+++ b/tests/unit/onboardingTypes.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from "vitest";
+import {
+ getNextStep,
+ getPrevStep,
+ getStepIndex,
+ ONBOARDING_STEPS,
+} from "@/features/onboarding/types";
+
+describe("ONBOARDING_STEPS", () => {
+ it("has at least 3 steps", () => {
+ expect(ONBOARDING_STEPS.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it("starts with welcome and ends with complete", () => {
+ expect(ONBOARDING_STEPS[0].id).toBe("welcome");
+ expect(ONBOARDING_STEPS[ONBOARDING_STEPS.length - 1].id).toBe("complete");
+ });
+
+ it("has unique step IDs", () => {
+ const ids = ONBOARDING_STEPS.map((s) => s.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("every step has a non-empty title and description", () => {
+ for (const step of ONBOARDING_STEPS) {
+ expect(step.title.length).toBeGreaterThan(0);
+ expect(step.description.length).toBeGreaterThan(0);
+ }
+ });
+});
+
+describe("getStepIndex", () => {
+ it("returns 0 for welcome", () => {
+ expect(getStepIndex("welcome")).toBe(0);
+ });
+
+ it("returns last index for complete", () => {
+ expect(getStepIndex("complete")).toBe(ONBOARDING_STEPS.length - 1);
+ });
+
+ it("returns correct index for connect", () => {
+ const idx = ONBOARDING_STEPS.findIndex((s) => s.id === "connect");
+ expect(getStepIndex("connect")).toBe(idx);
+ });
+});
+
+describe("getNextStep", () => {
+ it("returns prerequisites after welcome", () => {
+ expect(getNextStep("welcome")).toBe("prerequisites");
+ });
+
+ it("returns null after complete", () => {
+ expect(getNextStep("complete")).toBeNull();
+ });
+
+ it("advances through all steps in order", () => {
+ let current = ONBOARDING_STEPS[0].id;
+ const visited: string[] = [current];
+ let next = getNextStep(current);
+ while (next) {
+ visited.push(next);
+ current = next;
+ next = getNextStep(current);
+ }
+ expect(visited).toEqual(ONBOARDING_STEPS.map((s) => s.id));
+ });
+});
+
+describe("getPrevStep", () => {
+ it("returns null for welcome", () => {
+ expect(getPrevStep("welcome")).toBeNull();
+ });
+
+ it("returns prerequisites for connect", () => {
+ expect(getPrevStep("connect")).toBe("prerequisites");
+ });
+
+ it("navigates backward through all steps", () => {
+ let current = ONBOARDING_STEPS[ONBOARDING_STEPS.length - 1].id;
+ const visited: string[] = [current];
+ let prev = getPrevStep(current);
+ while (prev) {
+ visited.push(prev);
+ current = prev;
+ prev = getPrevStep(current);
+ }
+ visited.reverse();
+ expect(visited).toEqual(ONBOARDING_STEPS.map((s) => s.id));
+ });
+});