diff --git a/src/app/HomeClient.tsx b/src/app/HomeClient.tsx
index 010e99b..8a4cb89 100644
--- a/src/app/HomeClient.tsx
+++ b/src/app/HomeClient.tsx
@@ -88,7 +88,47 @@ export default function HomeClient({
Installed agents grouped by team workspace
- {appVersion}-beta
+
+
+ Create team
+
+
+ Recipes
+
+
+ Tickets
+
+
+
+ Kitchen Sink
+
+
+ Channels / Bindings
+
+
+ Settings
+
+
+ {appVersion}-beta
+
diff --git a/src/app/overview/page.tsx b/src/app/overview/page.tsx
new file mode 100644
index 0000000..6686ee4
--- /dev/null
+++ b/src/app/overview/page.tsx
@@ -0,0 +1,327 @@
+import Link from "next/link";
+import { unstable_noStore as noStore } from "next/cache";
+
+import { execFileAsync } from "@/lib/exec";
+import { inferTeamIdFromWorkspace, getActiveSessions, getAgents } from "@/lib/overview/overview-data";
+import { runOpenClaw } from "@/lib/openclaw";
+import { listRecipes } from "@/lib/recipes";
+
+export const dynamic = "force-dynamic";
+export const revalidate = 0;
+
+async function getTeamsFromRecipes(): Promise<{ teamNames: Record
}> {
+ const items = await listRecipes();
+ const teamNames: Record = {};
+ for (const r of items) {
+ if (r.kind !== "team") continue;
+ const name = String(r.name ?? "").trim();
+ if (!name) continue;
+ teamNames[r.id] = name;
+ }
+ return { teamNames };
+}
+
+type GatewayStatusSummary = {
+ ok: boolean;
+ statusLabel: string;
+ configOk: boolean;
+ rpcOk: boolean;
+ portStatus?: string;
+};
+
+
+type GatewayStatusJson = {
+ service?: {
+ runtime?: { status?: string; state?: string };
+ configAudit?: { ok?: boolean };
+ };
+ rpc?: { ok?: boolean };
+ port?: { status?: string };
+};
+
+async function getGatewayStatusSummary(): Promise {
+ const res = await runOpenClaw(["gateway", "status", "--json"]);
+ if (!res.ok) {
+ return {
+ ok: false,
+ statusLabel: "unknown",
+ configOk: false,
+ rpcOk: false,
+ };
+ }
+
+ const parsed = JSON.parse(res.stdout) as GatewayStatusJson;
+ const runtimeStatus = String(parsed?.service?.runtime?.status ?? "unknown");
+ const runtimeState = String(parsed?.service?.runtime?.state ?? "unknown");
+ const configOk = Boolean(parsed?.service?.configAudit?.ok);
+ const rpcOk = Boolean(parsed?.rpc?.ok);
+ const portStatus = String(parsed?.port?.status ?? "unknown");
+
+ const running = runtimeStatus === "running" && runtimeState === "active";
+ const ok = running && configOk && rpcOk;
+
+ return {
+ ok,
+ statusLabel: running ? "running" : runtimeStatus,
+ configOk,
+ rpcOk,
+ portStatus,
+ };
+}
+
+let cachedGatewayErrCount: { ts: number; value: number | null } = { ts: 0, value: null };
+
+async function getGatewayErrorsLast24h(): Promise {
+ // journalctl can be expensive; cache for 60s per server instance.
+ const now = Date.now();
+ if (now - cachedGatewayErrCount.ts < 60_000) return cachedGatewayErrCount.value;
+
+ try {
+ const { stdout } = await execFileAsync(
+ "bash",
+ [
+ "-lc",
+ "journalctl --user -u openclaw-gateway --since '24 hours ago' -p err --no-pager 2>/dev/null | wc -l",
+ ],
+ { encoding: "utf8", timeout: 5_000 },
+ );
+ const n = Number(String(stdout).trim());
+ const value = Number.isFinite(n) ? n : null;
+ cachedGatewayErrCount = { ts: now, value };
+ return value;
+ } catch {
+ cachedGatewayErrCount = { ts: now, value: null };
+ return null;
+ }
+}
+
+function formatAgeMs(ms: number | undefined) {
+ if (ms == null) return "";
+ const s = Math.round(ms / 1000);
+ if (s < 60) return `${s}s`;
+ const m = Math.round(s / 60);
+ if (m < 60) return `${m}m`;
+ const h = Math.round(m / 60);
+ return `${h}h`;
+}
+
+function displayNameForTeam(teamId: string, teamNames: Record) {
+ const fallback = teamId.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
+ return teamNames[teamId] || fallback || teamId;
+}
+
+export default async function OverviewPage({
+ searchParams,
+}: {
+ searchParams?: Promise>;
+}) {
+ noStore();
+
+ const sp = (await searchParams) ?? {};
+ const teamRaw = sp.team;
+ const team = (Array.isArray(teamRaw) ? teamRaw[0] : teamRaw) || "";
+ const teamFilter = team.trim();
+
+ const [agents, sessions60, sessions5, { teamNames }, gateway, gatewayErr24h] = await Promise.all([
+ getAgents(),
+ getActiveSessions(60),
+ getActiveSessions(5),
+ getTeamsFromRecipes(),
+ getGatewayStatusSummary(),
+ getGatewayErrorsLast24h(),
+ ]);
+
+ const agentsById = new Map(agents.map((a) => [a.id, a] as const));
+
+ const sessionsFiltered = sessions60.filter((s) => {
+ if (!teamFilter) return true;
+ const a = agentsById.get(s.agentId);
+ const t = inferTeamIdFromWorkspace(a?.workspace ?? null);
+ return t === teamFilter;
+ });
+
+ const sessions5Filtered = sessions5.filter((s) => {
+ if (!teamFilter) return true;
+ const a = agentsById.get(s.agentId);
+ const t = inferTeamIdFromWorkspace(a?.workspace ?? null);
+ return t === teamFilter;
+ });
+
+ const installedAgents = teamFilter
+ ? agents.filter((a) => inferTeamIdFromWorkspace(a.workspace ?? null) === teamFilter)
+ : agents;
+
+ const activeSessions = sessionsFiltered.length;
+ const tasksRunning = sessions5Filtered.length;
+
+ const tasksHref = teamFilter
+ ? `/overview/tasks?team=${encodeURIComponent(teamFilter)}`
+ : `/overview/tasks`;
+
+ const kpi: Array<{ label: string; value: string; note: string; href?: string }> = [
+ {
+ label: "Active Sessions (last 60m)",
+ value: String(activeSessions),
+ note: teamFilter ? `team=${teamFilter}` : "all teams",
+ },
+ {
+ label: "Agents (installed)",
+ value: String(installedAgents.length),
+ note: teamFilter ? displayNameForTeam(teamFilter, teamNames) : "all teams",
+ },
+ {
+ label: "Tasks Running",
+ value: String(tasksRunning),
+ note: "active sessions (last 5m)",
+ href: tasksHref,
+ },
+ {
+ label: "Errors (24h)",
+ value: gatewayErr24h == null ? "—" : String(gatewayErr24h),
+ note: "gateway journalctl -p err",
+ },
+ ];
+
+ const sessionsPreview = sessionsFiltered.slice().sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 8);
+
+ return (
+
+
+
+
Kitchen Sink
+
+ A live overview of your OpenClaw system. Data is real when available; otherwise we show explicit unknowns.
+
+
+ Tip: filter by team via ?team=<teamId>.
+
+
+
+
+
+ {teamFilter ? "Team →" : "Home →"}
+
+
+
+
+
+ {kpi.map((t) => (
+
+
{t.label}
+
+ {t.href ? (
+
+ {t.value}
+
+ ) : (
+ t.value
+ )}
+
+
{t.note}
+
+ ))}
+
+
+
+
+ System Health
+
+
+
Gateway
+
+ {gateway.statusLabel}
+ {!gateway.ok ? (
+ (degraded)
+ ) : null}
+
+
+
+
RPC
+
{gateway.rpcOk ? "ok" : "not ok"}
+
+
+
Config audit
+
+ {gateway.configOk ? "ok" : "issues"}
+
+
+
+
Port
+
{gateway.portStatus ?? "—"}
+
+
+
+ Source: openclaw gateway status --json
+
+
+
+
+ Security & Audit
+ No audit feed wired yet.
+ Empty state (intentional): no fake data.
+
+
+
+ Backup & Pipelines
+ No backup pipeline status wired yet.
+ Empty state (intentional): no fake data.
+
+
+
+
+
+
Recent Sessions
+
+ Showing last 60 minutes{teamFilter ? ` for team ${teamFilter}` : ""}.
+
+
+
+
+ {sessionsPreview.length ? (
+
+
+
+
+ | Agent |
+ Model |
+ Age |
+ Total tokens |
+ Context |
+
+
+
+ {sessionsPreview.map((s) => (
+
+ | {s.agentId} |
+ {s.model || "—"} |
+ {formatAgeMs(s.ageMs)} |
+ {s.totalTokens ?? "—"} |
+ {s.contextTokens ?? "—"} |
+
+ ))}
+
+
+
+ ) : (
+ No recent sessions.
+ )}
+
+
+
+ Recent Logs
+
+ Not wired yet. This will likely surface the last N lines of gateway + worker logs with filters.
+
+ Empty state (intentional): no fake data.
+
+
+
+ );
+}
diff --git a/src/app/overview/tasks/page.tsx b/src/app/overview/tasks/page.tsx
new file mode 100644
index 0000000..8893eb4
--- /dev/null
+++ b/src/app/overview/tasks/page.tsx
@@ -0,0 +1,125 @@
+import Link from "next/link";
+import { unstable_noStore as noStore } from "next/cache";
+
+import { getActiveSessions, getAgents, inferTeamIdFromWorkspace } from "@/lib/overview/overview-data";
+
+export const dynamic = "force-dynamic";
+export const revalidate = 0;
+
+function fmtIso(tsMs: number | null) {
+ if (!tsMs) return "—";
+ try {
+ return new Date(tsMs).toISOString().replace(".000Z", "Z");
+ } catch {
+ return "—";
+ }
+}
+
+export default async function TasksRunningPage({
+ searchParams,
+}: {
+ searchParams?: Promise>;
+}) {
+ noStore();
+
+ const sp = (await searchParams) ?? {};
+ const teamRaw = sp.team;
+ const team = (Array.isArray(teamRaw) ? teamRaw[0] : teamRaw) || "";
+ const teamFilter = team.trim();
+
+ const [agents, sessions5] = await Promise.all([getAgents(), getActiveSessions(5)]);
+ const agentsById = new Map(agents.map((a) => [a.id, a] as const));
+
+ const sessionsFiltered = sessions5.filter((s) => {
+ if (!teamFilter) return true;
+ const a = agentsById.get(s.agentId);
+ const t = inferTeamIdFromWorkspace(a?.workspace ?? null);
+ return t === teamFilter;
+ });
+
+ const rows = sessionsFiltered
+ .slice()
+ .sort((a, b) => b.updatedAt - a.updatedAt)
+ .map((s) => {
+ const a = agentsById.get(s.agentId);
+ const teamId = inferTeamIdFromWorkspace(a?.workspace ?? null);
+ const startedAt = s.ageMs != null ? s.updatedAt - s.ageMs : null;
+ const lastActivity = s.updatedAt;
+ return {
+ key: s.key,
+ agentId: s.agentId,
+ teamId: teamId ?? "—",
+ model: s.model ?? "—",
+ startedAt,
+ lastActivity,
+ };
+ });
+
+ const backHref = teamFilter ? `/overview?team=${encodeURIComponent(teamFilter)}` : `/overview`;
+
+ return (
+
+
+
+
Tasks Running
+
+ This is a best-effort view: we treat “tasks” as OpenClaw sessions active in the last 5 minutes.
+
+
+ Filter: {teamFilter ? team={teamFilter} : all teams}
+
+
+
+
+
+ Overview →
+
+
+
+
+
+
+ Active sessions (last 5m) — {rows.length}
+
+
+ {rows.length ? (
+
+
+
+
+ | sessionKey |
+ agentId |
+ teamId |
+ model |
+ start |
+ last activity |
+
+
+
+ {rows.map((r) => (
+
+ | {r.key} |
+ {r.agentId} |
+ {r.teamId} |
+ {r.model} |
+ {fmtIso(r.startedAt)} |
+ {fmtIso(r.lastActivity)} |
+
+ ))}
+
+
+
+ ) : (
+ No active sessions in the last 5 minutes.
+ )}
+
+
+ Source: openclaw sessions --active 5 --all-agents --json
+
+
+
+ );
+}
diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx
index aed09fa..b0d8d5c 100644
--- a/src/components/AppShell.tsx
+++ b/src/components/AppShell.tsx
@@ -202,6 +202,21 @@ export function AppShell({ children }: { children: React.ReactNode }) {
}, []);
const globalNav = [
+ {
+ href: navHref(`/overview`),
+ label: "Kitchen Sink",
+ icon: (
+
+
+
+ ),
+ },
{
href: `/`,
label: "Agents",
diff --git a/src/lib/openclaw.ts b/src/lib/openclaw.ts
index b9f9ef8..2aaabf5 100644
--- a/src/lib/openclaw.ts
+++ b/src/lib/openclaw.ts
@@ -178,7 +178,15 @@ export async function runOpenClaw(args: string[]): Promise {
return runOpenClawLocal(args);
}
- const api = getKitchenApi();
+ let api: ReturnType;
+ try {
+ api = getKitchenApi();
+ } catch {
+ // When Kitchen is started outside the gateway runtime (e.g. standalone Next.js server),
+ // the plugin API isn't injected. Fall back to local `openclaw` exec so pages can render.
+ return runOpenClawLocal(args);
+ }
+
try {
const res = (await api.runtime.system.runCommandWithTimeout(["openclaw", ...args], { timeoutMs: 120000 })) as {
stdout?: unknown;
diff --git a/src/lib/overview/overview-data.ts b/src/lib/overview/overview-data.ts
new file mode 100644
index 0000000..46240ac
--- /dev/null
+++ b/src/lib/overview/overview-data.ts
@@ -0,0 +1,42 @@
+import { runOpenClaw } from "@/lib/openclaw";
+
+export type AgentListItem = {
+ id: string;
+ identityName?: string | null;
+ workspace?: string | null;
+};
+
+export type SessionListItem = {
+ key: string;
+ updatedAt: number;
+ ageMs?: number;
+ agentId: string;
+ kind?: string;
+ model?: string;
+ contextTokens?: number;
+ inputTokens?: number;
+ outputTokens?: number;
+ totalTokens?: number;
+};
+
+export function inferTeamIdFromWorkspace(workspace: string | null | undefined) {
+ if (!workspace) return null;
+ const parts = workspace.split("/").filter(Boolean);
+ const wsPart = parts.find((p) => p.startsWith("workspace-")) ?? "";
+ if (!wsPart) return null;
+ const team = wsPart.slice("workspace-".length);
+ return team || null;
+}
+
+export async function getAgents(): Promise {
+ const res = await runOpenClaw(["agents", "list", "--json"]);
+ if (!res.ok) return [];
+ return JSON.parse(res.stdout) as AgentListItem[];
+}
+
+export async function getActiveSessions(minutes: number): Promise {
+ const res = await runOpenClaw(["sessions", "--active", String(minutes), "--all-agents", "--json"]);
+ if (!res.ok) return [];
+ const parsed = JSON.parse(res.stdout) as { sessions?: SessionListItem[] };
+ return Array.isArray(parsed.sessions) ? parsed.sessions : [];
+}
diff --git a/src/lib/tickets.ts b/src/lib/tickets.ts
index 3370ae9..5e21d20 100644
--- a/src/lib/tickets.ts
+++ b/src/lib/tickets.ts
@@ -38,11 +38,18 @@ export function teamWorkspace(teamId: string) {
*/
export function getTeamWorkspaceDir(teamId?: string): string {
if (teamId) return teamWorkspace(teamId);
- const envTeam = process.env.CK_TEAM_ID;
- if (!envTeam && !process.env.CK_TEAM_WORKSPACE_DIR) {
+
+ // Prefer explicit env vars when running outside a team-scoped UI context.
+ // CK_TEAM_WORKSPACE_DIR wins (it fully specifies the workspace path).
+ const envDir = process.env.CK_TEAM_WORKSPACE_DIR?.trim();
+ if (envDir) return envDir;
+
+ const envTeam = process.env.CK_TEAM_ID?.trim();
+ if (!envTeam) {
throw new Error("No team specified. Pass a teamId or set CK_TEAM_ID / CK_TEAM_WORKSPACE_DIR.");
}
- return process.env.CK_TEAM_WORKSPACE_DIR ?? teamWorkspace(envTeam!);
+
+ return teamWorkspace(envTeam);
}
export function stageDir(stage: TicketStage, teamIdOrDir: string) {