diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md
new file mode 100644
index 0000000..a95b3fd
--- /dev/null
+++ b/packages/dashboard/README.md
@@ -0,0 +1,24 @@
+# @uncaged/workflow-dashboard
+
+Web dashboard for the Uncaged Workflow engine. Connects to the local
+`uncaged-workflow serve` API to display threads, workflows, and CAS data.
+
+## Development
+
+```bash
+# Start the local API server (in another terminal)
+uncaged-workflow serve
+
+# Start the dashboard dev server
+bun run dev
+```
+
+Opens at http://localhost:5173. Vite proxies `/api/*` to `localhost:7860`.
+
+## Build
+
+```bash
+bun run build
+```
+
+Output goes to `dist/` — static files ready for CF Pages or any host.
diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html
new file mode 100644
index 0000000..6621a30
--- /dev/null
+++ b/packages/dashboard/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Workflow Dashboard
+
+
+
+
+
+
diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json
new file mode 100644
index 0000000..d0bdfc7
--- /dev/null
+++ b/packages/dashboard/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@uncaged/workflow-dashboard",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^19.2.6",
+ "react-dom": "^19.2.6"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.2.4",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "tailwindcss": "^4.2.4",
+ "typescript": "^6.0.3",
+ "vite": "^8.0.11"
+ }
+}
diff --git a/packages/dashboard/src/api.ts b/packages/dashboard/src/api.ts
new file mode 100644
index 0000000..c7ef011
--- /dev/null
+++ b/packages/dashboard/src/api.ts
@@ -0,0 +1,51 @@
+const BASE = "/api";
+
+async function fetchJson(path: string): Promise {
+ const res = await fetch(`${BASE}${path}`);
+ if (!res.ok) {
+ throw new Error(`API ${res.status}: ${path}`);
+ }
+ return res.json() as Promise;
+}
+
+export type WorkflowSummary = {
+ name: string;
+ currentHash: string;
+ versions: number;
+};
+
+export type ThreadSummary = {
+ threadId: string;
+ workflow: string | null;
+ hash: string | null;
+ startedAt: string | null;
+ status: string | null;
+};
+
+export type ThreadRecord = {
+ type: string;
+ role: string | null;
+ content: string | null;
+ timestamp: number | null;
+ [key: string]: unknown;
+};
+
+export function listWorkflows(): Promise<{ workflows: WorkflowSummary[] }> {
+ return fetchJson("/workflows");
+}
+
+export function listThreads(): Promise<{ threads: ThreadSummary[] }> {
+ return fetchJson("/threads");
+}
+
+export function listRunningThreads(): Promise<{ threads: ThreadSummary[] }> {
+ return fetchJson("/threads/running");
+}
+
+export function getThread(id: string): Promise<{ records: ThreadRecord[] }> {
+ return fetchJson(`/threads/${id}`);
+}
+
+export function getHealth(): Promise<{ ok: boolean }> {
+ return fetchJson("/healthz");
+}
diff --git a/packages/dashboard/src/app.tsx b/packages/dashboard/src/app.tsx
new file mode 100644
index 0000000..7173761
--- /dev/null
+++ b/packages/dashboard/src/app.tsx
@@ -0,0 +1,31 @@
+import { useState } from "react";
+import { Sidebar } from "./components/sidebar.tsx";
+import { ThreadList } from "./components/thread-list.tsx";
+import { ThreadDetail } from "./components/thread-detail.tsx";
+import { WorkflowList } from "./components/workflow-list.tsx";
+import { StatusBar } from "./components/status-bar.tsx";
+
+type View = "threads" | "workflows";
+
+export function App() {
+ const [view, setView] = useState("threads");
+ const [selectedThread, setSelectedThread] = useState(null);
+
+ return (
+
+
setSelectedThread(null)} />
+
+
+
+ {view === "threads" && !selectedThread && (
+
+ )}
+ {view === "threads" && selectedThread && (
+ setSelectedThread(null)} />
+ )}
+ {view === "workflows" && }
+
+
+
+ );
+}
diff --git a/packages/dashboard/src/components/sidebar.tsx b/packages/dashboard/src/components/sidebar.tsx
new file mode 100644
index 0000000..ceb8e90
--- /dev/null
+++ b/packages/dashboard/src/components/sidebar.tsx
@@ -0,0 +1,38 @@
+type Props = {
+ view: "threads" | "workflows";
+ onViewChange: (v: "threads" | "workflows") => void;
+ onBack: () => void;
+};
+
+export function Sidebar({ view, onViewChange }: Props) {
+ const items = [
+ { key: "threads" as const, label: "Threads", icon: "⚡" },
+ { key: "workflows" as const, label: "Workflows", icon: "📦" },
+ ];
+
+ return (
+
+
+
+ ⚙ Workflow
+
+
Dashboard
+
+
+ {items.map((item) => (
+ onViewChange(item.key)}
+ className="w-full text-left px-3 py-2 rounded text-sm transition-colors"
+ style={{
+ background: view === item.key ? "var(--color-accent-dim)" : "transparent",
+ color: view === item.key ? "#fff" : "var(--color-text-muted)",
+ }}
+ >
+ {item.icon} {item.label}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/dashboard/src/components/status-bar.tsx b/packages/dashboard/src/components/status-bar.tsx
new file mode 100644
index 0000000..9ae3cc6
--- /dev/null
+++ b/packages/dashboard/src/components/status-bar.tsx
@@ -0,0 +1,24 @@
+import { getHealth } from "../api.ts";
+import { useFetch } from "../hooks.ts";
+
+export function StatusBar() {
+ const health = useFetch(() => getHealth(), []);
+
+ return (
+
+ Local API: 127.0.0.1:7860
+
+ {health.status === "loading" && "⏳ Connecting..."}
+ {health.status === "ok" && (
+ ● Connected
+ )}
+ {health.status === "error" && (
+ ● Offline
+ )}
+
+
+ );
+}
diff --git a/packages/dashboard/src/components/thread-detail.tsx b/packages/dashboard/src/components/thread-detail.tsx
new file mode 100644
index 0000000..c9c76d9
--- /dev/null
+++ b/packages/dashboard/src/components/thread-detail.tsx
@@ -0,0 +1,62 @@
+import { getThread } from "../api.ts";
+import { useFetch } from "../hooks.ts";
+
+type Props = {
+ threadId: string;
+ onBack: () => void;
+};
+
+export function ThreadDetail({ threadId, onBack }: Props) {
+ const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
+
+ return (
+
+
+ ← Back to threads
+
+
{threadId}
+
+ {status === "loading" &&
Loading...
}
+ {status === "error" &&
Error: {error}
}
+ {status === "ok" && (
+
+ {data.records.map((r, i) => (
+
+
+
+ {r.type}
+
+ {r.role && (
+
+ {r.role}
+
+ )}
+ {r.timestamp && (
+
+ {new Date(r.timestamp).toLocaleTimeString()}
+
+ )}
+
+ {r.content && (
+
+ {typeof r.content === "string" ? r.content : JSON.stringify(r.content, null, 2)}
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/packages/dashboard/src/components/thread-list.tsx b/packages/dashboard/src/components/thread-list.tsx
new file mode 100644
index 0000000..1e71cb7
--- /dev/null
+++ b/packages/dashboard/src/components/thread-list.tsx
@@ -0,0 +1,67 @@
+import { listThreads } from "../api.ts";
+import { useFetch } from "../hooks.ts";
+
+type Props = {
+ onSelect: (id: string) => void;
+};
+
+export function ThreadList({ onSelect }: Props) {
+ const { status, data, error } = useFetch(() => listThreads(), []);
+
+ if (status === "loading") return Loading threads...
;
+ if (status === "error") return Error: {error}
;
+
+ const threads = data.threads;
+
+ return (
+
+
Threads
+ {threads.length === 0 ? (
+
No threads found.
+ ) : (
+
+ {threads.map((t) => (
+
onSelect(t.threadId)}
+ className="w-full text-left p-4 rounded-lg border transition-colors hover:border-[var(--color-accent-dim)]"
+ style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
+ >
+
+
+ {t.threadId}
+
+ {t.status && (
+
+ {t.status}
+
+ )}
+
+ {t.workflow && (
+
+ {t.workflow}
+
+ )}
+ {t.startedAt && (
+
+ {t.startedAt}
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/packages/dashboard/src/components/workflow-list.tsx b/packages/dashboard/src/components/workflow-list.tsx
new file mode 100644
index 0000000..8023db7
--- /dev/null
+++ b/packages/dashboard/src/components/workflow-list.tsx
@@ -0,0 +1,40 @@
+import { listWorkflows } from "../api.ts";
+import { useFetch } from "../hooks.ts";
+
+export function WorkflowList() {
+ const { status, data, error } = useFetch(() => listWorkflows(), []);
+
+ if (status === "loading") return Loading workflows...
;
+ if (status === "error") return Error: {error}
;
+
+ const workflows = data.workflows;
+
+ return (
+
+
Workflows
+ {workflows.length === 0 ? (
+
No workflows registered.
+ ) : (
+
+ {workflows.map((w) => (
+
+
+ {w.name}
+
+ {w.versions} version{w.versions !== 1 ? "s" : ""}
+
+
+
+ {w.currentHash}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/packages/dashboard/src/hooks.ts b/packages/dashboard/src/hooks.ts
new file mode 100644
index 0000000..ebddd2a
--- /dev/null
+++ b/packages/dashboard/src/hooks.ts
@@ -0,0 +1,36 @@
+import { useEffect, useState } from "react";
+
+type FetchState =
+ | { status: "loading"; data: null; error: null }
+ | { status: "ok"; data: T; error: null }
+ | { status: "error"; data: null; error: string };
+
+export function useFetch(fetcher: () => Promise, deps: unknown[] = []): FetchState {
+ const [state, setState] = useState>({
+ status: "loading",
+ data: null,
+ error: null,
+ });
+
+ useEffect(() => {
+ let cancelled = false;
+ setState({ status: "loading", data: null, error: null });
+ fetcher()
+ .then((data) => {
+ if (!cancelled) setState({ status: "ok", data, error: null });
+ })
+ .catch((err: unknown) => {
+ if (!cancelled)
+ setState({
+ status: "error",
+ data: null,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, deps);
+
+ return state;
+}
diff --git a/packages/dashboard/src/index.css b/packages/dashboard/src/index.css
new file mode 100644
index 0000000..537e9ea
--- /dev/null
+++ b/packages/dashboard/src/index.css
@@ -0,0 +1,21 @@
+@import "tailwindcss";
+
+:root {
+ --color-bg: #0a0a0f;
+ --color-surface: #12121a;
+ --color-border: #1e1e2e;
+ --color-text: #e4e4ef;
+ --color-text-muted: #6b6b8a;
+ --color-accent: #7c6df0;
+ --color-accent-dim: #5a4db8;
+ --color-success: #34d399;
+ --color-warning: #fbbf24;
+ --color-error: #f87171;
+}
+
+body {
+ margin: 0;
+ background: var(--color-bg);
+ color: var(--color-text);
+ font-family: "Inter", system-ui, -apple-system, sans-serif;
+}
diff --git a/packages/dashboard/src/main.tsx b/packages/dashboard/src/main.tsx
new file mode 100644
index 0000000..aeff74c
--- /dev/null
+++ b/packages/dashboard/src/main.tsx
@@ -0,0 +1,13 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./index.css";
+import { App } from "./app.tsx";
+
+const root = document.getElementById("root");
+if (root) {
+ createRoot(root).render(
+
+
+ ,
+ );
+}
diff --git a/packages/dashboard/tsconfig.json b/packages/dashboard/tsconfig.json
new file mode 100644
index 0000000..6790d00
--- /dev/null
+++ b/packages/dashboard/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "jsx": "react-jsx",
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true
+ },
+ "include": ["src"]
+}
diff --git a/packages/dashboard/vite.config.ts b/packages/dashboard/vite.config.ts
new file mode 100644
index 0000000..68ca093
--- /dev/null
+++ b/packages/dashboard/vite.config.ts
@@ -0,0 +1,16 @@
+import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ server: {
+ port: 5173,
+ proxy: {
+ "/api": {
+ target: "http://127.0.0.1:7860",
+ changeOrigin: true,
+ },
+ },
+ },
+});