+}[] = [
+ {
+ label: "Mon",
+ values: {
+ cursor: 8,
+ claudeCode: 4,
+ claudeDesktop: 3,
+ chatgpt: 2,
+ other: 1,
+ },
+ },
+ {
+ label: "Tue",
+ values: {
+ cursor: 10,
+ claudeCode: 6,
+ claudeDesktop: 4,
+ chatgpt: 3,
+ other: 1,
+ },
+ },
+ {
+ label: "Wed",
+ values: {
+ cursor: 12,
+ claudeCode: 9,
+ claudeDesktop: 5,
+ chatgpt: 4,
+ other: 1,
+ },
+ },
+ {
+ label: "Thu",
+ values: {
+ cursor: 9,
+ claudeCode: 5,
+ claudeDesktop: 4,
+ chatgpt: 3,
+ other: 1,
+ },
+ },
+ {
+ label: "Fri",
+ values: {
+ cursor: 14,
+ claudeCode: 10,
+ claudeDesktop: 6,
+ chatgpt: 4,
+ other: 2,
+ },
+ },
+ {
+ label: "Sat",
+ values: {
+ cursor: 5,
+ claudeCode: 3,
+ claudeDesktop: 2,
+ chatgpt: 1,
+ other: 1,
+ },
+ },
+ {
+ label: "Sun",
+ values: {
+ cursor: 17,
+ claudeCode: 12,
+ claudeDesktop: 7,
+ chatgpt: 4,
+ other: 1,
+ },
+ },
+]
+
+export const RECENT_ANSWERS = [
+ {
+ question: "What did we decide about pricing?",
+ answer: "Pro stays at $20/mo. No overages, top-up only.",
+ who: "You",
+ when: "2h ago",
+ },
+ {
+ question: "What's blocking the Acme deal?",
+ answer: "MSA review — Jane drafting a net-30 counter.",
+ who: "You",
+ when: "4h ago",
+ },
+ {
+ question: "Who owns the onboarding rewrite?",
+ answer: "Design team. Ship target Friday.",
+ who: "Sarah Kim",
+ when: "Yesterday",
+ },
+]
+
+export const WEEKLY_DIGEST = {
+ range: "Mon – Fri",
+ headline:
+ "Your brain captured 642 items, surfaced 12 decisions, and answered 184 questions across the team.",
+ bullets: [
+ { label: "5 deadlines coming up next week", tint: "text-[#4BA0FA]" },
+ {
+ label: "2 conflicts surfaced between Notion and Slack",
+ tint: "text-[#FF8A47]",
+ },
+ {
+ label: "Sarah Kim led contributions with 14 docs",
+ tint: "text-[#A1A1AA]",
+ },
+ ],
+}
+
+export const TEAM_THIS_WEEK = [
+ {
+ name: "Sarah Kim",
+ email: "sarah@acme.com",
+ contributions: 14,
+ summary: "14 docs · 3 spaces",
+ color: "#FF8A47",
+ },
+ {
+ name: "Jane Doe",
+ email: "jane@acme.com",
+ contributions: 11,
+ summary: "11 docs · 2 spaces",
+ color: "#4BA0FA",
+ },
+ {
+ name: "Mahesh S.",
+ email: "mahesh@acme.com",
+ contributions: 9,
+ summary: "9 docs · 1 space",
+ color: "#A1A1AA",
+ },
+ {
+ name: "Alex Chen",
+ email: "alex@acme.com",
+ contributions: 7,
+ summary: "7 docs · 2 spaces",
+ color: "#10A37F",
+ },
+]
+
+export const TEAM_RECENT = [
+ { who: "Sarah Kim", what: "added a memo to acme-deal-q2", when: "14m" },
+ { who: "Jane Doe", what: "updated the Acme MSA draft", when: "1h" },
+ { who: "You", what: 'asked "What did we decide about pricing?"', when: "2h" },
+ {
+ who: "Sarah Kim",
+ what: "connected Notion to Sales space",
+ when: "Yesterday",
+ },
+]
+
+export type ActivityEvent = {
+ when: string
+ text: string
+ source: "drive" | "notion" | "slack" | "system" | "answer" | "decision"
+}
+
+export const ACTIVITY: ActivityEvent[] = [
+ { when: "2m", text: "23 new docs ingested from Drive", source: "drive" },
+ { when: "14m", text: "Jane added a memo to acme-deal-q2", source: "notion" },
+ {
+ when: "1h",
+ text: "Decision surfaced: pricing locked at $20 Pro",
+ source: "slack",
+ },
+ {
+ when: "3h",
+ text: "14 Notion pages updated by Sarah Kim",
+ source: "notion",
+ },
+ { when: "5h", text: "New entity formed: pricing-v3", source: "system" },
+ {
+ when: "Yesterday",
+ text: "MSA contract uploaded · auto-tagged contracts/MSA",
+ source: "drive",
+ },
+]
+
+export type TimelineKind =
+ | "answer"
+ | "decision"
+ | "ingest"
+ | "mention"
+ | "system"
+
+export type TimelineEvent = {
+ kind: TimelineKind
+ when: string
+ title: string
+ detail?: string
+ source?: string
+ who?: string
+}
+
+export const TIMELINE_EVENTS: TimelineEvent[] = [
+ {
+ kind: "answer",
+ when: "2m ago",
+ title: "What did we decide about pricing?",
+ detail: "Pro stays at $20/mo. No overages, top-up only.",
+ source: "You asked",
+ },
+ {
+ kind: "ingest",
+ when: "14m ago",
+ title: "23 new docs ingested from Drive",
+ detail: "Q2 roadmap, Acme MSA draft v3, customer feedback Q1, +20 more.",
+ source: "Drive",
+ },
+ {
+ kind: "decision",
+ when: "1h ago",
+ title: "Decision: pricing locked at $20 Pro",
+ detail:
+ "Decided in #leadership thread to keep Pro at $20/mo. No overages — top-up only.",
+ source: "Slack · #leadership",
+ },
+ {
+ kind: "answer",
+ when: "4h ago",
+ title: "What's blocking the Acme deal?",
+ detail: "MSA review — Jane drafting a net-30 counter.",
+ source: "You asked",
+ },
+ {
+ kind: "mention",
+ when: "Today",
+ title: "onboarding-rewrite mentioned in 3 docs",
+ detail: "Owner: design team · Ship target: Friday.",
+ source: "Drive",
+ who: "Mahesh",
+ },
+ {
+ kind: "system",
+ when: "5h ago",
+ title: "New entity formed: pricing-v3",
+ detail: "Linked across 4 sources · auto-tagged by the brain.",
+ },
+ {
+ kind: "ingest",
+ when: "Yesterday",
+ title: "MSA contract uploaded · auto-tagged contracts/MSA",
+ source: "Drive",
+ },
+ {
+ kind: "answer",
+ when: "Yesterday",
+ title: "Who owns the onboarding rewrite?",
+ detail: "Design team. Ship target Friday.",
+ source: "Sarah Kim asked",
+ },
+ {
+ kind: "ingest",
+ when: "Yesterday",
+ title: "14 Notion pages updated by Sarah Kim",
+ source: "Notion",
+ who: "Sarah Kim",
+ },
+]
+
+export type SourceStatus = "healthy" | "syncing" | "warning" | "error"
+
+export type ConnectedSource = {
+ id: string
+ name: string
+ iconKey: "drive" | "notion" | "gmail" | "onedrive" | "github"
+ status: SourceStatus
+ delta: string
+ when: string
+ statusMessage?: string
+}
+
+export const CONNECTED_SOURCES: ConnectedSource[] = [
+ {
+ id: "drive",
+ name: "Google Drive",
+ iconKey: "drive",
+ status: "healthy",
+ delta: "+24 items",
+ when: "12m ago",
+ },
+ {
+ id: "notion",
+ name: "Company Notion",
+ iconKey: "notion",
+ status: "warning",
+ delta: "Sync stalled",
+ when: "4h ago",
+ statusMessage: "Re-auth · token expired",
+ },
+ {
+ id: "gmail",
+ name: "Gmail",
+ iconKey: "gmail",
+ status: "syncing",
+ delta: "Ingesting…",
+ when: "Just now",
+ },
+]
+
+export type RecommendedSource = {
+ id: string
+ name: string
+ iconKey: "slack" | "linear" | "granola" | "github" | "calendar"
+ reason: string
+}
+
+export const RECOMMENDED_SOURCES: RecommendedSource[] = [
+ {
+ id: "slack",
+ name: "Slack",
+ iconKey: "slack",
+ reason: "Decisions and threads — most teams connect this.",
+ },
+ {
+ id: "github",
+ name: "GitHub",
+ iconKey: "github",
+ reason: "PRs, issues, READMEs from your repos.",
+ },
+ {
+ id: "linear",
+ name: "Linear",
+ iconKey: "linear",
+ reason: "Track project decisions and blockers.",
+ },
+]
+
+export type LiveSession = {
+ name: string
+ avatarColor: string
+ status: "live" | "idle" | "away"
+ tool: string
+ toolColor: string
+ doing?: string
+ elapsed?: string
+}
+
+export const LIVE_SESSIONS: LiveSession[] = [
+ {
+ name: "Sarah Kim",
+ avatarColor: "#FF8A47",
+ status: "live",
+ tool: "Cursor",
+ toolColor: "#4BA0FA",
+ doing: "Refactoring auth flow with brain context",
+ elapsed: "3m elapsed",
+ },
+ {
+ name: "Jane Doe",
+ avatarColor: "#4BA0FA",
+ status: "live",
+ tool: "Claude Code",
+ toolColor: "#FF8A47",
+ doing: "MSA review · pulling Acme history",
+ elapsed: "18m elapsed",
+ },
+ {
+ name: "You",
+ avatarColor: "#A1A1AA",
+ status: "idle",
+ tool: "Cursor",
+ toolColor: "#4BA0FA",
+ elapsed: "Idle · 12m",
+ },
+]
+
+export const TODAY_SESSIONS_SUMMARY: {
+ name: string
+ avatarColor: string
+ tool: string
+ count: number
+ lastAt: string
+}[] = [
+ {
+ name: "Alex Chen",
+ avatarColor: "#10A37F",
+ tool: "ChatGPT",
+ count: 4,
+ lastAt: "2h ago",
+ },
+ {
+ name: "Mahesh S.",
+ avatarColor: "#A1A1AA",
+ tool: "Claude Desktop",
+ count: 7,
+ lastAt: "3h ago",
+ },
+ {
+ name: "Sarah Kim",
+ avatarColor: "#FF8A47",
+ tool: "Cursor",
+ count: 11,
+ lastAt: "Now",
+ },
+]
+
+export type ChatMessage = { role: "user" | "brain"; text: string }
+
+export const SAMPLE_CHAT: ChatMessage[] = [
+ {
+ role: "user",
+ text: "What did we decide about pricing this quarter?",
+ },
+ {
+ role: "brain",
+ text: "Pro is locked at $20/mo. No overages — top-up only. Decided in the #leadership Slack thread on May 28.",
+ },
+ {
+ role: "user",
+ text: "Who's working on the Acme MSA?",
+ },
+ {
+ role: "brain",
+ text: "Jane Doe is drafting a net-30 counter to Acme's net-60 ask. Latest draft is in Drive (Acme · MSA draft v3.pdf).",
+ },
+]
diff --git a/apps/web/components/brain-home/widgets.tsx b/apps/web/components/brain-home/widgets.tsx
new file mode 100644
index 000000000..69ff83f34
--- /dev/null
+++ b/apps/web/components/brain-home/widgets.tsx
@@ -0,0 +1,671 @@
+"use client"
+
+import { cn } from "@lib/utils"
+import { dmSans125ClassName } from "@/lib/fonts"
+import { GoogleDrive, Notion } from "@ui/assets/icons"
+import {
+ AlertCircle,
+ ArrowRight,
+ Calendar,
+ CheckCircle2,
+ FileText,
+ Github,
+ Loader2,
+ MessageSquare,
+ Plus,
+ Quote,
+ Sparkles,
+ Target,
+} from "lucide-react"
+import {
+ ACTIVITY,
+ AGENT_SERIES,
+ CONNECTED_SOURCES,
+ RECENT_ANSWERS,
+ RECOMMENDED_SOURCES,
+ SESSIONS_BY_DAY,
+ STATS,
+ TEAM_RECENT,
+ TEAM_THIS_WEEK,
+ WEEKLY_DIGEST,
+ type ActivityEvent,
+ type ConnectedSource,
+ type RecommendedSource,
+ type Stat,
+} from "./data"
+
+function GmailIcon({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+export const modalCardStyle = {
+ boxShadow:
+ "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
+}
+
+export const inputBevelStyle = {
+ boxShadow:
+ "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
+}
+
+export function StatsStrip({ stats = STATS }: { stats?: Stat[] }) {
+ return (
+
+ {stats.map((s) => (
+
+
+ {s.label}
+
+
+ {s.value}
+
+ {s.delta && (
+
+ {s.delta}
+
+ )}
+
+ ))}
+
+ )
+}
+
+export function SectionHeader({ title, cta }: { title: string; cta?: string }) {
+ return (
+
+
+ {title}
+
+ {cta && (
+
+ {cta}
+
+ )}
+
+ )
+}
+
+export function RecentAnswersCard() {
+ return (
+
+
+
+ Recent answers
+
+
+ See all →
+
+
+
+ {RECENT_ANSWERS.map((a) => (
+
+
+ {a.question}
+
+
+ → {a.answer}
+
+
+ {a.who} · {a.when}
+
+
+ ))}
+
+
+ )
+}
+
+export function WeeklyDigestCard() {
+ return (
+
+
+
+
+ This week
+
+
+ {WEEKLY_DIGEST.range}
+
+
+
+ {WEEKLY_DIGEST.headline.split(/(\d+)/).map((piece, i) =>
+ /^\d+$/.test(piece) ? (
+
+ {piece}
+
+ ) : (
+ {piece}
+ ),
+ )}
+
+
+ {WEEKLY_DIGEST.bullets.map((b) => (
+
+
+
+ {b.label}
+
+
+ ))}
+
+
+ Open weekly digest
+
+
+ )
+}
+
+export function BrainPulseBlock() {
+ return (
+
+ )
+}
+
+export function BrainActivityChart() {
+ const totalsByAgent = AGENT_SERIES.map((s) => ({
+ ...s,
+ total: SESSIONS_BY_DAY.reduce((sum, d) => sum + (d.values[s.key] ?? 0), 0),
+ }))
+ const grandTotal = totalsByAgent.reduce((sum, s) => sum + s.total, 0)
+ const dayTotals = SESSIONS_BY_DAY.map((d) =>
+ AGENT_SERIES.reduce((sum, s) => sum + (d.values[s.key] ?? 0), 0),
+ )
+ const max = Math.max(...dayTotals)
+ return (
+
+
+
+
+
+ Sessions by agent
+
+
+ {grandTotal} agent sessions, last 7 days — which tool the team
+ reached for.
+
+
+
+ {totalsByAgent.map((s) => (
+
+
+ {s.label}{" "}
+ {s.total}
+
+ ))}
+
+
+
+ {SESSIONS_BY_DAY.map((d, dayIdx) => {
+ const BAR_AREA_PX = 140
+ const totalForDay = dayTotals[dayIdx] ?? 0
+ const barHeightPx = Math.max(
+ 6,
+ Math.round((totalForDay / max) * BAR_AREA_PX),
+ )
+ return (
+
+
+
+ {AGENT_SERIES.map((series, sIdx) => {
+ const val = d.values[series.key] ?? 0
+ if (val === 0) return null
+ const segHeight =
+ totalForDay > 0 ? (val / totalForDay) * 100 : 0
+ return (
+
+ )
+ })}
+
+
+
+ {d.label}
+
+
+ )
+ })}
+
+
+ )
+}
+
+function activitySourceIcon(s: ActivityEvent["source"]) {
+ if (s === "drive") return
+ if (s === "notion") return
+ if (s === "slack")
+ return
+ if (s === "answer") return
+ if (s === "decision")
+ return
+ return
+}
+
+export function ActivityFeedBlock() {
+ return (
+
+
+
+ {ACTIVITY.map((a, i) => (
+
+
+ {activitySourceIcon(a.source)}
+
+
+ {a.text}
+
+
+ {a.when}
+
+
+ ))}
+
+
+ )
+}
+
+export function TeamActivityRail() {
+ return (
+
+
+
+ Team activity
+
+
+ This week
+
+
+
+ What your team's been adding to the brain.
+
+
+ {TEAM_THIS_WEEK.map((member) => (
+
+
+ {member.name[0]}
+
+
+
+ {member.name}
+
+
+ {member.summary}
+
+
+
+ {member.contributions}
+
+
+ ))}
+
+
+
+ Recent
+
+
+ {TEAM_RECENT.map((event, i) => (
+
+ {event.who} {event.what}
+ · {event.when}
+
+ ))}
+
+
+
+
+ Invite teammates
+
+
+ )
+}
+
+export function BrainHomeBackground() {
+ return (
+
+ )
+}
+
+export function Quotation({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ )
+}
+
+function connectedSourceIcon(key: ConnectedSource["iconKey"]) {
+ if (key === "drive") return
+ if (key === "notion") return
+ if (key === "gmail") return
+ if (key === "github") return
+ return
+}
+
+function sourceStatusMeta(status: ConnectedSource["status"]) {
+ if (status === "healthy")
+ return {
+ dot: "bg-[#4BA0FA]",
+ tint: "text-[#4BA0FA]",
+ icon:
,
+ }
+ if (status === "syncing")
+ return {
+ dot: "bg-[#A1A1AA]",
+ tint: "text-[#A1A1AA]",
+ icon:
,
+ }
+ if (status === "warning")
+ return {
+ dot: "bg-[#FF8A47]",
+ tint: "text-[#FF8A47]",
+ icon:
,
+ }
+ return {
+ dot: "bg-[#FF5C5C]",
+ tint: "text-[#FF5C5C]",
+ icon:
,
+ }
+}
+
+export function SourcesHealthRail({ empty }: { empty?: boolean }) {
+ if (empty) {
+ return (
+
+
+ Sources health
+
+
+ Nothing connected yet — Drive, Notion, Gmail and more are ready when
+ you are.
+
+
+
+ Connect a source
+
+
+ )
+ }
+ const issueCount = CONNECTED_SOURCES.filter(
+ (s) => s.status === "warning" || s.status === "error",
+ ).length
+ return (
+
+
+
+ Sources health
+
+ {issueCount > 0 ? (
+
+ {issueCount} need attention
+
+ ) : (
+
+ All healthy
+
+ )}
+
+
+ Is the brain getting fed?
+
+
+
+
+ )
+}
+
+function recommendedIcon(key: RecommendedSource["iconKey"]) {
+ if (key === "slack")
+ return
+ if (key === "github") return
+ if (key === "linear") return
+ if (key === "granola") return
+ if (key === "calendar") return
+ return
+}
+
+export function RecommendedRail() {
+ return (
+
+
+ Add more to the brain
+
+
+ Teams like yours connect these next.
+
+
+
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/onboarding-confetti.tsx b/apps/web/components/onboarding-brain/onboarding-confetti.tsx
new file mode 100644
index 000000000..2aac5ac69
--- /dev/null
+++ b/apps/web/components/onboarding-brain/onboarding-confetti.tsx
@@ -0,0 +1,53 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+import { useQueryState } from "nuqs"
+
+const COLORS = ["#4BA0FA", "#FF8A47", "#B19CFF", "#10A37F", "#fafafa"]
+
+export function OnboardingConfetti() {
+ const [onboarded, setOnboarded] = useQueryState("onboarded")
+ const fired = useRef(false)
+
+ useEffect(() => {
+ if (onboarded !== "1" || fired.current) return
+ fired.current = true
+ setOnboarded(null)
+
+ const reduceMotion = window.matchMedia?.(
+ "(prefers-reduced-motion: reduce)",
+ ).matches
+ if (reduceMotion) return
+
+ let raf = 0
+ const run = async () => {
+ const confetti = (await import("canvas-confetti")).default
+ const end = Date.now() + 1400
+ const frame = () => {
+ confetti({
+ particleCount: 4,
+ angle: 60,
+ spread: 70,
+ startVelocity: 55,
+ origin: { x: 0, y: 0.7 },
+ colors: COLORS,
+ })
+ confetti({
+ particleCount: 4,
+ angle: 120,
+ spread: 70,
+ startVelocity: 55,
+ origin: { x: 1, y: 0.7 },
+ colors: COLORS,
+ })
+ if (Date.now() < end) raf = requestAnimationFrame(frame)
+ }
+ frame()
+ }
+ run()
+
+ return () => cancelAnimationFrame(raf)
+ }, [onboarded, setOnboarded])
+
+ return null
+}
diff --git a/apps/web/components/onboarding-brain/shell.tsx b/apps/web/components/onboarding-brain/shell.tsx
new file mode 100644
index 000000000..9b2d9b309
--- /dev/null
+++ b/apps/web/components/onboarding-brain/shell.tsx
@@ -0,0 +1,137 @@
+"use client"
+
+import { LogoFull } from "@ui/assets/Logo"
+import { cn } from "@lib/utils"
+import { dmSansClassName } from "@/lib/fonts"
+import { motion } from "motion/react"
+import { BRAIN_STEPS, BRAIN_STEP_LABELS, type BrainStep } from "./types"
+
+interface ShellProps {
+ step: BrainStep
+ domain?: string | null
+ children: React.ReactNode
+}
+
+export function BrainShell({ step, children }: ShellProps) {
+ const visibleSteps: BrainStep[] = BRAIN_STEPS
+
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+ )
+}
+
+function StepIndicator({
+ step,
+ visibleSteps,
+}: {
+ step: BrainStep
+ visibleSteps: BrainStep[]
+}) {
+ const currentIdx = visibleSteps.indexOf(step)
+ return (
+
+ {visibleSteps.map((s, i) => {
+ const isDone = i < currentIdx
+ const isCurrent = i === currentIdx
+ const isLast = i === visibleSteps.length - 1
+ return (
+
+
+
+
+ {BRAIN_STEP_LABELS[s]}
+
+
+ {!isLast && (
+
+
+
+ )}
+
+ )
+ })}
+
+ )
+}
+
+function StepDot({ done, current }: { done: boolean; current: boolean }) {
+ if (current) {
+ return (
+
+
+
+
+ )
+ }
+ if (done) {
+ return (
+
+
+
+ )
+ }
+ return (
+
+
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/step-about.tsx b/apps/web/components/onboarding-brain/step-about.tsx
new file mode 100644
index 000000000..5d8ff4f27
--- /dev/null
+++ b/apps/web/components/onboarding-brain/step-about.tsx
@@ -0,0 +1,473 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { motion } from "motion/react"
+import { Button } from "@ui/components/button"
+import { Input } from "@ui/components/input"
+import { Textarea } from "@ui/components/textarea"
+import {
+ ArrowRight,
+ Brain,
+ Building2,
+ LayoutGrid,
+ Loader2,
+ Plug,
+ Terminal,
+ User2,
+ UserPlus,
+ Users2,
+} from "lucide-react"
+import { cn } from "@lib/utils"
+import { dmSans125ClassName } from "@/lib/fonts"
+import type { BrainMode } from "./types"
+
+export interface AboutValues {
+ name: string
+ about: string
+ workspaceName: string
+ workspaceDomain: string
+}
+
+interface Props {
+ mode: BrainMode
+ onModeChange: (m: BrainMode) => void
+ domain: string | null
+ suggestedWorkspaceName: string
+ defaultName: string
+ avatarUrl: string | null
+ values: AboutValues
+ onChange: (next: AboutValues) => void
+ onContinue: () => void
+ submitting?: boolean
+}
+
+const cardSurfaceStyle = {
+ boxShadow:
+ "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
+}
+
+const inputBevelStyle = {
+ boxShadow:
+ "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
+}
+
+const fieldLabel = "pl-2 pb-2 font-semibold text-[14px] text-[#737373]"
+const inputClass =
+ "bg-[#0F1217] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#525D6E] h-12 px-4 shadow-none focus-visible:ring-0 focus-visible:border-[rgba(115,115,115,0.3)] transition-colors"
+
+export function StepAbout({
+ mode,
+ onModeChange,
+ domain,
+ suggestedWorkspaceName,
+ defaultName,
+ avatarUrl,
+ values,
+ onChange,
+ onContinue,
+ submitting,
+}: Props) {
+ // biome-ignore lint/correctness/useExhaustiveDependencies: one-time initialization when defaults become available
+ useEffect(() => {
+ const patch: Partial
= {}
+ if (!values.name && defaultName) patch.name = defaultName
+ if (!values.workspaceName && suggestedWorkspaceName) {
+ patch.workspaceName = suggestedWorkspaceName
+ }
+ if (!values.workspaceDomain && domain) {
+ patch.workspaceDomain = domain
+ }
+ if (Object.keys(patch).length > 0) onChange({ ...values, ...patch })
+ }, [defaultName, suggestedWorkspaceName, domain])
+
+ const canContinue =
+ values.name.trim().length > 0 && values.workspaceName.trim().length > 0
+
+ return (
+
+
+
+
+
+ Tell us about you
+
+
+ So your brain sounds like yours, not the docs.
+
+
+
+
+
Your name
+
onChange({ ...values, name: e.target.value })}
+ placeholder="e.g. Mahesh"
+ className={inputClass}
+ style={inputBevelStyle}
+ />
+
+
+
+
+ What are you here for?{" "}
+ (optional)
+
+
+
+
+
+
+
+
+
+ {mode === "team" ? (
+
+ onChange({ ...values, workspaceDomain: d })
+ }
+ value={values.workspaceName}
+ onChange={(w) => onChange({ ...values, workspaceName: w })}
+ suggested={suggestedWorkspaceName}
+ />
+ ) : (
+ onChange({ ...values, workspaceName: w })}
+ />
+ )}
+
+
+
+
+
+
+ {submitting ? (
+ <>
+ Creating…
+
+ >
+ ) : (
+ <>
+ Continue
+
+ >
+ )}
+
+
+
+ )
+}
+
+function TeamWorkspaceCard({
+ domain,
+ onDomainChange,
+ value,
+ onChange,
+ suggested,
+}: {
+ domain: string
+ onDomainChange: (d: string) => void
+ value: string
+ onChange: (v: string) => void
+ suggested: string
+}) {
+ return (
+ <>
+
+
+ {domain ? (
+
+ ) : (
+
+ )}
+
+
+
onDomainChange(e.target.value.trim())}
+ placeholder="your-team.com"
+ className="w-full bg-transparent text-[18px] text-[#fafafa] font-semibold leading-tight outline-none border-b border-transparent hover:border-[rgba(115,115,115,0.2)] focus:border-[rgba(115,115,115,0.4)] transition-colors px-0 py-0.5"
+ />
+
+ Team workspace
+
+
+
+
+
+
+ Workspace name{" "}
+
+ (rename if you'd like)
+
+
+
onChange(e.target.value)}
+ placeholder={suggested || "Acme"}
+ className={inputClass}
+ style={inputBevelStyle}
+ />
+
+
+ ,
+ title: "Invite teammates",
+ blurb: "Everyone contributes to the same brain.",
+ },
+ {
+ icon: ,
+ title: "Shared coding agent context",
+ blurb: "Claude, Cursor, MCP — same brain across the team.",
+ },
+ {
+ icon: ,
+ title: "Org-wide spaces",
+ blurb: "Carve out sales, eng, design with their own access.",
+ },
+ ]}
+ />
+
+
+ Not your team? Switch above.
+
+ >
+ )
+}
+
+function DomainLogo({ domain }: { domain: string }) {
+ const sources = [
+ `https://logo.clearbit.com/${domain}`,
+ `https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${domain}&size=64`,
+ `https://icons.duckduckgo.com/ip3/${domain}.ico`,
+ ]
+ const [idx, setIdx] = useState(0)
+ if (idx >= sources.length) {
+ return
+ }
+ return (
+ setIdx((i) => i + 1)}
+ />
+ )
+}
+
+function PersonalWorkspaceCard({
+ value,
+ onChange,
+}: {
+ value: string
+ onChange: (v: string) => void
+}) {
+ return (
+ <>
+
+
+
+
+
+
+ Just you, for now
+
+
+ Personal workspace
+
+
+
+
+
+
Workspace nickname
+
onChange(e.target.value)}
+ placeholder="My brain"
+ className={inputClass}
+ style={inputBevelStyle}
+ />
+
+
+ ,
+ title: "Your own brain",
+ blurb: "Notes, docs, bookmarks — all searchable in one place.",
+ },
+ {
+ icon: ,
+ title: "Plug into your AI tools",
+ blurb: "Claude, Cursor, ChatGPT — your context, everywhere.",
+ },
+ {
+ icon: ,
+ title: "Switch to a team anytime",
+ blurb: "Invite teammates whenever you're ready.",
+ },
+ ]}
+ />
+
+
+ Working with a team? Switch above.
+
+ >
+ )
+}
+
+function ModeToggle({
+ mode,
+ onChange,
+}: {
+ mode: BrainMode
+ onChange: (m: BrainMode) => void
+}) {
+ const items: { id: BrainMode; label: string }[] = [
+ { id: "personal", label: "Personal" },
+ { id: "team", label: "Team" },
+ ]
+ return (
+
+ {items.map((item) => {
+ const isActive = mode === item.id
+ return (
+ onChange(item.id)}
+ className={cn(
+ "relative h-8 rounded-full text-center transition-colors",
+ isActive
+ ? "text-[#fafafa]"
+ : "text-[#737373] hover:text-[#fafafa]",
+ )}
+ >
+ {isActive && (
+
+ )}
+ {item.label}
+
+ )
+ })}
+
+ )
+}
+
+function UserAvatar({
+ url,
+ name,
+ className,
+}: {
+ url: string | null
+ name: string
+ className?: string
+}) {
+ const [errored, setErrored] = useState(false)
+ const initial = (name?.trim()?.[0] ?? "?").toUpperCase()
+ const hasImage = url && !errored
+
+ return (
+
+ {hasImage ? (
+
setErrored(true)}
+ />
+ ) : (
+
+ {initial}
+
+ )}
+
+ )
+}
+
+type Perk = { icon: React.ReactNode; title: string; blurb: string }
+
+function PerksList({ heading, perks }: { heading: string; perks: Perk[] }) {
+ return (
+
+
+ {heading}
+
+
+ {perks.map((p) => (
+
+ {p.icon}
+
+ {p.title}
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/step-ingest.tsx b/apps/web/components/onboarding-brain/step-ingest.tsx
new file mode 100644
index 000000000..90a43377e
--- /dev/null
+++ b/apps/web/components/onboarding-brain/step-ingest.tsx
@@ -0,0 +1,480 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import Image from "next/image"
+import { useQueryState, parseAsString } from "nuqs"
+import { Button } from "@ui/components/button"
+import { MCPIcon } from "@ui/assets/icons"
+import { ArrowRight, Check, Copy, EyeOff, Eye } from "lucide-react"
+import { cn } from "@lib/utils"
+import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts"
+import { toast } from "sonner"
+import { MCPSteps } from "@/components/mcp-modal/mcp-detail-view"
+import { PLUGIN_CATALOG } from "@/lib/plugin-catalog"
+
+interface Props {
+ mcpUrl: string
+ onContinue: () => void
+}
+
+const modalCardStyle = {
+ boxShadow:
+ "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
+}
+
+const inputBevelStyle = {
+ boxShadow:
+ "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
+}
+
+type AgentCategory = "coding" | "productivity"
+
+type Agent = {
+ key: string
+ name: string
+ tagline: string
+ category: AgentCategory
+ pluginId?: string
+}
+
+const AGENTS: Agent[] = [
+ {
+ key: "cursor",
+ name: "Cursor",
+ tagline: "Persistent context across coding sessions.",
+ category: "coding",
+ },
+ {
+ key: "claude-code",
+ name: "Claude Code",
+ tagline: "Memory and decisions across CLI sessions.",
+ category: "coding",
+ pluginId: "claude_code",
+ },
+ {
+ key: "vscode",
+ name: "VS Code",
+ tagline: "Inline context while you write.",
+ category: "coding",
+ },
+ {
+ key: "cline",
+ name: "Cline",
+ tagline: "Agentic dev tasks with your memory.",
+ category: "coding",
+ },
+ {
+ key: "codex",
+ name: "Codex",
+ tagline: "OpenAI Codex with persistent memory.",
+ category: "coding",
+ pluginId: "codex",
+ },
+ {
+ key: "gemini-cli",
+ name: "Gemini CLI",
+ tagline: "Gemini in your terminal, brain-aware.",
+ category: "coding",
+ },
+ {
+ key: "claude",
+ name: "Claude Desktop",
+ tagline: "Memory across every Claude conversation.",
+ category: "productivity",
+ },
+ {
+ key: "chatgpt",
+ name: "ChatGPT",
+ tagline: "Custom GPT backed by your brain.",
+ category: "productivity",
+ },
+]
+
+const CATEGORY_ORDER: { id: AgentCategory; label: string }[] = [
+ { id: "coding", label: "Coding" },
+ { id: "productivity", label: "Productivity" },
+]
+
+function agentIcon(agent: Agent) {
+ if (agent.pluginId) {
+ const plugin = PLUGIN_CATALOG[agent.pluginId]
+ if (plugin) return plugin.icon
+ }
+ const file = agent.key === "claude-code" ? "claude" : agent.key
+ return `/mcp-supported-tools/${file}.png`
+}
+
+export function StepIngest({ mcpUrl, onContinue }: Props) {
+ const [activeCategory, setActiveCategory] = useState("coding")
+ const [selectedKey, setSelectedKey] = useState("cursor")
+ const [, setMcpClient] = useQueryState("mcpClient", parseAsString)
+
+ const selectedAgent = AGENTS.find((a) => a.key === selectedKey) ?? AGENTS[0]
+
+ useEffect(() => {
+ if (selectedAgent && !selectedAgent.pluginId) {
+ setMcpClient(selectedAgent.key)
+ } else {
+ setMcpClient(null)
+ }
+ }, [selectedAgent, setMcpClient])
+
+ const selectAgent = (agent: Agent) => {
+ setSelectedKey(agent.key)
+ }
+
+ const filtered = AGENTS.filter((a) => a.category === activeCategory)
+
+ return (
+
+
+
+ Use your brain anywhere
+
+
+ Now plug it into the tools you already use to write code, chat, think.
+
+
+
+
+
+
+
+
+
+ {selectedAgent?.pluginId ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ Skip for now
+
+
+ Continue
+
+
+
+
+ )
+}
+
+function McpHero({ url }: { url: string }) {
+ const [copied, setCopied] = useState(false)
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(url)
+ setCopied(true)
+ toast.success("MCP URL copied")
+ setTimeout(() => setCopied(false), 2000)
+ } catch {
+ toast.error("Could not copy")
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ Universal MCP URL
+
+
+ {url}
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+ {copied ? "Copied" : "Copy URL"}
+
+
+ )
+}
+
+function PluginSteps({ pluginId }: { pluginId: string }) {
+ const plugin = PLUGIN_CATALOG[pluginId]
+ if (!plugin) return null
+ const steps = plugin.installSteps ?? []
+ return (
+
+
+
+
+
+
+
+ Set up {plugin.name}
+
+
+ {plugin.tagline}
+
+
+ {plugin.docsUrl && (
+
+ Docs ↗
+
+ )}
+
+
+
+ {steps.map((step, i) => (
+
+ ))}
+
+
+
+
+ Your API key is minted in
+ Settings → Integrations → Plugins. Mint it once and paste into the
+ step above.
+
+
+
+ )
+}
+
+function PluginStep({
+ idx,
+ step,
+}: {
+ idx: number
+ step: import("@/lib/plugin-catalog").InstallStep
+}) {
+ const [revealed, setRevealed] = useState(false)
+ const [copied, setCopied] = useState(false)
+ const copy = async () => {
+ if (!step.code) return
+ try {
+ await navigator.clipboard.writeText(step.code)
+ setCopied(true)
+ toast.success("Copied")
+ setTimeout(() => setCopied(false), 1500)
+ } catch {
+ toast.error("Could not copy")
+ }
+ }
+ return (
+
+
+
+
+ {step.title}
+ {step.optional && (
+
+ Optional
+
+ )}
+
+ {step.description && (
+
+ {step.description}
+
+ )}
+ {step.code && (
+
+
+ {step.code}
+
+
+ {step.secret && (
+ setRevealed((v) => !v)}
+ className="size-7 rounded-md text-[#737373] hover:text-[#fafafa] flex items-center justify-center transition-colors"
+ aria-label={revealed ? "Hide" : "Reveal"}
+ >
+ {revealed ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
+
+
+ )
+}
+
+function CategoryTabs({
+ value,
+ onChange,
+}: {
+ value: AgentCategory
+ onChange: (c: AgentCategory) => void
+}) {
+ const counts: Record = {
+ coding: 0,
+ productivity: 0,
+ }
+ for (const a of AGENTS) counts[a.category] += 1
+ return (
+
+ {CATEGORY_ORDER.map((cat) => {
+ const isActive = value === cat.id
+ return (
+ onChange(cat.id)}
+ className={cn(
+ dmSansClassName(),
+ "flex flex-1 h-7 shrink-0 items-center justify-center gap-1.5 rounded-full px-3 text-[12px] font-medium leading-none transition-colors",
+ isActive
+ ? "bg-white/[0.10] text-[#FAFAFA]"
+ : "text-[#A1A1AA] hover:text-[#FAFAFA]",
+ )}
+ >
+ {cat.label}
+
+ {counts[cat.id]}
+
+
+ )
+ })}
+
+ )
+}
+
+function AgentRow({
+ agent,
+ active,
+ onClick,
+}: {
+ agent: Agent
+ active: boolean
+ onClick: () => void
+}) {
+ return (
+
+
+
+
+
+
{agent.name}
+
+ {agent.tagline}
+
+
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/step-sources.tsx b/apps/web/components/onboarding-brain/step-sources.tsx
new file mode 100644
index 000000000..6e77cab67
--- /dev/null
+++ b/apps/web/components/onboarding-brain/step-sources.tsx
@@ -0,0 +1,587 @@
+"use client"
+
+import { useState } from "react"
+import { Button } from "@ui/components/button"
+import {
+ Drawer,
+ DrawerContent,
+ DrawerHeader,
+ DrawerTitle,
+} from "@ui/components/drawer"
+import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
+import { Logo } from "@ui/assets/Logo"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@ui/components/select"
+import {
+ AlertTriangle,
+ ArrowRight,
+ Check,
+ Database,
+ FolderOpen,
+ Github,
+ Globe,
+ Mic,
+ Plus,
+} from "lucide-react"
+import {
+ AppleShortcutsIcon,
+ ChromeIcon,
+ RaycastIcon,
+} from "@/components/integration-icons"
+
+function XBookmarksIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ )
+}
+
+function GmailIcon({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+
+
+ )
+}
+import { cn } from "@lib/utils"
+import { dmSans125ClassName } from "@/lib/fonts"
+import { $fetch } from "@lib/api"
+import { toast } from "sonner"
+
+type SourceId = "drive" | "notion" | "gmail" | "github" | "onedrive"
+type SourceState = "idle" | "connecting" | "connected" | "waitlist"
+type DriveScope = "selective" | "full"
+
+export interface SourcesValues {
+ connected: Partial>
+ driveScope: DriveScope
+}
+
+interface Props {
+ containerTag: string
+ workspaceName: string
+ values: SourcesValues
+ onChange: (next: SourcesValues) => void
+ onContinue: () => void
+}
+
+const modalCardStyle = {
+ boxShadow:
+ "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
+}
+
+const inputBevelStyle = {
+ boxShadow:
+ "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
+}
+
+export function StepSources({
+ containerTag,
+ workspaceName,
+ values,
+ onChange,
+ onContinue,
+}: Props) {
+ const [moreOpen, setMoreOpen] = useState(false)
+
+ const setState = (id: SourceId, state: SourceState) => {
+ onChange({ ...values, connected: { ...values.connected, [id]: state } })
+ }
+
+ const connectRealProvider = async (
+ provider: "google-drive" | "notion" | "onedrive",
+ id: SourceId,
+ ) => {
+ setState(id, "connecting")
+ try {
+ const metadata: Record = {}
+ if (provider === "google-drive") {
+ metadata.scope = values.driveScope
+ }
+ const res = await $fetch("@post/connections/:provider", {
+ params: { provider },
+ body: {
+ redirectUrl: window.location.href,
+ containerTags: [containerTag],
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
+ },
+ })
+ const data = "data" in res ? res.data : null
+ if (data && "authLink" in data && data.authLink) {
+ window.location.href = data.authLink
+ return
+ }
+ throw new Error("No auth link returned")
+ } catch (err) {
+ setState(id, "idle")
+ toast.error(
+ err instanceof Error ? err.message : "Could not start connection",
+ )
+ }
+ }
+
+ const connectedCount = Object.values(values.connected).filter(
+ (s) => s === "connected" || s === "waitlist",
+ ).length
+
+ return (
+
+
+
+
+ Connect your team's signals
+
+
+ Start with the sources that carry the most context. Add more
+ anytime.
+
+
+
+
+
+
+
}
+ state={values.connected.drive ?? "idle"}
+ ctaLabel="Connect"
+ perks={[
+ "Docs, sheets, slides — all parsed",
+ "Stays in sync as files change",
+ "You pick what to share at sign-in",
+ ]}
+ onConnect={() => connectRealProvider("google-drive", "drive")}
+ headerNote={
+ values.driveScope === "full" ? (
+
+
+ Full Drive can exhaust your monthly usage.
+
+ ) : null
+ }
+ footerLeft={
+
onChange({ ...values, driveScope: s })}
+ />
+ }
+ footerRight={ }
+ />
+ }
+ state={values.connected.notion ?? "idle"}
+ ctaLabel="Connect"
+ perks={[
+ "Pages and database rows",
+ "Stays in sync when you edit",
+ "Pick which workspaces ingest",
+ ]}
+ onConnect={() => connectRealProvider("notion", "notion")}
+ footerRight={ }
+ />
+
+
+
+
setMoreOpen(true)}
+ className="text-[#737373] font-medium text-[14px] hover:text-[#fafafa] inline-flex items-center gap-1.5 transition-colors"
+ >
+
+ More integrations
+
+ (Gmail, GitHub, OneDrive, Granola…)
+
+
+
+
+ Skip for now
+
+
+ Continue
+ {connectedCount > 0 && (
+ ({connectedCount})
+ )}
+
+
+
+
+
+
setMoreOpen(false)}
+ containerTag={containerTag}
+ connectRealProvider={connectRealProvider}
+ />
+
+ )
+}
+
+function RoutingChip({ workspaceName }: { workspaceName: string }) {
+ return (
+
+
+ Routing to
+
+ {workspaceName || "your brain"}
+
+
+ )
+}
+
+function SourceCard({
+ title,
+ blurb,
+ icon,
+ state,
+ ctaLabel,
+ perks,
+ soft,
+ headerNote,
+ footerLeft,
+ footerRight,
+ onConnect,
+}: {
+ title: string
+ blurb: string
+ icon: React.ReactNode
+ state: SourceState
+ ctaLabel: string
+ perks: string[]
+ soft?: boolean
+ headerNote?: React.ReactNode
+ footerLeft?: React.ReactNode
+ footerRight?: React.ReactNode
+ onConnect: () => void
+}) {
+ const isDone = state === "connected" || state === "waitlist"
+
+ return (
+
+
+
+
+ {icon}
+
+
+
{title}
+
+ {blurb}
+
+ {headerNote}
+
+
+ {isDone ? (
+
+
+ {state === "waitlist" ? "Requested" : "Connected"}
+
+ ) : (
+
+ {state === "connecting" ? "Opening…" : ctaLabel}
+
+ )}
+
+
+
+ {perks.map((p) => (
+
+
+ {p}
+
+ ))}
+
+
+ {soft && !isDone && (
+
+ OAuth lands shortly — request access and we'll auto-enable it.
+
+ )}
+
+ {(footerLeft || footerRight) && (
+
+
{footerLeft}
+
{footerRight}
+
+ )}
+
+ )
+}
+
+function SpaceChip({ name }: { name: string }) {
+ return (
+
+
+ Saves to
+
+
+ {name}
+
+ )
+}
+
+function DriveScopePicker({
+ value,
+ onChange,
+}: {
+ value: DriveScope
+ onChange: (s: DriveScope) => void
+}) {
+ return (
+ onChange(v as DriveScope)}>
+
+
+
+
+
+ Files & folders
+
+
+ Full Drive
+
+
+
+ )
+}
+
+function MoreDrawer({
+ open,
+ onClose,
+ containerTag,
+ connectRealProvider,
+}: {
+ open: boolean
+ onClose: () => void
+ containerTag: string
+ connectRealProvider: (
+ provider: "google-drive" | "notion" | "onedrive",
+ id: SourceId,
+ ) => void
+}) {
+ return (
+ !o && onClose()}>
+
+
+
+ More integrations
+
+
+ Add any of these alongside your spotlight sources. Everything routes
+ to {containerTag} .
+
+
+
+ }
+ action="Request access"
+ soft
+ />
+ }
+ action="Request access"
+ soft
+ />
+ }
+ action="Connect"
+ onAction={() => connectRealProvider("onedrive", "onedrive")}
+ />
+ }
+ action="Coming soon"
+ soft
+ />
+ }
+ action="Connect"
+ soft
+ />
+ }
+ action="Connect"
+ soft
+ />
+ }
+ action="Install"
+ soft
+ />
+ }
+ action="Install"
+ soft
+ />
+ }
+ action="Install"
+ soft
+ />
+ }
+ action="Import"
+ soft
+ />
+
+
+
+ )
+}
+
+function MoreItem({
+ title,
+ blurb,
+ icon,
+ action,
+ soft,
+ onAction,
+}: {
+ title: string
+ blurb: string
+ icon: React.ReactNode
+ action: string
+ soft?: boolean
+ onAction?: () => void
+}) {
+ return (
+
+
+ {icon}
+
+
+
+ {title}
+
+
+ {blurb}
+
+
+
+ {action}
+
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/step-team.tsx b/apps/web/components/onboarding-brain/step-team.tsx
new file mode 100644
index 000000000..2c04f8578
--- /dev/null
+++ b/apps/web/components/onboarding-brain/step-team.tsx
@@ -0,0 +1,331 @@
+"use client"
+
+import { useMemo, useState } from "react"
+import { Button } from "@ui/components/button"
+import { Input } from "@ui/components/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@ui/components/select"
+import { ArrowRight, Loader2, Mail, Plus, Trash2, Users } from "lucide-react"
+import { cn } from "@lib/utils"
+import { dmSans125ClassName } from "@/lib/fonts"
+
+const modalCardStyle = {
+ boxShadow:
+ "0 2.842px 14.211px 0 rgba(0, 0, 0, 0.25), 0.711px 0.711px 0.711px 0 rgba(255, 255, 255, 0.10) inset",
+}
+
+const inputBevelStyle = {
+ boxShadow:
+ "0px 1px 2px 0px rgba(0,43,87,0.1), inset 0px 0px 0px 1px rgba(43,49,67,0.08), inset 0px 1px 1px 0px rgba(0,0,0,0.08), inset 0px 2px 4px 0px rgba(0,0,0,0.02)",
+}
+
+const inputClass =
+ "bg-[#0F1217] border border-[rgba(82,89,102,0.2)] rounded-[12px] text-[#fafafa] text-[14px] placeholder:text-[#525D6E] h-12 shadow-none focus-visible:ring-0 focus-visible:border-[rgba(115,115,115,0.3)] transition-colors"
+
+export interface TeamValues {
+ invites: { email: string; role: "admin" | "member" }[]
+ visibility: "team-private" | "org-shared"
+ suggestChanges: boolean
+}
+
+interface Props {
+ mode: "personal" | "team"
+ isScale: boolean
+ inviteDomain: string | null
+ values: TeamValues
+ onChange: (next: TeamValues) => void
+ onContinue: () => void
+ onSkip?: () => void
+ onUpgrade: () => void
+ submitting?: boolean
+}
+
+const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi
+
+export function StepTeam({
+ mode,
+ inviteDomain,
+ values,
+ onChange,
+ onContinue,
+ onSkip,
+ submitting,
+}: Props) {
+ const [draft, setDraft] = useState("")
+ const domainOrFallback = (inviteDomain || "acme.com").trim().toLowerCase()
+
+ const addInvites = (text: string) => {
+ const found = text.match(EMAIL_RE) ?? []
+ if (found.length === 0) return
+ const existing = new Set(values.invites.map((i) => i.email.toLowerCase()))
+ const next: { email: string; role: "admin" | "member" }[] = []
+ for (const raw of found) {
+ const email = raw.trim().toLowerCase()
+ if (!email || existing.has(email)) continue
+ existing.add(email)
+ next.push({ email, role: "member" })
+ }
+ if (next.length === 0) {
+ setDraft("")
+ return
+ }
+ onChange({ ...values, invites: [...values.invites, ...next] })
+ setDraft("")
+ }
+
+ const removeInvite = (email: string) => {
+ onChange({
+ ...values,
+ invites: values.invites.filter((i) => i.email !== email),
+ })
+ }
+
+ const setRole = (email: string, role: "admin" | "member") => {
+ onChange({
+ ...values,
+ invites: values.invites.map((i) =>
+ i.email === email ? { ...i, role } : i,
+ ),
+ })
+ }
+
+ const domainBreakdown = useMemo(() => {
+ const counts = new Map()
+ for (const inv of values.invites) {
+ const at = inv.email.lastIndexOf("@")
+ if (at < 0) continue
+ const domain = inv.email.slice(at + 1).toLowerCase()
+ counts.set(domain, (counts.get(domain) ?? 0) + 1)
+ }
+ return [...counts.entries()].sort((a, b) => b[1] - a[1])
+ }, [values.invites])
+
+ if (mode === "personal") {
+ return (
+
+
+
+
+
+ Going solo — for now
+
+
+ You're in Personal mode, so there's no team step. Switch to Team in
+ the top bar anytime to invite others.
+
+
+ Continue
+
+
+
+ )
+ }
+
+ const count = values.invites.length
+
+ return (
+
+
+
+
+
+
+
+
+ Invite your team
+
+
+ A brain gets sharper as more people contribute. You can also do
+ this later.
+
+
+
+
+
+
+
+ Paste multiple emails at once — we'll split them for you.
+
+
+ {count === 0 ? (
+
+
+ No invites yet.
+
+
+ Try{" "}
+ alex@{domainOrFallback} ,{" "}
+ sam@{domainOrFallback} ,
+ etc.
+
+
+ ) : (
+ <>
+
+
+ {count} invite{count === 1 ? "" : "s"}
+
+ {domainBreakdown.length > 0 && (
+
+ {domainBreakdown.slice(0, 3).map(([d, n]) => (
+
+ {d} · {n}
+
+ ))}
+
+ )}
+
+
+ {values.invites.map((inv) => (
+
+
+
+ {(inv.email[0] ?? "?").toUpperCase()}
+
+
+
+ {inv.email}
+
+
+ setRole(inv.email, r as "admin" | "member")
+ }
+ >
+
+
+
+
+
+ Member
+
+
+ Admin
+
+
+
+
removeInvite(inv.email)}
+ className="text-[#737373] hover:text-[#fafafa] p-1 transition-colors"
+ aria-label={`Remove ${inv.email}`}
+ >
+
+
+
+ ))}
+
+ >
+ )}
+
+
+
+
+ Skip for now
+
+
+ {submitting ? (
+ <>
+ Sending…
+
+ >
+ ) : (
+ <>
+ {count > 0
+ ? `Send ${count} invite${count === 1 ? "" : "s"}`
+ : "Continue"}
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/apps/web/components/onboarding-brain/types.ts b/apps/web/components/onboarding-brain/types.ts
new file mode 100644
index 000000000..2e016f3fa
--- /dev/null
+++ b/apps/web/components/onboarding-brain/types.ts
@@ -0,0 +1,131 @@
+export type BrainMode = "personal" | "team"
+
+export type BrainStep = "about" | "sources" | "ingest" | "team"
+
+export const BRAIN_STEPS: BrainStep[] = ["about", "sources", "ingest", "team"]
+
+export const BRAIN_STEP_LABELS: Record = {
+ about: "About",
+ sources: "Tools",
+ ingest: "Flows",
+ team: "Team",
+}
+
+const FREE_EMAIL_DOMAINS = new Set([
+ "gmail.com",
+ "googlemail.com",
+ "yahoo.com",
+ "yahoo.co.uk",
+ "yahoo.co.in",
+ "outlook.com",
+ "hotmail.com",
+ "live.com",
+ "icloud.com",
+ "me.com",
+ "mac.com",
+ "aol.com",
+ "protonmail.com",
+ "proton.me",
+ "pm.me",
+ "fastmail.com",
+ "zoho.com",
+ "yandex.com",
+ "yandex.ru",
+ "mail.com",
+ "qq.com",
+ "163.com",
+ "126.com",
+ "naver.com",
+ "duck.com",
+])
+
+export function detectModeFromEmail(
+ email: string | undefined | null,
+): BrainMode {
+ if (!email) return "personal"
+ const at = email.lastIndexOf("@")
+ if (at < 0) return "personal"
+ const domain = email
+ .slice(at + 1)
+ .toLowerCase()
+ .trim()
+ if (!domain) return "personal"
+ if (FREE_EMAIL_DOMAINS.has(domain)) return "personal"
+ return "team"
+}
+
+export function workspaceNameFromEmail(
+ email: string | undefined | null,
+): string {
+ if (!email) return ""
+ const at = email.lastIndexOf("@")
+ if (at < 0) return ""
+ const domain = email.slice(at + 1).toLowerCase()
+ const root = domain.split(".")[0] ?? ""
+ if (!root) return ""
+ return root.charAt(0).toUpperCase() + root.slice(1)
+}
+
+export function workspaceDomainFromEmail(
+ email: string | undefined | null,
+): string | null {
+ if (!email) return null
+ const at = email.lastIndexOf("@")
+ if (at < 0) return null
+ const domain = email
+ .slice(at + 1)
+ .toLowerCase()
+ .trim()
+ return domain || null
+}
+
+export type BrainMetadata = {
+ brainOnboardingVersion?: "v1"
+ brainOnboardingComplete?: boolean
+ brainMode?: BrainMode
+ brainWorkspaceName?: string
+ brainWorkspaceDomain?: string | null
+ brainAbout?: string
+ brainContainerTag?: string
+ brainSources?: {
+ drive?: { status: "connected" | "pending" | "skipped" }
+ gmail?: { status: "requested" | "connected" | "skipped"; range?: string }
+ notion?: { status: "connected" | "pending" | "skipped" }
+ granola?: { status: "waitlist" | "skipped" }
+ }
+ brainInvites?: { email: string; role: "admin" | "member" }[]
+ brainPermissions?: {
+ visibility: "team-private" | "org-shared"
+ suggestChanges: boolean
+ }
+}
+
+export function generateOrgSlug(name: string): string {
+ const base =
+ name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "") || "org"
+ return `${base}-${Math.floor(100000 + Math.random() * 900000)}`
+}
+
+export function generateUsername(name: string): string {
+ const base =
+ name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "_")
+ .replace(/(^_|_$)/g, "") || "user"
+ return `${base}${Math.floor(100000 + Math.random() * 900000)}`
+}
+
+export function containerTagFromWorkspace(
+ name: string,
+ mode: BrainMode,
+): string {
+ const slug = name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "")
+ if (!slug) return mode === "team" ? "team-brain" : "personal-brain"
+ return mode === "team" ? `${slug}-brain` : `${slug}-personal`
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index 3535d9a43..cd4e10b30 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -2,7 +2,10 @@
"name": "@repo/web",
"version": "0.1.0",
"private": true,
- "portless": { "name": "app.dev.supermemory", "script": "dev:app" },
+ "portless": {
+ "name": "app.dev.supermemory",
+ "script": "dev:app"
+ },
"scripts": {
"dev": "portless",
"dev:app": "next dev --port ${PORT:-3000}",
@@ -73,6 +76,7 @@
"agents": "^0.4.0",
"ai": "^6.0.168",
"autumn-js": "1.2.12",
+ "canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
@@ -117,6 +121,7 @@
"@sentry/cli": "^2.52.0",
"@tailwindcss/postcss": "^4.1.11",
"@total-typescript/tsconfig": "^1.0.4",
+ "@types/canvas-confetti": "^1.9.0",
"@types/is-hotkey": "^0.1.10",
"@types/node": "^24.0.4",
"@types/react": "^19.2.9",
diff --git a/bun.lock b/bun.lock
index 7f815b139..40d15a9ea 100644
--- a/bun.lock
+++ b/bun.lock
@@ -186,6 +186,7 @@
"agents": "^0.4.0",
"ai": "^6.0.168",
"autumn-js": "1.2.12",
+ "canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
@@ -230,6 +231,7 @@
"@sentry/cli": "^2.52.0",
"@tailwindcss/postcss": "^4.1.11",
"@total-typescript/tsconfig": "^1.0.4",
+ "@types/canvas-confetti": "^1.9.0",
"@types/is-hotkey": "^0.1.10",
"@types/node": "^24.0.4",
"@types/react": "^19.2.9",
@@ -1981,6 +1983,8 @@
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
+ "@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
+
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/chrome": ["@types/chrome@0.1.37", "", { "dependencies": { "@types/filesystem": "*", "@types/har-format": "*" } }, "sha512-IJE4ceuDO7lrEuua7Pow47zwNcI8E6qqkowRP7aFPaZ0lrjxh6y836OPqqkIZeTX64FTogbw+4RNH0+QrweCTQ=="],
@@ -2479,6 +2483,8 @@
"canvas-color-tracker": ["canvas-color-tracker@1.3.2", "", { "dependencies": { "tinycolor2": "^1.6.0" } }, "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg=="],
+ "canvas-confetti": ["canvas-confetti@1.9.4", "", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
+
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],