diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx
index 1fbe8af1..81c973d1 100644
--- a/frontend/src/app/Main.tsx
+++ b/frontend/src/app/Main.tsx
@@ -29,6 +29,7 @@ import AnalyticsOptIn from './components/AnalyticsOptIn';
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
+import OnboardingModal from './components/OnboardingModal';
import { ClaudeTokens } from '@/shared/styles/claudeTokens';
function buildMuiTheme(c: ClaudeTokens, mode: 'light' | 'dark') {
@@ -238,6 +239,7 @@ const ThemedApp: React.FC = () => {
+
diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx
new file mode 100644
index 00000000..2aa18330
--- /dev/null
+++ b/frontend/src/app/components/OnboardingModal.tsx
@@ -0,0 +1,215 @@
+import React, { useState, useEffect } from 'react';
+import { Box, Typography, Modal, Button } from '@mui/material';
+import { useAppSelector } from '@/shared/hooks';
+import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+import { API_BASE } from '@/shared/config';
+
+const SUBSCRIPTION_PROVIDERS = [
+ { id: 'claude', name: 'Claude', desc: 'Sonnet, Opus, Haiku', color: '#E8927A' },
+ { id: 'codex', name: 'ChatGPT', desc: 'GPT-5.4, o3, o4-mini', color: '#74AA9C' },
+ { id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models', color: '#8B949E' },
+ { id: 'gemini-cli', name: 'Gemini', desc: 'Gemini 2.5 Pro & Flash', color: '#4285F4' },
+];
+
+const OnboardingModal: React.FC = () => {
+ const c = useClaudeTokens();
+ const settings = useAppSelector((s) => s.settings);
+ const [open, setOpen] = useState(false);
+ const [dismissed, setDismissed] = useState(false);
+ const [connecting, setConnecting] = useState(null);
+ const [nineRouterStatus, setNineRouterStatus] = useState(null);
+
+ // Check if user has any credentials configured
+ const hasAnyKey = !!(
+ settings.anthropic_api_key ||
+ settings.openai_api_key ||
+ settings.google_api_key ||
+ settings.openrouter_api_key
+ );
+
+ // Check 9Router subscription status
+ useEffect(() => {
+ fetch(`${API_BASE}/agents/subscriptions/status`)
+ .then((r) => r.json())
+ .then(setNineRouterStatus)
+ .catch(() => setNineRouterStatus(null));
+ }, []);
+
+ const hasSubscription = (() => {
+ if (!nineRouterStatus?.running) return false;
+ const connections = nineRouterStatus?.providers?.connections || [];
+ return connections.some((p: any) => p.isActive);
+ })();
+
+ // Show modal if no keys AND no subscriptions AND not dismissed
+ useEffect(() => {
+ if (!hasAnyKey && !hasSubscription && !dismissed && nineRouterStatus !== null) {
+ setOpen(true);
+ } else {
+ setOpen(false);
+ }
+ }, [hasAnyKey, hasSubscription, dismissed, nineRouterStatus]);
+
+ const handleConnect = async (providerId: string) => {
+ setConnecting(providerId);
+ try {
+ const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ provider: providerId }),
+ });
+ const data = await r.json();
+
+ if (data.flow === 'device_code') {
+ const verifyUrl = data.verification_uri;
+ if (verifyUrl) window.open(verifyUrl, '_blank');
+ // Poll for completion
+ const timer = setInterval(async () => {
+ try {
+ const pr = await fetch(`${API_BASE}/agents/subscriptions/poll`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ provider: providerId, device_code: data.device_code, code_verifier: data.code_verifier, extra_data: data.extra_data }),
+ });
+ const pd = await pr.json();
+ if (pd.success) {
+ clearInterval(timer);
+ setConnecting(null);
+ setOpen(false);
+ }
+ } catch {}
+ }, 5000);
+ setTimeout(() => { clearInterval(timer); setConnecting(null); }, 300000);
+ } else if (data.flow === 'authorization_code') {
+ const popup = window.open(data.auth_url, 'oauth', 'popup,width=600,height=700');
+ const handler = async (event: MessageEvent) => {
+ const d = event.data;
+ if (d?.code || d?.type === 'oauth-callback') {
+ window.removeEventListener('message', handler);
+ try {
+ await fetch(`${API_BASE}/agents/subscriptions/exchange`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ provider: providerId, code: d.code,
+ redirect_uri: data.redirect_uri, code_verifier: data.code_verifier,
+ state: d.state || data.state,
+ }),
+ });
+ } catch {}
+ if (popup && !popup.closed) popup.close();
+ setConnecting(null);
+ setOpen(false);
+ }
+ };
+ window.addEventListener('message', handler);
+ // Fallback poll
+ const timer = setInterval(async () => {
+ try {
+ const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
+ const sd = await sr.json();
+ const conns = sd.providers?.connections || [];
+ if (conns.some((p: any) => p.provider === providerId && p.isActive)) {
+ clearInterval(timer);
+ window.removeEventListener('message', handler);
+ setConnecting(null);
+ setOpen(false);
+ }
+ } catch {}
+ }, 3000);
+ setTimeout(() => { clearInterval(timer); setConnecting(null); }, 300000);
+ }
+ } catch {
+ setConnecting(null);
+ }
+ };
+
+ const handleApiKey = () => {
+ setDismissed(true);
+ setOpen(false);
+ // User will manually go to Settings → Models to add API keys
+ };
+
+ const handleSkip = () => {
+ setDismissed(true);
+ setOpen(false);
+ };
+
+ if (!open) return null;
+
+ return (
+
+
+
+ Welcome to OpenSwarm
+
+
+ Connect an AI model to get started
+
+
+ {/* Subscription options */}
+
+ Use your existing subscription
+
+
+ {SUBSCRIPTION_PROVIDERS.map((p) => (
+ !connecting && handleConnect(p.id)}
+ sx={{
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+ p: 1.5, borderRadius: `${c.radius.md}px`, border: `1px solid ${c.border.subtle}`,
+ cursor: connecting ? 'wait' : 'pointer',
+ transition: 'border-color 0.15s, background 0.15s',
+ '&:hover': { borderColor: c.border.medium, bgcolor: `${c.accent.primary}05` },
+ }}
+ >
+
+ {p.name}
+ {p.desc}
+
+
+ {connecting === p.id ? 'Connecting...' : 'Connect →'}
+
+
+ ))}
+
+
+ {/* API key option */}
+
+ Or use an API key
+
+
+
+ I have an API key
+
+
+ Go to Settings → Models to enter your key
+
+
+
+ {/* Skip */}
+
+
+
+ );
+};
+
+export default OnboardingModal;