diff --git a/.gitignore b/.gitignore
index d8641360..e2f3ca83 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,6 @@ backend/npm-servers/*/node_modules/
frontend/dist/
# Bundled uv binaries (downloaded during build)
backend/uv-bin/
+.account-factory
+openswarm-cloud
+.openswarm-cloud
diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py
index 97ebeafa..725bfa0a 100644
--- a/backend/apps/agents/agent_manager.py
+++ b/backend/apps/agents/agent_manager.py
@@ -1041,11 +1041,18 @@ class AgentManager:
"disallowed_tools": effective_disallowed,
"include_partial_messages": True,
}
- # Priority: Anthropic API key (Anthropic models only) → 9Router.
- # Non-Anthropic api_types always route through 9Router regardless
- # of whether an Anthropic API key is set.
+ # Priority: openswarm-pro mode → Anthropic API key → 9Router.
+ # Non-Anthropic api_types always route through 9Router regardless.
from backend.apps.nine_router import is_running as _9r_running
- if api_type == "anthropic" and global_settings.anthropic_api_key:
+ if api_type == "anthropic" and getattr(global_settings, "connection_mode", "own_key") == "openswarm-pro":
+ proxy_url = getattr(global_settings, "openswarm_proxy_url", None) or "https://api.openswarm.com"
+ bearer = getattr(global_settings, "openswarm_bearer_token", "") or ""
+ options_kwargs["env"] = {
+ "ANTHROPIC_AUTH_TOKEN": bearer,
+ "ANTHROPIC_BASE_URL": proxy_url,
+ }
+ logger.info(f"[MCP-DEBUG] Using OpenSwarm Pro proxy at {proxy_url}")
+ elif api_type == "anthropic" and global_settings.anthropic_api_key:
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
logger.info("[MCP-DEBUG] Using direct Anthropic API key")
elif _9r_running():
diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py
index 39b56c20..6ffac10f 100644
--- a/backend/apps/agents/agents.py
+++ b/backend/apps/agents/agents.py
@@ -336,7 +336,11 @@ async def list_models():
continue
elif api == "anthropic":
has_key = bool(getattr(settings, "anthropic_api_key", None))
- if not has_key and "claude" not in connected:
+ is_openswarm_pro = (
+ getattr(settings, "connection_mode", "own_key") == "openswarm-pro"
+ and bool(getattr(settings, "openswarm_bearer_token", None))
+ )
+ if not has_key and "claude" not in connected and not is_openswarm_pro:
continue
visible.append({
"value": m["value"],
diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py
index 87a598e3..921584d4 100644
--- a/backend/apps/agents/providers/registry.py
+++ b/backend/apps/agents/providers/registry.py
@@ -259,6 +259,7 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
"""Resolve a short model name into the id string passed to ClaudeAgentOptions.
Priority:
+ - Anthropic model + openswarm-pro mode → bare `model_id` (our cloud proxy)
- Anthropic model with an API key set → bare `model_id` (real Anthropic API)
- Everything else → `router_model_id` (9Router with cc/ cx/ gc/ gh/ prefix)
- Unknown names pass through unchanged
@@ -266,8 +267,11 @@ def resolve_model_id_for_sdk(short_name: str, settings: AppSettings) -> str:
entry = _find_builtin_model(short_name)
if entry is None:
return short_name
- if entry.get("api") == "anthropic" and getattr(settings, "anthropic_api_key", None):
- return entry.get("model_id", short_name)
+ if entry.get("api") == "anthropic":
+ if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
+ return entry.get("model_id", short_name)
+ if getattr(settings, "anthropic_api_key", None):
+ return entry.get("model_id", short_name)
return entry.get("router_model_id", entry.get("model_id", short_name))
@@ -294,6 +298,11 @@ async def resolve_aux_model(settings: AppSettings, preferred_tier: str = "haiku"
sonnet_bare = "claude-sonnet-4-20250514"
bare = haiku_bare if preferred_tier == "haiku" else sonnet_bare
+ # OpenSwarm Pro — route through our cloud proxy
+ if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
+ proxy_url = getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.com"
+ return (bare, proxy_url)
+
# Direct API key wins
if getattr(settings, "anthropic_api_key", None):
return (bare, None)
@@ -361,10 +370,10 @@ def create_provider(
if api_type == "anthropic":
from backend.apps.agents.providers.anthropic import AnthropicProvider
- if getattr(settings, "connection_mode", "own_key") == "managed":
+ if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return AnthropicProvider(
- auth_token=getattr(settings, "openswarm_auth_token", None),
- base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.ai",
+ auth_token=getattr(settings, "openswarm_bearer_token", None),
+ base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.com",
)
# Priority: API key → 9Router subscription
if settings.anthropic_api_key:
@@ -467,8 +476,8 @@ def _has_credentials(provider_name: str, settings: AppSettings) -> bool:
api_type = _get_api_type(provider_name)
if api_type == "anthropic":
- if getattr(settings, "connection_mode", "own_key") == "managed":
- return bool(getattr(settings, "openswarm_auth_token", None))
+ if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
+ return bool(getattr(settings, "openswarm_bearer_token", None))
return bool(settings.anthropic_api_key)
if api_type == "openai":
return bool(settings.openai_api_key)
diff --git a/backend/apps/settings/credentials.py b/backend/apps/settings/credentials.py
index 60026faf..3cd4d293 100644
--- a/backend/apps/settings/credentials.py
+++ b/backend/apps/settings/credentials.py
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
import anthropic
from backend.apps.settings.models import AppSettings
-OPENSWARM_DEFAULT_PROXY_URL = "https://api.openswarm.ai"
+OPENSWARM_DEFAULT_PROXY_URL = "https://api.openswarm.com"
def _check_9router() -> bool:
@@ -42,8 +42,8 @@ def validate_credentials(settings: AppSettings, provider: str = "anthropic") ->
return
if p == "anthropic":
- if getattr(settings, "connection_mode", "own_key") == "managed":
- if not getattr(settings, "openswarm_auth_token", None):
+ if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
+ if not getattr(settings, "openswarm_bearer_token", None):
raise ValueError("Open Swarm account not connected. Sign in via Settings -> API.")
return
if settings.anthropic_api_key:
@@ -81,9 +81,9 @@ def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str,
validate_credentials(settings, provider)
if p in ("anthropic", "claude"):
- if getattr(settings, "connection_mode", "own_key") == "managed":
+ if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
return {
- "auth_token": getattr(settings, "openswarm_auth_token", "") or "",
+ "auth_token": getattr(settings, "openswarm_bearer_token", "") or "",
"base_url": getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL,
}
return {"api_key": settings.anthropic_api_key or ""}
@@ -116,10 +116,10 @@ def get_agent_sdk_env(settings: AppSettings) -> dict[str, str]:
"""
validate_credentials(settings, "anthropic")
- if getattr(settings, "connection_mode", "own_key") == "managed":
+ if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
return {
- "ANTHROPIC_AUTH_TOKEN": getattr(settings, "openswarm_auth_token", ""),
+ "ANTHROPIC_AUTH_TOKEN": getattr(settings, "openswarm_bearer_token", ""),
"ANTHROPIC_BASE_URL": proxy_url,
}
@@ -133,10 +133,10 @@ def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
"""
import anthropic
- if getattr(settings, "connection_mode", "own_key") == "managed":
+ if getattr(settings, "connection_mode", "own_key") == "openswarm-pro":
proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
return anthropic.AsyncAnthropic(
- auth_token=getattr(settings, "openswarm_auth_token", None),
+ auth_token=getattr(settings, "openswarm_bearer_token", None),
base_url=proxy_url,
)
diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py
index fe7d6b07..5be0f2ff 100644
--- a/backend/apps/settings/models.py
+++ b/backend/apps/settings/models.py
@@ -69,6 +69,13 @@ class AppSettings(BaseModel):
analytics_opt_in: bool = True
installation_id: Optional[str] = None
first_opened_at: Optional[str] = None # ISO timestamp of first app open
+ # OpenSwarm Pro subscription
+ connection_mode: str = "own_key" # "own_key" | "openswarm-pro"
+ openswarm_bearer_token: Optional[str] = None
+ openswarm_proxy_url: Optional[str] = None # default resolved in credentials.py
+ openswarm_subscription_plan: Optional[str] = None # "hobby"|"pro"|"pro_plus"|"ultra"
+ openswarm_subscription_expires: Optional[str] = None # ISO 8601
+ openswarm_usage_cached: Optional[dict] = None # {count, limit, window_end_at}
class CustomProvider(BaseModel):
diff --git a/backend/apps/settings/settings.py b/backend/apps/settings/settings.py
index 2a000efc..70d3536c 100644
--- a/backend/apps/settings/settings.py
+++ b/backend/apps/settings/settings.py
@@ -27,11 +27,27 @@ async def settings_lifespan():
settings = SubApp("settings", settings_lifespan)
+def _migrate_legacy_fields(raw: dict) -> dict:
+ """Translate deprecated field names/values so they survive into the new schema.
+
+ Pre-launch scaffolding used `connection_mode="managed"` and
+ `openswarm_auth_token`; production names are `"openswarm-pro"` and
+ `openswarm_bearer_token`. Zero known users are affected, but keep the
+ mapping for safety.
+ """
+ if raw.get("connection_mode") == "managed":
+ raw["connection_mode"] = "openswarm-pro"
+ if "openswarm_auth_token" in raw and "openswarm_bearer_token" not in raw:
+ raw["openswarm_bearer_token"] = raw.pop("openswarm_auth_token")
+ return raw
+
+
def load_settings() -> AppSettings:
"""Load settings from JSON file, returning defaults if not found."""
if os.path.exists(SETTINGS_FILE):
with open(SETTINGS_FILE) as f:
- settings = AppSettings(**json.load(f))
+ raw = _migrate_legacy_fields(json.load(f))
+ settings = AppSettings(**raw)
if settings.default_system_prompt is None:
settings.default_system_prompt = DEFAULT_SYSTEM_PROMPT
return settings
diff --git a/backend/apps/subscription/__init__.py b/backend/apps/subscription/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/apps/subscription/router.py b/backend/apps/subscription/router.py
new file mode 100644
index 00000000..d1fbbe45
--- /dev/null
+++ b/backend/apps/subscription/router.py
@@ -0,0 +1,208 @@
+"""Desktop-side subscription endpoints."""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+from contextlib import asynccontextmanager
+from typing import Optional
+
+import httpx
+from fastapi import HTTPException
+from pydantic import BaseModel
+
+from backend.config.Apps import SubApp
+from backend.apps.settings.credentials import OPENSWARM_DEFAULT_PROXY_URL
+from backend.apps.settings.settings import SETTINGS_FILE, load_settings
+
+logger = logging.getLogger(__name__)
+
+
+@asynccontextmanager
+async def subscription_lifespan():
+ yield
+
+
+subscription = SubApp("subscription", subscription_lifespan)
+
+
+def _proxy_url() -> str:
+ """Cloud router base URL. Overridable per-user via settings, falling back
+ to the module-default. No trailing slash."""
+ settings_obj = load_settings()
+ url = (getattr(settings_obj, "openswarm_proxy_url", None)
+ or OPENSWARM_DEFAULT_PROXY_URL)
+ return url.rstrip("/")
+
+
+def _write_settings(settings_obj) -> None:
+ """Persist AppSettings to disk. Mirrors backend/apps/settings/settings.py
+ _save_settings to avoid importing a private module member."""
+ os.makedirs(os.path.dirname(SETTINGS_FILE), exist_ok=True)
+ with open(SETTINGS_FILE, "w") as f:
+ json.dump(settings_obj.model_dump(), f, indent=2)
+
+
+# ---------------------------------------------------------------------------
+# POST /api/subscription/activate
+# ---------------------------------------------------------------------------
+
+class ActivateRequest(BaseModel):
+ token: str
+ plan: Optional[str] = None
+ expires: Optional[str] = None # ISO 8601
+
+
+@subscription.router.post("/activate")
+async def activate(body: ActivateRequest):
+ """Renderer calls this after catching an openswarm://auth deep link.
+
+ Validates the bearer by calling the cloud /api/me, then persists it to
+ settings. On success the desktop app flips into openswarm-pro mode for
+ subsequent Claude requests.
+ """
+ if not body.token or len(body.token) < 16:
+ raise HTTPException(status_code=400, detail="Invalid token")
+
+ proxy = _proxy_url()
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ r = await client.get(
+ f"{proxy}/api/me",
+ headers={"Authorization": f"Bearer {body.token}"},
+ )
+ except httpx.HTTPError as e:
+ raise HTTPException(
+ status_code=502,
+ detail=f"Could not reach subscription service: {e}",
+ )
+
+ if r.status_code == 401:
+ raise HTTPException(status_code=401, detail="Token rejected by service")
+ if r.status_code >= 400:
+ raise HTTPException(
+ status_code=r.status_code,
+ detail=r.text[:200] or "Service error",
+ )
+
+ me = r.json()
+
+ # Persist to settings. Prefer cloud-reported values; fall back to the
+ # deep-link's own fields if cloud is sparse.
+ settings_obj = load_settings()
+ settings_obj.connection_mode = "openswarm-pro"
+ settings_obj.openswarm_bearer_token = body.token
+ settings_obj.openswarm_proxy_url = proxy
+ settings_obj.openswarm_subscription_plan = (
+ me.get("plan") or body.plan or "pro"
+ )
+ period_end = me.get("current_period_end")
+ if isinstance(period_end, (int, float)):
+ # cloud returns unix ms
+ from datetime import datetime, timezone
+ settings_obj.openswarm_subscription_expires = (
+ datetime.fromtimestamp(period_end / 1000, tz=timezone.utc).isoformat()
+ )
+ elif body.expires:
+ settings_obj.openswarm_subscription_expires = body.expires
+
+ usage = me.get("usage")
+ if isinstance(usage, dict):
+ settings_obj.openswarm_usage_cached = usage
+
+ _write_settings(settings_obj)
+ return {"ok": True, "plan": settings_obj.openswarm_subscription_plan}
+
+
+# ---------------------------------------------------------------------------
+# GET /api/subscription/status
+# ---------------------------------------------------------------------------
+
+@subscription.router.get("/status")
+async def status():
+ """Consolidated view for the Settings card. Reads persisted plan/expires,
+ polls cloud for live usage when a bearer is present."""
+ settings_obj = load_settings()
+ bearer = getattr(settings_obj, "openswarm_bearer_token", None)
+ plan = getattr(settings_obj, "openswarm_subscription_plan", None)
+ expires = getattr(settings_obj, "openswarm_subscription_expires", None)
+ mode = getattr(settings_obj, "connection_mode", "own_key")
+
+ if mode != "openswarm-pro" or not bearer:
+ return {
+ "connected": False,
+ "connection_mode": mode,
+ }
+
+ # Best-effort live fetch — surface stale cache if cloud is unreachable.
+ live_usage = None
+ live_status = None
+ try:
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ r = await client.get(
+ f"{_proxy_url()}/api/me",
+ headers={"Authorization": f"Bearer {bearer}"},
+ )
+ if r.status_code == 200:
+ me = r.json()
+ live_usage = me.get("usage")
+ live_status = me.get("status")
+ # Update cache for offline display.
+ if isinstance(live_usage, dict):
+ settings_obj.openswarm_usage_cached = live_usage
+ _write_settings(settings_obj)
+ except httpx.HTTPError as e:
+ logger.debug("subscription/status live fetch failed: %s", e)
+
+ return {
+ "connected": True,
+ "connection_mode": mode,
+ "plan": plan,
+ "status": live_status or "active",
+ "expires": expires,
+ "usage": live_usage or getattr(settings_obj, "openswarm_usage_cached", None),
+ }
+
+
+# ---------------------------------------------------------------------------
+# POST /api/subscription/portal
+# ---------------------------------------------------------------------------
+
+@subscription.router.post("/portal")
+async def portal():
+ """Returns a Stripe Customer Portal URL. Renderer opens it in the
+ system browser via shell.openExternal."""
+ settings_obj = load_settings()
+ bearer = getattr(settings_obj, "openswarm_bearer_token", None)
+ if not bearer:
+ raise HTTPException(status_code=400, detail="Not subscribed")
+
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ r = await client.post(
+ f"{_proxy_url()}/api/billing/portal",
+ headers={"Authorization": f"Bearer {bearer}"},
+ )
+ if r.status_code >= 400:
+ raise HTTPException(status_code=r.status_code, detail=r.text[:200])
+ data = r.json()
+ return {"url": data.get("url")}
+
+
+# ---------------------------------------------------------------------------
+# POST /api/subscription/disconnect
+# ---------------------------------------------------------------------------
+
+@subscription.router.post("/disconnect")
+async def disconnect():
+ """Clears local bearer + reverts to own_key mode. Does NOT cancel the
+ Stripe subscription (use the portal for that). Useful when a user wants
+ to temporarily route through their own API key."""
+ settings_obj = load_settings()
+ settings_obj.connection_mode = "own_key"
+ settings_obj.openswarm_bearer_token = None
+ settings_obj.openswarm_subscription_plan = None
+ settings_obj.openswarm_subscription_expires = None
+ settings_obj.openswarm_usage_cached = None
+ _write_settings(settings_obj)
+ return {"ok": True}
diff --git a/backend/main.py b/backend/main.py
index ab38896a..ad410676 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -38,11 +38,12 @@ from backend.apps.skill_registry.skill_registry import skill_registry
from backend.apps.outputs.outputs import outputs
from backend.apps.dashboards.dashboards import dashboards
from backend.apps.analytics.analytics import analytics
+from backend.apps.subscription.router import subscription
from fastapi.middleware.cors import CORSMiddleware
from fastapi import WebSocket, WebSocketDisconnect
import json
-main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, analytics])
+main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, analytics, subscription])
app = main_app.app
app.add_middleware(
diff --git a/debugger/debugger_backend/Directory.py b/debugger/debugger_backend/Directory.py
index 412e0ab3..fe23db47 100644
--- a/debugger/debugger_backend/Directory.py
+++ b/debugger/debugger_backend/Directory.py
@@ -87,7 +87,7 @@ class Directory:
if debug_file.calls_debug_function():
parent_dir.add_child(debug_file)
else:
- raise Exception(f"[build_structure]: Entry is not dir or file: {entry.path}")
+ continue
construct_project_structure(root_dir, self)
# [print(f"[build_structure]: {file}") for file in project_structure]
diff --git a/electron/main.js b/electron/main.js
index 6bd077fe..f313658c 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -12,11 +12,45 @@ const http = require('http');
// (or macOS auto-launch + manual launch overlapping) spawns two independent
// processes — each with its own backend on a different port — resulting in
// one populated window and one empty window.
+// Register openswarm:// protocol handler BEFORE any gotLock branching.
+// Must happen synchronously at the top of main.js so the OS knows this
+// binary is the default handler even before whenReady fires.
+if (process.defaultApp) {
+ // Dev run: `electron .` needs the entry-script path to re-launch cleanly.
+ if (process.argv.length >= 2) {
+ app.setAsDefaultProtocolClient('openswarm', process.execPath, [path.resolve(process.argv[1])]);
+ }
+} else {
+ app.setAsDefaultProtocolClient('openswarm');
+}
+
+// Pending deep-link captured before mainWindow exists (cold-launch case).
+// Flushed to renderer once mainWindow is ready.
+let pendingDeepLink = null;
+
+function forwardDeepLinkToRenderer(url) {
+ if (!url) return;
+ if (mainWindow && mainWindow.webContents && !mainWindow.webContents.isLoading()) {
+ mainWindow.webContents.send('openswarm:auth-url', url);
+ } else {
+ pendingDeepLink = url;
+ }
+}
+
+function extractOpenswarmUrl(argv) {
+ return argv && argv.find((a) => typeof a === 'string' && a.startsWith('openswarm://'));
+}
+
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.exit(0);
} else {
- app.on('second-instance', () => {
+ app.on('second-instance', (_event, argv) => {
+ // Windows/Linux: a `openswarm://...` click lands here because the OS
+ // re-launches the app with the URL as an argv. We swallow the second
+ // instance, focus the existing window, and forward the URL to renderer.
+ const url = extractOpenswarmUrl(argv);
+ if (url) forwardDeepLinkToRenderer(url);
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
@@ -24,6 +58,14 @@ if (!gotLock) {
});
}
+// macOS-only: clicks on openswarm:// links fire this event (instead of
+// relaunching the process).
+app.on('open-url', (event, url) => {
+ event.preventDefault();
+ forwardDeepLinkToRenderer(url);
+ if (mainWindow) mainWindow.focus();
+});
+
app.commandLine.appendSwitch('disable-features', 'HardwareMediaKeyHandling');
app.commandLine.appendSwitch('ignore-gpu-blocklist');
app.commandLine.appendSwitch('enable-gpu-rasterization');
@@ -253,6 +295,15 @@ function createWindow() {
mainWindow.webContents.send('webview-new-window', url, mainWindow.webContents.id);
});
+ // Once the renderer has loaded, flush any deep-link URL we captured before
+ // the window existed (cold-launch via openswarm://).
+ mainWindow.webContents.once('did-finish-load', () => {
+ if (pendingDeepLink) {
+ mainWindow.webContents.send('openswarm:auth-url', pendingDeepLink);
+ pendingDeepLink = null;
+ }
+ });
+
mainWindow.on('closed', () => {
mainWindow = null;
});
@@ -317,6 +368,12 @@ function killBackend() {
}
app.whenReady().then(async () => {
+ // Cold-launch: if the OS opened us via openswarm:// (Windows/Linux it's
+ // in argv; macOS fires open-url AFTER whenReady which we handle above)
+ // buffer the URL for when mainWindow loads.
+ const initialDeepLink = extractOpenswarmUrl(process.argv);
+ if (initialDeepLink) pendingDeepLink = initialDeepLink;
+
if (process.platform === 'darwin' && !isPackaged) {
try { app.dock.setIcon(iconPath); } catch (_) {}
}
diff --git a/electron/preload.js b/electron/preload.js
index c6c2ac2b..3f1e8c38 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -54,5 +54,13 @@ const { contextBridge, ipcRenderer } = require('electron');
ipcRenderer.on('webview-new-window', listener);
return () => ipcRenderer.removeListener('webview-new-window', listener);
},
+
+ // Deep-link callback: fires when the OS opens the app with an
+ // openswarm://auth?token=... URL (after Stripe-hosted checkout).
+ onAuthUrl: (cb) => {
+ const listener = (_event, url) => cb(url);
+ ipcRenderer.on('openswarm:auth-url', listener);
+ return () => ipcRenderer.removeListener('openswarm:auth-url', listener);
+ },
});
})();
diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx
index 9f62f179..491c6507 100644
--- a/frontend/src/app/Main.tsx
+++ b/frontend/src/app/Main.tsx
@@ -25,6 +25,7 @@ import Analytics from './pages/Analytics/Analytics';
import OnboardingModal from './components/OnboardingModal';
import { trackEvent, getLastAction, getLastPage, getTimeSpent } from '@/shared/analytics';
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
+import { useDeepLink } from '@/shared/hooks/useDeepLink';
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
import { ClaudeTokens } from '@/shared/styles/claudeTokens';
@@ -155,6 +156,11 @@ const ShortcutsProvider: React.FC<{ children: React.ReactNode }> = ({ children }
return <>{children}>;
};
+const DeepLinkListener: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ useDeepLink();
+ return <>{children}>;
+};
+
const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
const { setMode: setThemeMode } = useThemeMode();
@@ -244,23 +250,25 @@ const ThemedApp: React.FC = () => {
-
- }>
- } />
- {/* Dashboard route is a no-op stub — the actual is rendered
- persistently inside AppShell so its webviews survive navigation between
- routes. This route exists only so React Router matches the URL. */}
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
-
+
+
+ }>
+ } />
+ {/* Dashboard route is a no-op stub — the actual is rendered
+ persistently inside AppShell so its webviews survive navigation between
+ routes. This route exists only so React Router matches the URL. */}
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
diff --git a/frontend/src/app/components/OnboardingModal.tsx b/frontend/src/app/components/OnboardingModal.tsx
index 41b389cc..d7268f7d 100644
--- a/frontend/src/app/components/OnboardingModal.tsx
+++ b/frontend/src/app/components/OnboardingModal.tsx
@@ -45,10 +45,10 @@ function isValidEmail(email: string): boolean {
}
const SUBSCRIPTION_PROVIDERS = [
- { id: 'claude', name: 'Claude', desc: 'Sonnet, Opus, Haiku', color: '#E8927A', preview: false },
+ { id: 'openswarm-pro', name: 'OpenSwarm Pro', desc: 'One subscription — no setup, no Claude account needed', color: '#6366F1', preview: false, recommended: true },
+ { id: 'claude', name: 'Claude', desc: 'Use your own Claude Pro/Max subscription', color: '#E8927A', preview: false },
{ id: 'gemini-cli', name: 'Gemini', desc: 'Gemini 2.5 Pro & Flash', color: '#4285F4', preview: true },
{ id: 'codex', name: 'ChatGPT', desc: 'GPT-5.4, o3, o4-mini', color: '#74AA9C', preview: true },
- { id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models', color: '#8B949E', preview: true },
];
const USE_CASES = [
@@ -150,6 +150,21 @@ const OnboardingModal: React.FC = () => {
};
}, []);
+ // Auto-dismiss when a subscription activates via deep link while the
+ // modal is open. activateSubscription refetches settings; we watch the
+ // connection_mode field flipping to openswarm-pro.
+ useEffect(() => {
+ if (!open) return;
+ const mode = (settings.data as any).connection_mode;
+ const bearer = (settings.data as any).openswarm_bearer_token;
+ if (mode === 'openswarm-pro' && bearer) {
+ trackEvent('onboarding.openswarm_pro_activated');
+ dismiss();
+ }
+ // dismiss is stable enough — don't include in deps
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, settings.data]);
+
const dismiss = async () => {
localStorage.setItem('openswarm_onboarding_seen', 'true');
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
@@ -262,6 +277,28 @@ const OnboardingModal: React.FC = () => {
setConnecting(providerId);
trackEvent('onboarding.provider_selected', { provider: providerId });
+ // OpenSwarm Pro: no OAuth — just open the pricing/checkout page in the
+ // system browser. The post-payment openswarm://auth deep link will
+ // dismiss this modal automatically via useDeepLink → fetchSettings.
+ if (providerId === 'openswarm-pro') {
+ try {
+ const r = await fetch('https://api.openswarm.com/api/stripe/checkout', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ plan: 'pro', billing_interval: 'monthly' }),
+ });
+ if (r.ok) {
+ const { url } = await r.json();
+ const api = (window as any).openswarm;
+ if (url && api?.openExternal) api.openExternal(url);
+ else if (url) window.open(url, '_blank');
+ }
+ } catch (e) {
+ console.error('Failed to create checkout session:', e);
+ }
+ return;
+ }
+
// Delay before calling connect — avoids Claude OAuth rate limit on retries
await new Promise(r => setTimeout(r, 1000));
diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx
index e40315a4..2a6e37df 100644
--- a/frontend/src/app/pages/AgentChat/ChatInput.tsx
+++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx
@@ -202,6 +202,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
const modesArr = useMemo(() => Object.values(modesMap), [modesMap]);
const modelsByProvider = useAppSelector((state) => state.models.byProvider);
const modelsLoaded = useAppSelector((state) => state.models.loaded);
+ const connectionMode = useAppSelector((state) => state.settings.data.connection_mode);
const toolItems = useAppSelector((state) => state.tools.items);
// Count the total number of enabled MCP tool permissions (non-deny) across
@@ -226,21 +227,27 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
const MCP_WARNING_LS_KEY = 'openswarm:nonClaudeMcpWarningDismissed';
const MCP_WARNING_THRESHOLD = 20;
- // Build flat model list with provider grouping
+ // Build flat model list with provider grouping. When in openswarm-pro mode
+ // the Anthropic section is renamed to "OpenSwarm Pro" so the user sees their
+ // paid subscription is powering the Claude models.
const allModelOptions = useMemo(() => {
+ const isPro = connectionMode === 'openswarm-pro';
+ const renameAnthropic = (prov: string) => (isPro && prov === 'Anthropic' ? 'OpenSwarm Pro' : prov);
if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) {
- return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: 'Anthropic' })), grouped: { Anthropic: FALLBACK_MODELS } };
+ const key = renameAnthropic('Anthropic');
+ return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: key })), grouped: { [key]: FALLBACK_MODELS } };
}
const flat: Array<{ value: string; label: string; context_window: number; provider: string; reasoning: boolean }> = [];
const grouped: Record> = {};
for (const [prov, models] of Object.entries(modelsByProvider)) {
- grouped[prov] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, reasoning: !!m.reasoning }));
+ const key = renameAnthropic(prov);
+ grouped[key] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, reasoning: !!m.reasoning }));
for (const m of models) {
- flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: prov, reasoning: !!m.reasoning });
+ flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: key, reasoning: !!m.reasoning });
}
}
return { flat, grouped };
- }, [modelsByProvider, modelsLoaded]);
+ }, [modelsByProvider, modelsLoaded, connectionMode]);
useEffect(() => {
if (modesArr.length === 0) dispatch(fetchModes());
@@ -1096,7 +1103,13 @@ const ChatInput = forwardRef(({ onSend, disabled, mode,
// menu layout static avoids the "cursor chases a moving
// target" problem that happens when items above the cursor
// appear/disappear.
+ const isOpenSwarmPro = prov === 'OpenSwarm Pro';
const brandColor = PROVIDER_COLORS[prov.toLowerCase()] ?? c.text.tertiary;
+ // OpenSwarm Pro uses a warm blue→pink→orange gradient to stand
+ // out as the recommended paid tier (distinct from plain provider
+ // brand dots).
+ const OPENSWARM_GRADIENT =
+ 'linear-gradient(135deg, #8FB3FF 0%, #E56BC4 45%, #FFA85C 100%)';
return [