diff --git a/apps/web/app/components/workspace/markdown-editor.tsx b/apps/web/app/components/workspace/markdown-editor.tsx
index d234861b318..eab8bf03f1e 100644
--- a/apps/web/app/components/workspace/markdown-editor.tsx
+++ b/apps/web/app/components/workspace/markdown-editor.tsx
@@ -399,7 +399,7 @@ export function MarkdownEditor({
{/* Bubble menu for text selection */}
-
+
) => void;
- deleteNode: () => void;
- selected: boolean;
-}) {
+}: ReactNodeViewProps) {
+ const configAttr = node.attrs.config as string;
const [showSource, setShowSource] = useState(false);
- const [editValue, setEditValue] = useState(node.attrs.config);
+ const [editValue, setEditValue] = useState(configAttr);
let parsedConfig: ReportConfig | null = null;
let parseError: string | null = null;
try {
- const parsed = JSON.parse(node.attrs.config);
+ const parsed = JSON.parse(configAttr);
if (parsed?.panels && Array.isArray(parsed.panels)) {
parsedConfig = parsed as ReportConfig;
} else {
@@ -76,7 +72,7 @@ function ReportBlockView({
if (showSource) {
handleSaveSource();
} else {
- setEditValue(node.attrs.config);
+ setEditValue(configAttr);
setShowSource(true);
}
}}
@@ -128,7 +124,7 @@ function ReportBlockView({
+ }>
+
+
+ );
+}
+
+function WorkspacePageInner() {
const searchParams = useSearchParams();
const router = useRouter();
const initialPathHandled = useRef(false);
diff --git a/apps/web/lib/report-blocks.ts b/apps/web/lib/report-blocks.ts
index d80703d882b..f6f117f1cfd 100644
--- a/apps/web/lib/report-blocks.ts
+++ b/apps/web/lib/report-blocks.ts
@@ -3,26 +3,9 @@
* Extracted from chat-message.tsx for testability.
*/
-export type ReportConfig = {
- version: number;
- title: string;
- description?: string;
- panels: Array<{
- id: string;
- title: string;
- type: string;
- sql: string;
- mapping: Record;
- size?: string;
- }>;
- filters?: Array<{
- id: string;
- type: string;
- label: string;
- column: string;
- sql?: string;
- }>;
-};
+import type { ReportConfig } from "../app/components/charts/types";
+
+export type { ReportConfig };
export type ParsedSegment =
| { type: "text"; text: string }
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index 219381a48c7..c5813ac0417 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -1,6 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
+ // Produce a self-contained build in .next/standalone so the npm package
+ // can run `node .next/standalone/server.js` without a full node_modules.
+ output: "standalone",
+
// Allow long-running API routes for agent streaming
serverExternalPackages: [],
diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json
index d8908f7346f..515b0220d7f 100644
--- a/apps/web/package-lock.json
+++ b/apps/web/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "openclaw-web",
+ "name": "ironclaw-web",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "openclaw-web",
+ "name": "ironclaw-web",
"version": "0.1.0",
"dependencies": {
"@ai-sdk/react": "^3.0.75",
@@ -33,6 +33,7 @@
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "react-is": "^19.2.4",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1"
@@ -110,6 +111,7 @@
},
"../../node_modules/.pnpm/@types+react-dom@19.1.5_@types+react@19.1.4/node_modules/@types/react-dom": {
"version": "19.1.5",
+ "dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.0.0"
@@ -117,6 +119,7 @@
},
"../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react": {
"version": "19.1.4",
+ "dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -4243,8 +4246,7 @@
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
@@ -4958,126 +4960,6 @@
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
- },
- "node_modules/@next/swc-darwin-arm64": {
- "version": "15.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.3.tgz",
- "integrity": "sha512-WRJERLuH+O3oYB4yZNVahSVFmtxRNjNF1I1c34tYMoJb0Pve+7/RaLAJJizyYiFhjYNGHRAE1Ri2Fd23zgDqhg==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-darwin-x64": {
- "version": "15.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.3.tgz",
- "integrity": "sha512-XHdzH/yBc55lu78k/XwtuFR/ZXUTcflpRXcsu0nKmF45U96jt1tsOZhVrn5YH+paw66zOANpOnFQ9i6/j+UYvw==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-arm64-gnu": {
- "version": "15.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.3.tgz",
- "integrity": "sha512-VZ3sYL2LXB8znNGcjhocikEkag/8xiLgnvQts41tq6i+wql63SMS1Q6N8RVXHw5pEUjiof+II3HkDd7GFcgkzw==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-arm64-musl": {
- "version": "15.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.3.tgz",
- "integrity": "sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-x64-gnu": {
- "version": "15.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.3.tgz",
- "integrity": "sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-linux-x64-musl": {
- "version": "15.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.3.tgz",
- "integrity": "sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-win32-arm64-msvc": {
- "version": "15.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.3.tgz",
- "integrity": "sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ==",
- "cpu": [
- "arm64"
- ],
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10"
- }
- },
- "node_modules/@next/swc-win32-x64-msvc": {
- "version": "15.3.3",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.3.tgz",
- "integrity": "sha512-4QZG6F8enl9/S2+yIiOiju0iCTFd93d8VC1q9LZS4p/Xuk81W2QDjCFeoogmrWWkAD59z8ZxepBQap2dKS5ruw==",
- "cpu": [
- "x64"
- ],
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">= 10"
- }
}
}
}
diff --git a/apps/web/package.json b/apps/web/package.json
index 5aa1ea3c3cd..3108665b033 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -35,6 +35,7 @@
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "react-is": "^19.2.4",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1"
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 5e716a7be18..abf0a7d99a2 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -1,7 +1,7 @@
{
"compilerOptions": {
- "target": "ES2022",
- "lib": ["dom", "dom.iterable", "ES2022"],
+ "target": "ES2023",
+ "lib": ["dom", "dom.iterable", "ES2023"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
diff --git a/package.json b/package.json
index 17287f3fc39..7a3be69e69f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ironclaw",
- "version": "2026.2.6-3",
+ "version": "2026.2.6-3.4",
"description": "AI-powered CRM platform with multi-channel agent gateway, DuckDB workspace, and knowledge management",
"keywords": [],
"license": "MIT",
@@ -9,6 +9,11 @@
"ironclaw": "openclaw.mjs"
},
"files": [
+ "apps/web/.next/standalone/",
+ "apps/web/.next/static/",
+ "apps/web/public/",
+ "apps/web/package.json",
+ "apps/web/next.config.ts",
"assets/",
"CHANGELOG.md",
"dist/",
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index 9a295501ee9..d820a289645 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
-# deploy.sh — build and publish openclaw-ai-sdk to npm
+# deploy.sh — build and publish ironclaw to npm
#
# Versioning convention (mirrors upstream openclaw tags):
# --upstream Sync to an upstream release version.
@@ -16,7 +16,7 @@
set -euo pipefail
-PACKAGE_NAME="openclaw-ai-sdk"
+PACKAGE_NAME="ironclaw"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -176,12 +176,17 @@ npm version "$VERSION" --no-git-tag-version --allow-same-version "${NPM_FLAGS[@]
if [[ "$SKIP_BUILD" != true ]]; then
echo "building..."
pnpm build
+
+ # Build the Ironclaw Next.js web app so the npm package ships a ready-to-run
+ # production build (gateway can skip the build step at startup).
+ echo "building web app..."
+ (cd apps/web && npm install --legacy-peer-deps && npx next build)
fi
# ── publish ──────────────────────────────────────────────────────────────────
# Always tag as "latest" — npm skips the latest tag for prerelease versions
-# by default, but we want `npm i -g openclaw-ai-sdk` to always resolve to
+# by default, but we want `npm i -g ironclaw` to always resolve to
# the most recently published version.
echo "publishing ${PACKAGE_NAME}@${VERSION}..."
npm publish --access public --tag latest "${NPM_FLAGS[@]}"
diff --git a/src/commands/onboard-non-interactive/local/gateway-config.ts b/src/commands/onboard-non-interactive/local/gateway-config.ts
index 4f509bb203b..edffb7413e1 100644
--- a/src/commands/onboard-non-interactive/local/gateway-config.ts
+++ b/src/commands/onboard-non-interactive/local/gateway-config.ts
@@ -99,6 +99,7 @@ export function applyNonInteractiveGatewayConfig(params: {
mode: tailscaleMode,
resetOnExit: tailscaleResetOnExit,
},
+ webApp: { enabled: true },
},
};
diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts
index 1bb17c9c72c..4a971d7b6a4 100644
--- a/src/config/types.gateway.ts
+++ b/src/config/types.gateway.ts
@@ -211,6 +211,15 @@ export type GatewayNodesConfig = {
denyCommands?: string[];
};
+export type GatewayWebAppConfig = {
+ /** If true, the Gateway will build and serve the Ironclaw Next.js web app. Default: false. */
+ enabled?: boolean;
+ /** Port for the Next.js web app (default: 3100). */
+ port?: number;
+ /** Run in dev mode (`next dev`) instead of production (`next build && next start`). Default: false. */
+ dev?: boolean;
+};
+
export type GatewayConfig = {
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
port?: number;
@@ -245,4 +254,6 @@ export type GatewayConfig = {
* `x-real-ip`) to determine the client IP for local pairing and HTTP checks.
*/
trustedProxies?: string[];
+ /** Ironclaw Next.js web app served alongside the gateway. */
+ webApp?: GatewayWebAppConfig;
};
diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts
index 6947a587604..ca8e75e3d2f 100644
--- a/src/config/zod-schema.ts
+++ b/src/config/zod-schema.ts
@@ -508,6 +508,14 @@ export const OpenClawSchema = z
})
.strict()
.optional(),
+ webApp: z
+ .object({
+ enabled: z.boolean().optional(),
+ port: z.number().int().positive().optional(),
+ dev: z.boolean().optional(),
+ })
+ .strict()
+ .optional(),
})
.strict()
.optional(),
diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts
index ea0323587a9..0f1ebe29864 100644
--- a/src/gateway/server-close.ts
+++ b/src/gateway/server-close.ts
@@ -3,6 +3,7 @@ import type { WebSocketServer } from "ws";
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
import type { HeartbeatRunner } from "../infra/heartbeat-runner.js";
import type { PluginServicesHandle } from "../plugins/services.js";
+import type { WebAppHandle } from "./server-web-app.js";
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
@@ -26,6 +27,7 @@ export function createGatewayCloseHandler(params: {
clients: Set<{ socket: { close: (code: number, reason: string) => void } }>;
configReloader: { stop: () => Promise };
browserControl: { stop: () => Promise } | null;
+ webApp: WebAppHandle | null;
wss: WebSocketServer;
httpServer: HttpServer;
httpServers?: HttpServer[];
@@ -108,6 +110,9 @@ export function createGatewayCloseHandler(params: {
if (params.browserControl) {
await params.browserControl.stop().catch(() => {});
}
+ if (params.webApp) {
+ await params.webApp.stop().catch(() => {});
+ }
await new Promise((resolve) => params.wss.close(() => resolve()));
const servers =
params.httpServers && params.httpServers.length > 0
diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts
index 6fedc290f6b..1bbb374ddff 100644
--- a/src/gateway/server-runtime-config.ts
+++ b/src/gateway/server-runtime-config.ts
@@ -43,8 +43,13 @@ export async function resolveGatewayRuntimeConfig(params: {
const bindMode = params.bind ?? params.cfg.gateway?.bind ?? "loopback";
const customBindHost = params.cfg.gateway?.customBindHost;
const bindHost = params.host ?? (await resolveGatewayBindHost(bindMode, customBindHost));
+ // webApp.enabled is the primary toggle for all web UIs.
+ // controlUi.enabled is a more specific override if set explicitly.
const controlUiEnabled =
- params.controlUiEnabled ?? params.cfg.gateway?.controlUi?.enabled ?? true;
+ params.controlUiEnabled ??
+ params.cfg.gateway?.controlUi?.enabled ??
+ params.cfg.gateway?.webApp?.enabled ??
+ true;
const openAiChatCompletionsEnabled =
params.openAiChatCompletionsEnabled ??
params.cfg.gateway?.http?.endpoints?.chatCompletions?.enabled ??
diff --git a/src/gateway/server-startup.ts b/src/gateway/server-startup.ts
index 1971ef8a2d3..17e21708888 100644
--- a/src/gateway/server-startup.ts
+++ b/src/gateway/server-startup.ts
@@ -22,6 +22,7 @@ import {
scheduleRestartSentinelWake,
shouldWakeFromRestartSentinel,
} from "./server-restart-sentinel.js";
+import { type WebAppHandle, startWebAppIfEnabled } from "./server-web-app.js";
export async function startGatewaySidecars(params: {
cfg: ReturnType;
@@ -37,6 +38,11 @@ export async function startGatewaySidecars(params: {
};
logChannels: { info: (msg: string) => void; error: (msg: string) => void };
logBrowser: { error: (msg: string) => void };
+ logWebApp: {
+ info: (msg: string) => void;
+ warn: (msg: string) => void;
+ error: (msg: string) => void;
+ };
}) {
// Start OpenClaw browser control server (unless disabled via config).
let browserControl: Awaited> = null;
@@ -46,6 +52,14 @@ export async function startGatewaySidecars(params: {
params.logBrowser.error(`server failed to start: ${String(err)}`);
}
+ // Start the Ironclaw Next.js web app if enabled (gateway.webApp.enabled).
+ let webApp: WebAppHandle | null = null;
+ try {
+ webApp = await startWebAppIfEnabled(params.cfg.gateway?.webApp, params.logWebApp);
+ } catch (err) {
+ params.logWebApp.error(`web app failed to start: ${String(err)}`);
+ }
+
// Start Gmail watcher if configured (hooks.gmail.account).
if (!isTruthyEnvValue(process.env.OPENCLAW_SKIP_GMAIL_WATCHER)) {
try {
@@ -156,5 +170,5 @@ export async function startGatewaySidecars(params: {
}, 750);
}
- return { browserControl, pluginServices };
+ return { browserControl, pluginServices, webApp };
}
diff --git a/src/gateway/server-web-app.ts b/src/gateway/server-web-app.ts
new file mode 100644
index 00000000000..466bb65cfe1
--- /dev/null
+++ b/src/gateway/server-web-app.ts
@@ -0,0 +1,184 @@
+import type { ChildProcess } from "node:child_process";
+import { spawn } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import type { GatewayWebAppConfig } from "../config/types.gateway.js";
+import { isTruthyEnvValue } from "../infra/env.js";
+
+export const DEFAULT_WEB_APP_PORT = 3100;
+
+export type WebAppHandle = {
+ port: number;
+ stop: () => Promise;
+};
+
+/**
+ * Resolve the `apps/web` directory relative to the package root.
+ * Walks up from the current module until we find the workspace root
+ * (identified by the presence of `apps/web/package.json`).
+ */
+function resolveWebAppDir(): string | null {
+ const __filename = fileURLToPath(import.meta.url);
+ let dir = path.dirname(__filename);
+ for (let i = 0; i < 10; i++) {
+ const candidate = path.join(dir, "apps", "web", "package.json");
+ if (fs.existsSync(candidate)) {
+ return path.join(dir, "apps", "web");
+ }
+ const parent = path.dirname(dir);
+ if (parent === dir) {
+ break;
+ }
+ dir = parent;
+ }
+ return null;
+}
+
+/** Check whether a Next.js standalone build exists (from `output: "standalone"`). */
+function hasStandaloneBuild(webAppDir: string): boolean {
+ return fs.existsSync(path.join(webAppDir, ".next", "standalone", "server.js"));
+}
+
+/** Check whether a regular Next.js production build exists. */
+function hasNextBuild(webAppDir: string): boolean {
+ return fs.existsSync(path.join(webAppDir, ".next", "BUILD_ID"));
+}
+
+/**
+ * Start the Ironclaw Next.js web app as a child process.
+ *
+ * Resolution order:
+ * 1. Standalone build (`.next/standalone/server.js`) — shipped in the npm
+ * package; runs with plain `node`, no extra deps needed.
+ * 2. Regular build (`.next/BUILD_ID`) — runs via `npx next start`.
+ * 3. No build — builds from source, then starts.
+ */
+export async function startWebAppIfEnabled(
+ cfg: GatewayWebAppConfig | undefined,
+ log: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void },
+): Promise {
+ if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_WEB_APP)) {
+ return null;
+ }
+ if (!cfg?.enabled) {
+ return null;
+ }
+
+ const port = cfg.port ?? DEFAULT_WEB_APP_PORT;
+ const devMode = cfg.dev === true; // default false (production)
+
+ const webAppDir = resolveWebAppDir();
+ if (!webAppDir) {
+ log.warn("apps/web directory not found — skipping web app");
+ return null;
+ }
+
+ let child: ChildProcess;
+
+ if (devMode) {
+ // Dev mode: ensure deps, then `next dev`.
+ await ensureDepsInstalled(webAppDir, log);
+ log.info(`starting web app (dev) on port ${port}…`);
+ child = spawn("npx", ["next", "dev", "--port", String(port)], {
+ cwd: webAppDir,
+ stdio: "pipe",
+ env: { ...process.env, PORT: String(port) },
+ });
+ } else if (hasStandaloneBuild(webAppDir)) {
+ // Standalone build: run directly with node — no deps needed.
+ log.info("using pre-built standalone web app");
+ const serverJs = path.join(webAppDir, ".next", "standalone", "server.js");
+ child = spawn(process.execPath, [serverJs], {
+ cwd: path.join(webAppDir, ".next", "standalone"),
+ stdio: "pipe",
+ env: { ...process.env, PORT: String(port), HOSTNAME: "0.0.0.0" },
+ });
+ } else {
+ // No standalone — fall back to regular next start.
+ await ensureDepsInstalled(webAppDir, log);
+ if (!hasNextBuild(webAppDir)) {
+ log.info("building web app for production…");
+ await runCommand("npx", ["next", "build"], webAppDir);
+ } else {
+ log.info("pre-built web app found — skipping build");
+ }
+ log.info(`starting web app (production) on port ${port}…`);
+ child = spawn("npx", ["next", "start", "--port", String(port)], {
+ cwd: webAppDir,
+ stdio: "pipe",
+ env: { ...process.env, PORT: String(port) },
+ });
+ }
+
+ // Forward child stdout/stderr to the gateway log.
+ child.stdout?.on("data", (data: Buffer) => {
+ for (const line of data.toString().split("\n").filter(Boolean)) {
+ log.info(line);
+ }
+ });
+ child.stderr?.on("data", (data: Buffer) => {
+ for (const line of data.toString().split("\n").filter(Boolean)) {
+ log.warn(line);
+ }
+ });
+
+ child.on("error", (err) => {
+ log.error(`web app process error: ${String(err)}`);
+ });
+
+ child.on("exit", (code, signal) => {
+ if (code !== null && code !== 0) {
+ log.warn(`web app exited with code ${code}`);
+ } else if (signal) {
+ log.info(`web app terminated by signal ${signal}`);
+ }
+ });
+
+ log.info(`web app available at http://localhost:${port}`);
+
+ return {
+ port,
+ stop: async () => {
+ if (child.exitCode === null && !child.killed) {
+ child.kill("SIGTERM");
+ await new Promise((resolve) => {
+ const timeout = setTimeout(() => {
+ if (child.exitCode === null && !child.killed) {
+ child.kill("SIGKILL");
+ }
+ resolve();
+ }, 5_000);
+ child.on("exit", () => {
+ clearTimeout(timeout);
+ resolve();
+ });
+ });
+ }
+ },
+ };
+}
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+async function ensureDepsInstalled(
+ webAppDir: string,
+ log: { info: (msg: string) => void },
+): Promise {
+ const nodeModulesDir = path.join(webAppDir, "node_modules");
+ if (fs.existsSync(nodeModulesDir)) {
+ return;
+ }
+ log.info("installing web app dependencies…");
+ await runCommand("pnpm", ["install"], webAppDir);
+}
+
+function runCommand(cmd: string, args: string[], cwd: string): Promise {
+ return new Promise((resolve, reject) => {
+ const proc = spawn(cmd, args, { cwd, stdio: "pipe", env: { ...process.env } });
+ proc.on("close", (code) =>
+ code === 0 ? resolve() : reject(new Error(`${cmd} ${args[0]} exited with code ${code}`)),
+ );
+ proc.on("error", reject);
+ });
+}
diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts
index d46a38ef3d6..4d7107ea194 100644
--- a/src/gateway/server.impl.ts
+++ b/src/gateway/server.impl.ts
@@ -88,6 +88,7 @@ const logDiscovery = log.child("discovery");
const logTailscale = log.child("tailscale");
const logChannels = log.child("channels");
const logBrowser = log.child("browser");
+const logWebApp = log.child("webapp");
const logHealth = log.child("health");
const logCron = log.child("cron");
const logReload = log.child("reload");
@@ -217,6 +218,27 @@ export async function startGatewayServer(
}
}
+ // Ensure gateway.webApp is enabled by default for all configs.
+ // Existing configs created before the web app feature won't have this key,
+ // so we backfill it on startup and persist.
+ {
+ const currentCfg = loadConfig();
+ if (currentCfg.gateway?.webApp === undefined) {
+ try {
+ await writeConfigFile({
+ ...currentCfg,
+ gateway: {
+ ...currentCfg.gateway,
+ webApp: { enabled: true },
+ },
+ });
+ log.info("gateway: auto-enabled webApp (gateway.webApp.enabled = true)");
+ } catch (err) {
+ log.warn(`gateway: failed to persist webApp auto-enable: ${String(err)}`);
+ }
+ }
+ }
+
const cfgAtStart = loadConfig();
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
if (diagnosticsEnabled) {
@@ -546,7 +568,8 @@ export async function startGatewayServer(
});
let browserControl: Awaited> = null;
- ({ browserControl, pluginServices } = await startGatewaySidecars({
+ let webApp: Awaited>["webApp"] = null;
+ ({ browserControl, pluginServices, webApp } = await startGatewaySidecars({
cfg: cfgAtStart,
pluginRegistry,
defaultWorkspaceDir,
@@ -556,6 +579,7 @@ export async function startGatewayServer(
logHooks,
logChannels,
logBrowser,
+ logWebApp,
}));
const { applyHotReload, requestGatewayRestart } = createGatewayReloadHandlers({
@@ -617,6 +641,7 @@ export async function startGatewayServer(
clients,
configReloader,
browserControl,
+ webApp,
wss,
httpServer,
httpServers,
diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts
index 54be9a6a208..1287e45f22c 100644
--- a/src/wizard/onboarding.finalize.ts
+++ b/src/wizard/onboarding.finalize.ts
@@ -33,6 +33,7 @@ import {
} from "../commands/onboard-helpers.js";
import { resolveGatewayService } from "../daemon/service.js";
import { isSystemdUserServiceAvailable } from "../daemon/systemd.js";
+import { DEFAULT_WEB_APP_PORT } from "../gateway/server-web-app.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import { restoreTerminalState } from "../terminal/restore.js";
import { runTui } from "../tui/tui.js";
@@ -345,34 +346,23 @@ export async function finalizeOnboardingWizard(
});
launchedTui = true;
} else if (hatchChoice === "web") {
+ const webAppPort = nextConfig.gateway?.webApp?.port ?? DEFAULT_WEB_APP_PORT;
+ const webAppUrl = `http://localhost:${webAppPort}`;
const browserSupport = await detectBrowserOpenSupport();
if (browserSupport.ok) {
- controlUiOpened = await openUrl(authedUrl);
- if (!controlUiOpened) {
- controlUiOpenHint = formatControlUiSshHint({
- port: settings.port,
- basePath: controlUiBasePath,
- token: settings.authMode === "token" ? settings.gatewayToken : undefined,
- });
- }
- } else {
- controlUiOpenHint = formatControlUiSshHint({
- port: settings.port,
- basePath: controlUiBasePath,
- token: settings.authMode === "token" ? settings.gatewayToken : undefined,
- });
+ controlUiOpened = await openUrl(webAppUrl);
}
await prompter.note(
[
- `Dashboard link (with token): ${authedUrl}`,
+ `Ironclaw Web UI: ${webAppUrl}`,
controlUiOpened
- ? "Opened in your browser. Keep that tab to control Ironclaw."
- : "Copy/paste this URL in a browser on this machine to control Ironclaw.",
- controlUiOpenHint,
+ ? "Opened in your browser."
+ : "Copy/paste this URL in a browser on this machine.",
+ `Dashboard (control UI): ${authedUrl}`,
]
.filter(Boolean)
.join("\n"),
- "Dashboard ready",
+ "Web UI ready",
);
} else {
await prompter.note(
@@ -515,10 +505,10 @@ export async function finalizeOnboardingWizard(
await prompter.outro(
controlUiOpened
- ? "Onboarding complete. Dashboard opened; keep that tab to control Ironclaw."
+ ? "Onboarding complete. Web UI opened; keep that tab to control Ironclaw."
: seededInBackground
- ? "Onboarding complete. Web UI seeded in the background; open it anytime with the dashboard link above."
- : "Onboarding complete. Use the dashboard link above to control Ironclaw.",
+ ? "Onboarding complete. Web UI seeded in the background; open it anytime with the link above."
+ : "Onboarding complete. Use the links above to control Ironclaw.",
);
return { launchedTui };
diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts
index aef746a72d1..0f0e8570cc0 100644
--- a/src/wizard/onboarding.gateway-config.ts
+++ b/src/wizard/onboarding.gateway-config.ts
@@ -247,6 +247,7 @@ export async function configureGatewayForOnboarding(
mode: tailscaleMode as GatewayTailscaleMode,
resetOnExit: tailscaleResetOnExit,
},
+ webApp: { enabled: true },
},
};