diff --git a/.changeset/ai-dashboard.md b/.changeset/ai-dashboard.md
new file mode 100644
index 0000000000..430efa7670
--- /dev/null
+++ b/.changeset/ai-dashboard.md
@@ -0,0 +1,12 @@
+---
+'@tanstack/ai-dashboard': minor
+---
+
+New package `@tanstack/ai-dashboard`: a self-hosted dashboard for harness sessions.
+
+- `npx @tanstack/ai-dashboard` starts it (`startDashboard` in code).
+- Agents dial out with `connectDashboard` from `@tanstack/ai-dashboard/connect`, pair with a one-time code, and get a revocable host token.
+- The web app lists hosts and sessions, streams messages and tool calls, shows approval cards and plugin questions, and sends prompts, steers, and stops. It installs as a web app on a phone.
+- Inputs for an offline host wait at the dashboard and reach it on reconnect.
+- After a dashboard restart, a host with `onPairingCode` pairs again and gets a new token through `onToken`. Without it, `onError` gets the error.
+- A host that sets `allowRemoteStart` gets an "Open a thread" form in its row.
diff --git a/docs/config.json b/docs/config.json
index e8b8d260a4..9a392b37a6 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -1068,6 +1068,11 @@
"label": "Use a harness from an editor (ACP)",
"to": "harness/acp",
"addedAt": "2026-10-13"
+ },
+ {
+ "label": "Self-host the dashboard",
+ "to": "harness/dashboard",
+ "addedAt": "2026-10-13"
}
]
},
diff --git a/docs/harness/dashboard.md b/docs/harness/dashboard.md
new file mode 100644
index 0000000000..371e6d5ab1
--- /dev/null
+++ b/docs/harness/dashboard.md
@@ -0,0 +1,149 @@
+---
+title: Self-host the dashboard
+id: harness-dashboard
+order: 16
+description: "Watch and steer your harness sessions from a browser or a phone. Agents dial out to your dashboard server, so they need no open port."
+keywords:
+ - tanstack ai
+ - harness
+ - dashboard
+ - mobile
+ - relay
+ - pairing
+ - allowRemoteStart
+---
+
+Your agent runs on a laptop, a server, or a CI runner, and it stops to ask for approval while you are away from that terminal. The dashboard shows every connected session in a browser or on your phone. You approve tools, answer questions, and send prompts from there. Agents connect out to the dashboard, so they need no open port.
+
+## 1. Start the dashboard
+
+```bash
+npx @tanstack/ai-dashboard --port 8790
+```
+
+It prints a sign-in link that holds the owner token. Set `DASHBOARD_OWNER_TOKEN` to keep the same token across restarts. Put the dashboard behind TLS when it is reachable from other machines.
+
+## 2. Connect an agent
+
+Install the package next to your harness:
+
+
+
+react: @tanstack/ai-dashboard
+vue: @tanstack/ai-dashboard
+solid: @tanstack/ai-dashboard
+svelte: @tanstack/ai-dashboard
+preact: @tanstack/ai-dashboard
+angular: @tanstack/ai-dashboard
+octane: @tanstack/ai-dashboard
+vanilla: @tanstack/ai-dashboard
+
+
+
+Then call `connectDashboard` in your host:
+
+```ts group=harness-dashboard
+import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
+import { connectDashboard } from '@tanstack/ai-dashboard/connect'
+import { openaiText } from '@tanstack/ai-openai'
+
+const studio = defineHarness({ name: 'acme/studio', adapter: openaiText('gpt-6.1-sol') })
+const host = createHarnessHost()
+
+const connection = await connectDashboard({
+ host,
+ harness: studio,
+ url: 'http://127.0.0.1:8790',
+ threads: ['main'],
+ onPairingCode: (code) => console.log(`Approve ${code} in the dashboard`),
+ onToken: (token) => console.log(`Save this host token: ${token}`),
+})
+```
+
+## 3. Pair it
+
+The first time, the agent prints a pairing code. Approve the code in the dashboard under "Pairing requests". The agent gets a host token. Pass it as `token` to skip pairing next time. Revoke a host in the dashboard to remove its access.
+
+A saved token works while the same dashboard process runs. The dashboard keeps tokens in memory, so a restart makes them invalid. See [When the dashboard restarts](#when-the-dashboard-restarts).
+
+## What you can do in the dashboard
+
+- See hosts (online or offline) and sessions (running, waiting for you, idle).
+- Read the messages and tool calls of a session as they stream.
+- Approve or reject tool calls, and answer questions from plugins.
+- Send a prompt, steer a running turn, or stop it.
+- Open a thread on a host by its id. See [Open a thread](#open-a-thread).
+
+Inputs for an offline host wait at the dashboard for 10 minutes and reach the host when it reconnects.
+
+On a phone, open the dashboard and add it to the home screen. It works as an installed web app.
+
+To show a session list and a live status in your own app, use the session index of the host. See [List, rename, and fork sessions](./sessions).
+
+## Open a thread
+
+The dashboard shows the threads in `threads`. To watch another thread, or to start a new one from your phone, open it by its id.
+
+Set `allowRemoteStart: true` in `connectDashboard`:
+
+```ts group=harness-dashboard
+const remote = await connectDashboard({
+ host,
+ harness: studio,
+ url: 'http://127.0.0.1:8790',
+ threads: ['main'],
+ allowRemoteStart: true,
+ onPairingCode: (code) => console.log(`Approve ${code} in the dashboard`),
+})
+```
+
+Then open the thread in the dashboard:
+
+1. Find the host under "Hosts".
+2. Type a thread id in the "Thread id" field.
+3. Click "Open".
+
+The thread view opens. For a new thread, send a prompt to start it. If the host is offline, the view shows "Queued: the host is offline." The host opens the thread if it reconnects within 10 minutes.
+
+If the host refuses an input, the view shows "Refused:" and the reason. A view that you open later shows the latest refusal too.
+
+Know these limits:
+
+- Only a host with `allowRemoteStart` shows the "Thread id" field. For other hosts, the dashboard refuses an open with a `403` and `remote_start_disabled`.
+- A thread in `threads`, or a thread that you attach with `connection.attach(threadId)`, opens without `allowRemoteStart`.
+
+## When the dashboard restarts
+
+The dashboard keeps host tokens in memory. After a restart, it refuses each saved token with a `401`. What the agent does next depends on `onPairingCode`:
+
+- With `onPairingCode`, the agent pairs again. It calls `onPairingCode` with a new code. After you approve the code, it calls `onToken` with the new token.
+- Without `onPairingCode`, the connection stops, and the agent calls `onError`.
+
+If the agent starts with a refused token and has `onPairingCode`, `connectDashboard` waits until you approve the new code.
+
+To stop and report the error, set `onError` and do not set `onPairingCode`:
+
+```ts group=harness-dashboard
+const savedToken = process.env.HARNESS_DASHBOARD_TOKEN
+if (!savedToken) throw new Error('Set HARNESS_DASHBOARD_TOKEN first.')
+
+const strict = await connectDashboard({
+ host,
+ harness: studio,
+ url: 'http://127.0.0.1:8790',
+ token: savedToken,
+ onError: (error) => console.error(`Dashboard: ${error.message}`),
+})
+```
+
+`onError` also gets an error when the agent cannot handle a frame from the dashboard, for example when a thread cannot open. Then the connection stays.
+
+`connection.token` always holds the current host token. It changes when the agent pairs again.
+
+A revoked host also gets a `401`. If it has `onPairingCode`, it asks to pair again, and a new request shows under "Pairing requests". If you do not approve the request, the host does not connect. Each request expires after 10 minutes, and then the host asks again. To stop the requests, call `connection.close()` on that host, or stop the agent.
+
+## What you have now
+
+- Your sessions on any screen, with approvals and prompts a tap away.
+- Threads that you open from the dashboard, on hosts that allow it.
+- Agents that pair again after a dashboard restart, or that stop and report it.
diff --git a/packages/ai-dashboard/README.md b/packages/ai-dashboard/README.md
new file mode 100644
index 0000000000..9e573e8735
--- /dev/null
+++ b/packages/ai-dashboard/README.md
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+# @tanstack/ai-dashboard
+
+A self-hosted dashboard for TanStack AI harnesses. Watch sessions, approve tool calls, answer questions, and send prompts from a browser or a phone. Agents dial out to it, so they need no open port.
+
+## Start the dashboard
+
+```bash
+npx @tanstack/ai-dashboard --port 8790
+```
+
+It prints a sign-in link with the owner token. Set `DASHBOARD_OWNER_TOKEN` to keep the same token across restarts.
+
+## Connect an agent
+
+```ts
+import { connectDashboard } from '@tanstack/ai-dashboard/connect'
+
+await connectDashboard({
+ host,
+ harness: studio,
+ url: 'http://127.0.0.1:8790',
+ threads: ['main'],
+ onPairingCode: (code) => console.log(`Approve ${code} in the dashboard`),
+ onToken: (token) => console.log(`Save this host token: ${token}`),
+})
+```
+
+To open new threads on this host from the dashboard, set `allowRemoteStart: true`. Only a host with this option shows the "Thread id" field.
+
+## When the dashboard restarts
+
+The dashboard keeps host tokens in memory. After a restart, it refuses the saved token of each agent. It also refuses the token of a revoked host. What the agent does next depends on `onPairingCode`:
+
+- If `onPairingCode` is set, the agent pairs again. It calls `onPairingCode` with a new code, then `onToken` with the new token.
+- If `onPairingCode` is not set, the agent stops and calls `onError`.
+
+```ts
+const connection = await connectDashboard({
+ host,
+ harness: studio,
+ url: 'http://127.0.0.1:8790',
+ token: savedToken,
+ onError: (error) => console.error(`Dashboard: ${error.message}`),
+})
+```
+
+`onError` also gets an error when the agent cannot handle a frame from the dashboard, for example when a thread cannot open. Then the connection stays.
+
+`connection.token` always holds the current host token.
+
+## Documentation
+
+Read [Self-host the dashboard](https://tanstack.com/ai/latest/docs/harness/dashboard).
+
+## License
+
+MIT
diff --git a/packages/ai-dashboard/bin/dashboard.mjs b/packages/ai-dashboard/bin/dashboard.mjs
new file mode 100755
index 0000000000..bf9a07d52e
--- /dev/null
+++ b/packages/ai-dashboard/bin/dashboard.mjs
@@ -0,0 +1,26 @@
+#!/usr/bin/env node
+// Start a dashboard: npx @tanstack/ai-dashboard [--port 8790] [--host 127.0.0.1]
+import { parseArgs } from 'node:util'
+import { startDashboard } from '../dist/esm/index.js'
+
+const { values } = parseArgs({
+ options: { port: { type: 'string' }, host: { type: 'string' } },
+})
+const dashboard = await startDashboard({
+ port: Number(values.port ?? process.env.PORT ?? 8790),
+ hostname: values.host ?? process.env.HOST ?? '127.0.0.1',
+ ...(process.env.DASHBOARD_OWNER_TOKEN
+ ? { ownerToken: process.env.DASHBOARD_OWNER_TOKEN }
+ : {}),
+})
+console.log(`Dashboard: ${dashboard.url}`)
+if (!process.env.DASHBOARD_OWNER_TOKEN) {
+ console.log(`Owner token: ${dashboard.ownerToken}`)
+ console.log(`Sign in: ${dashboard.url}/#token=${dashboard.ownerToken}`)
+}
+const stop = async () => {
+ await dashboard.close()
+ process.exit(0)
+}
+process.once('SIGINT', stop)
+process.once('SIGTERM', stop)
diff --git a/packages/ai-dashboard/package.json b/packages/ai-dashboard/package.json
new file mode 100644
index 0000000000..5e096733d6
--- /dev/null
+++ b/packages/ai-dashboard/package.json
@@ -0,0 +1,85 @@
+{
+ "name": "@tanstack/ai-dashboard",
+ "version": "0.0.0",
+ "description": "A self-hosted dashboard for TanStack AI harnesses: watch sessions, approve tools, and send prompts from a browser or a phone",
+ "author": "",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/TanStack/ai.git",
+ "directory": "packages/ai-dashboard"
+ },
+ "type": "module",
+ "module": "./dist/esm/index.js",
+ "types": "./dist/esm/index.d.ts",
+ "bin": {
+ "tanstack-ai-dashboard": "./bin/dashboard.mjs"
+ },
+ "exports": {
+ ".": {
+ "types": "./dist/esm/index.d.ts",
+ "import": "./dist/esm/index.js"
+ },
+ "./connect": {
+ "types": "./dist/esm/connect.d.ts",
+ "import": "./dist/esm/connect.js"
+ }
+ },
+ "sideEffects": false,
+ "files": [
+ "dist",
+ "src",
+ "bin"
+ ],
+ "scripts": {
+ "build": "vite build",
+ "clean": "premove ./build ./dist",
+ "lint:fix": "oxlint src --type-aware --fix",
+ "test:build": "publint --strict",
+ "test:oxlint": "oxlint src --type-aware",
+ "test:coverage": "vitest run --passWithNoTests --coverage --coverage.include='src/**' --coverage.reporter=text-summary --coverage.reporter=json-summary",
+ "test:lib": "vitest --passWithNoTests",
+ "test:lib:dev": "pnpm test:lib --watch",
+ "test:types": "tsc"
+ },
+ "nx": {
+ "targets": {
+ "test:lib": {
+ "dependsOn": [
+ "build",
+ "^build"
+ ]
+ },
+ "test:coverage": {
+ "dependsOn": [
+ "build",
+ "^build"
+ ]
+ },
+ "test:types": {
+ "dependsOn": [
+ "build",
+ "^build"
+ ]
+ }
+ }
+ },
+ "keywords": [
+ "ai",
+ "tanstack",
+ "harness",
+ "dashboard",
+ "relay",
+ "pwa"
+ ],
+ "peerDependencies": {
+ "@tanstack/ai": "workspace:^",
+ "@tanstack/ai-harness": "workspace:^"
+ },
+ "devDependencies": {
+ "@tanstack/ai": "workspace:*",
+ "@tanstack/ai-harness": "workspace:*",
+ "@tanstack/ai-persistence": "workspace:*",
+ "@vitest/coverage-v8": "4.1.10"
+ }
+}
diff --git a/packages/ai-dashboard/src/connect.ts b/packages/ai-dashboard/src/connect.ts
new file mode 100644
index 0000000000..41953d4841
--- /dev/null
+++ b/packages/ai-dashboard/src/connect.ts
@@ -0,0 +1,298 @@
+import { applyInput, parseHarnessInput } from '@tanstack/ai-harness'
+import type {
+ AnyHarness,
+ HarnessHost,
+ HarnessSession,
+} from '@tanstack/ai-harness'
+
+export interface ConnectDashboardOptions {
+ host: HarnessHost
+ harness: AnyHarness
+ /** The dashboard URL, for example `https://dash.example.com`. */
+ url: string
+ /** The host token. Without one, the host pairs first (see `onPairingCode`). */
+ token?: string
+ /** How this host shows up in the dashboard. */
+ name?: string
+ /** Threads to show in the dashboard at once. */
+ threads?: ReadonlyArray
+ /** Let the dashboard open new sessions on this host. Default false. */
+ allowRemoteStart?: boolean
+ /** Called with the code to approve in the dashboard. Set it to pair again when the dashboard refuses the token. */
+ onPairingCode?: (code: string) => void
+ /** Called with the new host token after pairing. Save it to skip pairing next time. */
+ onToken?: (token: string) => void
+ /**
+ * Called when the dashboard refuses the host token and `onPairingCode` is not set. The connection stops.
+ * Also called when the host fails to handle a frame from the dashboard, for example when a thread does not open. The connection stays.
+ */
+ onError?: (error: Error) => void
+ fetch?: typeof fetch
+ /** Wait between reconnects, doubled up to 30 s. Default 1000 ms. */
+ reconnectDelayMs?: number
+}
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === 'object' && value !== null
+
+async function* sseData(response: Response): AsyncGenerator {
+ if (!response.body) return
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ''
+ while (true) {
+ const { value, done } = await reader.read()
+ if (done) return
+ buffer += decoder.decode(value, { stream: true })
+ const blocks = buffer.split('\n\n')
+ buffer = blocks.pop() ?? ''
+ for (const block of blocks) {
+ const data = block
+ .split('\n')
+ .find((line) => line.startsWith('data: '))
+ ?.slice(6)
+ if (data) yield JSON.parse(data)
+ }
+ }
+}
+
+/**
+ * Connect a harness host to a dashboard. The host dials out, so it needs no
+ * open port. The dashboard shows its sessions and sends inputs back. Every
+ * input goes through the same session API as a local client.
+ */
+export async function connectDashboard(options: ConnectDashboardOptions) {
+ const base = options.url.replace(/\/$/, '')
+ const doFetch = options.fetch ?? fetch
+ const stopped = new AbortController()
+
+ const pair = async (): Promise => {
+ const started = await (
+ await doFetch(`${base}/api/pair/start`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: options.name ?? options.harness.name }),
+ })
+ ).json()
+ if (
+ !isRecord(started) ||
+ typeof started.pairingId !== 'string' ||
+ typeof started.code !== 'string'
+ ) {
+ throw new Error('The dashboard did not start a pairing.')
+ }
+ options.onPairingCode?.(started.code)
+ while (true) {
+ if (stopped.signal.aborted)
+ throw new Error('Stopped before pairing finished.')
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+ const status: unknown = await (
+ await doFetch(
+ `${base}/api/pair/status?pairingId=${encodeURIComponent(started.pairingId)}`,
+ )
+ ).json()
+ if (
+ isRecord(status) &&
+ status.status === 'approved' &&
+ typeof status.token === 'string'
+ ) {
+ options.onToken?.(status.token)
+ return status.token
+ } else if (!isRecord(status) || status.status !== 'pending') {
+ throw new Error('The pairing expired. Start again.')
+ }
+ }
+ }
+
+ let hostToken = options.token || (await pair())
+ const headers = () => ({
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${hostToken}`,
+ })
+
+ const post = (path: string, value: unknown) =>
+ doFetch(`${base}${path}`, {
+ method: 'POST',
+ headers: headers(),
+ body: JSON.stringify(value),
+ })
+
+ const hello = async (): Promise => {
+ const response = await post('/api/host/hello', {
+ name: options.name ?? options.harness.name,
+ harnesses: [options.harness.name],
+ allowRemoteStart: options.allowRemoteStart === true,
+ })
+ if (response.status !== 401) return
+ // A restarted dashboard forgets its host tokens. Pair again, or report it.
+ if (!options.onPairingCode) {
+ options.onError?.(new Error('The dashboard refused this host token.'))
+ return stopped.abort()
+ }
+ hostToken = await pair()
+ await hello()
+ }
+ await hello()
+
+ /** Open a thread and send its events to the dashboard. */
+ const open = async (threadId: string): Promise => {
+ const session = await options.host.open(options.harness, { threadId })
+ // Batch events so a streaming answer is a few requests, not one per token.
+ let pending: Array = []
+ let timer: ReturnType | undefined
+ // One send at a time, so the dashboard gets the batches in order.
+ let sending = Promise.resolve()
+ const flush = () => {
+ timer = undefined
+ const frames = pending
+ pending = []
+ if (frames.length > 0) {
+ sending = sending
+ .then(() =>
+ post('/api/host/frames', {
+ threadId,
+ harness: options.harness.name,
+ frames,
+ }),
+ )
+ .then(
+ () => {},
+ () => {},
+ )
+ }
+ }
+ void (async () => {
+ for await (const entry of session.events({
+ from: '0',
+ signal: stopped.signal,
+ })) {
+ pending.push({ type: 'harness.event', ...entry })
+ timer ??= setTimeout(flush, 50)
+ }
+ })().catch((error: unknown) =>
+ // A failed feed stops this thread only. The host keeps running.
+ options.onError?.(
+ error instanceof Error ? error : new Error(String(error)),
+ ),
+ )
+ return session
+ }
+ // Keep the open while it runs, so a frame that comes before it ends waits
+ // for it, and a second attach does not open the thread again.
+ const attached = new Map>()
+ const attach = (threadId: string): Promise => {
+ const existing = attached.get(threadId)
+ if (existing) return existing
+ const opening = open(threadId)
+ attached.set(threadId, opening)
+ // A failed open leaves no entry. Its inputs are refused, and a later
+ // open tries again.
+ opening.catch(() => attached.delete(threadId))
+ return opening
+ }
+ for (const threadId of options.threads ?? []) await attach(threadId)
+
+ const handle = async (envelope: unknown) => {
+ if (
+ !isRecord(envelope) ||
+ typeof envelope.threadId !== 'string' ||
+ !isRecord(envelope.frame)
+ )
+ return
+ const { threadId, frame } = envelope
+ if (frame.type === 'harness.subscribe') {
+ if (attached.has(threadId) || options.allowRemoteStart)
+ await attach(threadId)
+ return
+ }
+ // Wait for an open that still runs. A failed open refuses the input.
+ const session = await attached.get(threadId)?.catch(() => undefined)
+ if (!session) {
+ if (
+ frame.type === 'harness.input' &&
+ typeof frame.requestId === 'string'
+ ) {
+ await post('/api/host/frames', {
+ threadId,
+ harness: options.harness.name,
+ frames: [
+ {
+ type: 'harness.receipt',
+ requestId: frame.requestId,
+ status: 'rejected',
+ reason: 'remote_start_disabled',
+ },
+ ],
+ })
+ }
+ return
+ }
+ if (frame.type === 'harness.input' && typeof frame.requestId === 'string') {
+ try {
+ const receipt = await applyInput(
+ options.harness,
+ session,
+ parseHarnessInput(frame.input),
+ )
+ await post('/api/host/frames', {
+ threadId,
+ harness: options.harness.name,
+ frames: [
+ { type: 'harness.receipt', requestId: frame.requestId, ...receipt },
+ ],
+ })
+ } catch (error) {
+ await post('/api/host/frames', {
+ threadId,
+ harness: options.harness.name,
+ frames: [
+ {
+ type: 'harness.error',
+ message: error instanceof Error ? error.message : String(error),
+ },
+ ],
+ })
+ }
+ }
+ }
+
+ // The control stream, with reconnects.
+ void (async () => {
+ let delay = options.reconnectDelayMs ?? 1000
+ while (!stopped.signal.aborted) {
+ try {
+ const response = await doFetch(`${base}/api/host/stream`, {
+ headers: headers(),
+ signal: stopped.signal,
+ })
+ if (response.status === 401) {
+ await hello()
+ } else {
+ delay = options.reconnectDelayMs ?? 1000
+ for await (const envelope of sseData(response)) {
+ // A frame that fails does not stop the host. Report it.
+ void handle(envelope).catch((error: unknown) =>
+ options.onError?.(
+ error instanceof Error ? error : new Error(String(error)),
+ ),
+ )
+ }
+ }
+ } catch {
+ if (stopped.signal.aborted) return
+ }
+ await new Promise((resolve) => setTimeout(resolve, delay))
+ delay = Math.min(delay * 2, 30_000)
+ }
+ })()
+
+ return {
+ /** The host token. It changes when the host pairs again. */
+ get token() {
+ return hostToken
+ },
+ /** Show another thread in the dashboard. */
+ attach: (threadId: string) => attach(threadId).then(() => undefined),
+ close: () => stopped.abort(),
+ }
+}
diff --git a/packages/ai-dashboard/src/index.ts b/packages/ai-dashboard/src/index.ts
new file mode 100644
index 0000000000..2d67dee7c8
--- /dev/null
+++ b/packages/ai-dashboard/src/index.ts
@@ -0,0 +1,2 @@
+export { startDashboard } from './server'
+export type { ControlEnvelope, DashboardOptions } from './server'
diff --git a/packages/ai-dashboard/src/server.ts b/packages/ai-dashboard/src/server.ts
new file mode 100644
index 0000000000..6cfb1db7ce
--- /dev/null
+++ b/packages/ai-dashboard/src/server.ts
@@ -0,0 +1,461 @@
+import { createServer } from 'node:http'
+import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto'
+import { DASHBOARD_HTML, MANIFEST_JSON, SERVICE_WORKER } from './ui'
+import type { IncomingMessage, ServerResponse } from 'node:http'
+
+/** One frame the dashboard sends a host: a session-tier control frame for a thread. */
+export interface ControlEnvelope {
+ threadId: string
+ frame: Record
+}
+
+export interface DashboardOptions {
+ port?: number
+ hostname?: string
+ /** The owner's login token. Default: a new random token, returned by `startDashboard`. */
+ ownerToken?: string
+ /** Events kept per session for replay. Default 2000. */
+ cacheSize?: number
+ /** How long an input waits for an offline host. Default 10 minutes. */
+ queueTtlMs?: number
+}
+
+interface HostRecord {
+ hostId: string
+ name: string
+ harnesses: Array
+ /** From the hello. A host that sends no flag does not allow remote start. */
+ allowRemoteStart: boolean
+ token: string
+ lastSeen: number
+ streams: Set
+ queue: Array<{ envelope: ControlEnvelope; expiresAt: number }>
+}
+
+interface SessionRecord {
+ hostId: string
+ threadId: string
+ harness?: string
+ events: Array<{
+ cursor: string
+ operationId: string
+ event: Record
+ }>
+ /**
+ * The latest refused input, until a new event comes. A view that connects
+ * later gets it after the events.
+ */
+ refusal?: Record
+ lastActivity: number
+ status: 'idle' | 'running' | 'waiting'
+ clients: Set
+}
+
+const token = () => randomBytes(24).toString('base64url')
+
+function sameSecret(
+ given: string | null | undefined,
+ expected: string,
+): boolean {
+ if (!given) return false
+ const a = Buffer.from(given)
+ const b = Buffer.from(expected)
+ return a.length === b.length && timingSafeEqual(a, b)
+}
+
+function bearer(req: IncomingMessage, url: URL): string | null {
+ const header = req.headers.authorization
+ if (typeof header === 'string' && header.startsWith('Bearer '))
+ return header.slice(7)
+ // EventSource cannot send headers, so SSE clients pass the token in the URL.
+ return url.searchParams.get('token')
+}
+
+async function body(req: IncomingMessage): Promise> {
+ const chunks: Array = []
+ let size = 0
+ for await (const chunk of req) {
+ size += (chunk as Buffer).length
+ if (size > 5_000_000) throw new Error('Body too large.')
+ chunks.push(chunk as Buffer)
+ }
+ if (chunks.length === 0) return {}
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
+ throw new Error('Expected a JSON object.')
+ // Checked to be a plain object above.
+ return parsed as Record
+}
+
+function json(res: ServerResponse, status: number, value: unknown): void {
+ res.writeHead(status, {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-store',
+ })
+ res.end(JSON.stringify(value))
+}
+
+function openSse(res: ServerResponse): void {
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+ })
+ res.write(': connected\n\n')
+}
+
+function sse(res: ServerResponse, data: unknown, id?: string): void {
+ res.write(`${id ? `id: ${id}\n` : ''}data: ${JSON.stringify(data)}\n\n`)
+}
+
+/** A code a person can read out: 3 characters, a dash, 3 characters. */
+function pairingCode(): string {
+ const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
+ const pick = () => alphabet.charAt(randomInt(alphabet.length))
+ return `${pick()}${pick()}${pick()}-${pick()}${pick()}${pick()}`
+}
+
+/**
+ * Start a self-hosted dashboard server. Harness hosts dial out to it
+ * (`connectDashboard`), and people open it in a browser or on a phone.
+ *
+ * - Hosts pair with a one-time code that the owner approves, and get a host token.
+ * - The owner logs in with the owner token.
+ * - The server relays session frames and keeps a cache of recent events.
+ * Hosts keep the transcripts.
+ */
+export async function startDashboard(options: DashboardOptions = {}) {
+ const ownerToken = options.ownerToken ?? token()
+ const cacheSize = options.cacheSize ?? 2000
+ const queueTtlMs = options.queueTtlMs ?? 10 * 60_000
+ const hosts = new Map()
+ const hostByToken = new Map()
+ const pairings = new Map<
+ string,
+ { code: string; name: string; expiresAt: number; hostId?: string }
+ >()
+ const sessions = new Map()
+ const sessionKey = (hostId: string, threadId: string) =>
+ `${hostId}\u0000${threadId}`
+
+ const isOwner = (req: IncomingMessage, url: URL) =>
+ sameSecret(bearer(req, url), ownerToken)
+ const hostFor = (req: IncomingMessage, url: URL): HostRecord | undefined => {
+ const given = bearer(req, url)
+ const hostId = given ? hostByToken.get(given) : undefined
+ const host = hostId ? hosts.get(hostId) : undefined
+ return host && sameSecret(given, host.token) ? host : undefined
+ }
+
+ const sessionFor = (hostId: string, threadId: string): SessionRecord => {
+ const key = sessionKey(hostId, threadId)
+ let session = sessions.get(key)
+ if (!session) {
+ session = {
+ hostId,
+ threadId,
+ events: [],
+ lastActivity: Date.now(),
+ status: 'idle',
+ clients: new Set(),
+ }
+ sessions.set(key, session)
+ }
+ return session
+ }
+
+ const sendToHost = (
+ host: HostRecord,
+ envelope: ControlEnvelope,
+ ): 'sent' | 'queued' => {
+ if (host.streams.size === 0) {
+ host.queue.push({ envelope, expiresAt: Date.now() + queueTtlMs })
+ return 'queued'
+ }
+ for (const stream of host.streams) sse(stream, envelope)
+ return 'sent'
+ }
+
+ const record = (session: SessionRecord, frame: Record) => {
+ session.lastActivity = Date.now()
+ if (frame.type === 'harness.event') {
+ const event = frame.event as Record
+ const name = event.type === 'CUSTOM' ? event.name : undefined
+ if (name === 'harness.operation.started') session.status = 'running'
+ if (name === 'harness.question') session.status = 'waiting'
+ if (name === 'harness.operation.finished') {
+ const value = event.value as { status?: unknown } | undefined
+ session.status = value?.status === 'interrupted' ? 'waiting' : 'idle'
+ }
+ session.events.push({
+ cursor: String(frame.cursor),
+ operationId: String(frame.operationId),
+ event,
+ })
+ if (session.events.length > cacheSize)
+ session.events.splice(0, session.events.length - cacheSize)
+ session.refusal = undefined
+ }
+ if (frame.type === 'harness.receipt' && frame.status === 'rejected')
+ session.refusal = frame
+ for (const client of session.clients)
+ sse(
+ client,
+ frame,
+ typeof frame.cursor === 'string' ? frame.cursor : undefined,
+ )
+ }
+
+ const route = async (req: IncomingMessage, res: ServerResponse) => {
+ const url = new URL(req.url ?? '/', 'http://dashboard')
+ const path = url.pathname
+ const method = req.method ?? 'GET'
+
+ // The app shell. It holds no data; every API call needs a token.
+ if (method === 'GET' && (path === '/' || path === '/index.html')) {
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
+ res.end(DASHBOARD_HTML)
+ return
+ }
+ if (method === 'GET' && path === '/manifest.webmanifest') {
+ res.writeHead(200, { 'Content-Type': 'application/manifest+json' })
+ res.end(MANIFEST_JSON)
+ return
+ }
+ if (method === 'GET' && path === '/sw.js') {
+ res.writeHead(200, { 'Content-Type': 'text/javascript' })
+ res.end(SERVICE_WORKER)
+ return
+ }
+
+ // Pairing: a host asks, the owner approves, the host gets a token.
+ if (method === 'POST' && path === '/api/pair/start') {
+ const input = await body(req)
+ const pairingId = token()
+ const code = pairingCode()
+ pairings.set(pairingId, {
+ code,
+ name:
+ typeof input.name === 'string' ? input.name.slice(0, 100) : 'host',
+ expiresAt: Date.now() + 10 * 60_000,
+ })
+ json(res, 200, { pairingId, code })
+ return
+ }
+ if (method === 'GET' && path === '/api/pair/status') {
+ const pairing = pairings.get(url.searchParams.get('pairingId') ?? '')
+ if (!pairing || pairing.expiresAt < Date.now())
+ return json(res, 404, { error: 'unknown or expired pairing' })
+ if (!pairing.hostId) return json(res, 200, { status: 'pending' })
+ const host = hosts.get(pairing.hostId)
+ pairings.delete(url.searchParams.get('pairingId') ?? '')
+ return json(res, 200, {
+ status: 'approved',
+ hostId: pairing.hostId,
+ token: host?.token,
+ })
+ }
+
+ if (path.startsWith('/api/host/')) {
+ const host = hostFor(req, url)
+ if (!host) return json(res, 401, { error: 'unauthorized' })
+ host.lastSeen = Date.now()
+ if (method === 'GET' && path === '/api/host/stream') {
+ openSse(res)
+ host.streams.add(res)
+ const now = Date.now()
+ for (const queued of host.queue.splice(0)) {
+ if (queued.expiresAt > now) sse(res, queued.envelope)
+ }
+ const heartbeat = setInterval(() => res.write(': ping\n\n'), 15_000)
+ res.on('close', () => {
+ clearInterval(heartbeat)
+ host.streams.delete(res)
+ })
+ return
+ }
+ if (method === 'POST' && path === '/api/host/hello') {
+ const input = await body(req)
+ if (typeof input.name === 'string') host.name = input.name.slice(0, 100)
+ if (Array.isArray(input.harnesses))
+ host.harnesses = input.harnesses.map(String).slice(0, 50)
+ host.allowRemoteStart = input.allowRemoteStart === true
+ return json(res, 200, { hostId: host.hostId })
+ }
+ if (method === 'POST' && path === '/api/host/frames') {
+ const input = await body(req)
+ if (
+ typeof input.threadId !== 'string' ||
+ !Array.isArray(input.frames)
+ ) {
+ return json(res, 400, { error: 'expected { threadId, frames }' })
+ }
+ const session = sessionFor(host.hostId, input.threadId)
+ if (typeof input.harness === 'string') session.harness = input.harness
+ for (const frame of input.frames) {
+ if (typeof frame === 'object' && frame !== null)
+ record(session, frame as Record)
+ }
+ return json(res, 200, { ok: true })
+ }
+ return json(res, 404, { error: 'not found' })
+ }
+
+ // Everything below is for the owner.
+ if (!path.startsWith('/api/')) return json(res, 404, { error: 'not found' })
+ if (!isOwner(req, url)) return json(res, 401, { error: 'unauthorized' })
+
+ if (method === 'GET' && path === '/api/pairings') {
+ const now = Date.now()
+ return json(
+ res,
+ 200,
+ [...pairings.values()]
+ .filter((pairing) => !pairing.hostId && pairing.expiresAt > now)
+ .map((pairing) => ({ code: pairing.code, name: pairing.name })),
+ )
+ }
+ if (method === 'POST' && path === '/api/pair/approve') {
+ const input = await body(req)
+ const entry = [...pairings.entries()].find(
+ ([, pairing]) =>
+ pairing.code === input.code &&
+ !pairing.hostId &&
+ pairing.expiresAt > Date.now(),
+ )
+ if (!entry) return json(res, 404, { error: 'unknown or expired code' })
+ const hostId = `host-${randomBytes(6).toString('hex')}`
+ const hostToken = token()
+ hosts.set(hostId, {
+ hostId,
+ name: entry[1].name,
+ harnesses: [],
+ allowRemoteStart: false,
+ token: hostToken,
+ lastSeen: Date.now(),
+ streams: new Set(),
+ queue: [],
+ })
+ hostByToken.set(hostToken, hostId)
+ entry[1].hostId = hostId
+ return json(res, 200, { hostId })
+ }
+ if (method === 'POST' && path === '/api/hosts/revoke') {
+ const input = await body(req)
+ const host = hosts.get(String(input.hostId))
+ if (!host) return json(res, 404, { error: 'unknown host' })
+ hostByToken.delete(host.token)
+ for (const stream of host.streams) stream.end()
+ hosts.delete(host.hostId)
+ return json(res, 200, { ok: true })
+ }
+ if (method === 'GET' && path === '/api/hosts') {
+ return json(
+ res,
+ 200,
+ [...hosts.values()].map((host) => ({
+ hostId: host.hostId,
+ name: host.name,
+ harnesses: host.harnesses,
+ allowRemoteStart: host.allowRemoteStart,
+ online: host.streams.size > 0,
+ lastSeen: host.lastSeen,
+ })),
+ )
+ }
+ if (method === 'GET' && path === '/api/sessions') {
+ return json(
+ res,
+ 200,
+ [...sessions.values()]
+ .sort((a, b) => b.lastActivity - a.lastActivity)
+ .map((session) => ({
+ hostId: session.hostId,
+ threadId: session.threadId,
+ harness: session.harness,
+ status: session.status,
+ lastActivity: session.lastActivity,
+ online: (hosts.get(session.hostId)?.streams.size ?? 0) > 0,
+ })),
+ )
+ }
+
+ const match =
+ /^\/api\/sessions\/([^/]+)\/([^/]+)\/(events|input|open)$/.exec(path)
+ if (match) {
+ const hostId = decodeURIComponent(match[1] ?? '')
+ const threadId = decodeURIComponent(match[2] ?? '')
+ const host = hosts.get(hostId)
+ if (!host) return json(res, 404, { error: 'unknown host' })
+ if (method === 'GET' && match[3] === 'events') {
+ const session = sessionFor(hostId, threadId)
+ openSse(res)
+ const from = Number(
+ url.searchParams.get('from') ?? req.headers['last-event-id'] ?? 0,
+ )
+ for (const entry of session.events) {
+ if (Number(entry.cursor) > from)
+ sse(res, { type: 'harness.event', ...entry }, entry.cursor)
+ }
+ if (session.refusal) sse(res, session.refusal)
+ session.clients.add(res)
+ const heartbeat = setInterval(() => res.write(': ping\n\n'), 15_000)
+ res.on('close', () => {
+ clearInterval(heartbeat)
+ session.clients.delete(res)
+ })
+ return
+ }
+ if (method === 'POST' && match[3] === 'input') {
+ const input = await body(req)
+ const requestId = `dash-${randomBytes(6).toString('hex')}`
+ const delivery = sendToHost(host, {
+ threadId,
+ frame: { type: 'harness.input', requestId, input: input.input },
+ })
+ return json(res, 202, { requestId, status: delivery })
+ }
+ if (method === 'POST' && match[3] === 'open') {
+ // The host would ignore the open, so say no at once.
+ if (!host.allowRemoteStart)
+ return json(res, 403, { error: 'remote_start_disabled' })
+ const delivery = sendToHost(host, {
+ threadId,
+ frame: { type: 'harness.subscribe', threadId },
+ })
+ return json(res, 202, { status: delivery })
+ }
+ }
+ return json(res, 404, { error: 'not found' })
+ }
+
+ const server = createServer((req, res) => {
+ route(req, res).catch((error: unknown) => {
+ if (!res.headersSent)
+ json(res, 400, {
+ error: error instanceof Error ? error.message : String(error),
+ })
+ else res.end()
+ })
+ })
+ await new Promise((resolve, reject) => {
+ server.once('error', reject)
+ server.listen(options.port ?? 8790, options.hostname ?? '127.0.0.1', () =>
+ resolve(),
+ )
+ })
+ const address = server.address()
+ const port =
+ typeof address === 'object' && address
+ ? address.port
+ : (options.port ?? 8790)
+ return {
+ url: `http://${options.hostname ?? '127.0.0.1'}:${port}`,
+ ownerToken,
+ close: () =>
+ new Promise((resolve) => {
+ server.closeAllConnections()
+ server.close(() => resolve())
+ }),
+ }
+}
diff --git a/packages/ai-dashboard/src/ui.ts b/packages/ai-dashboard/src/ui.ts
new file mode 100644
index 0000000000..1c4d37b4e8
--- /dev/null
+++ b/packages/ai-dashboard/src/ui.ts
@@ -0,0 +1,247 @@
+// The dashboard web app: one static page, no build step. It renders every
+// value with textContent, never as HTML, so session content cannot inject
+// markup.
+
+export const MANIFEST_JSON = JSON.stringify({
+ name: 'TanStack AI Dashboard',
+ short_name: 'Harness',
+ start_url: '/',
+ display: 'standalone',
+ background_color: '#0b0d12',
+ theme_color: '#0b0d12',
+ icons: [],
+})
+
+// Caches the app shell only. API calls always go to the network.
+export const SERVICE_WORKER = `
+const SHELL = ['/', '/manifest.webmanifest']
+self.addEventListener('install', (event) => {
+ event.waitUntil(caches.open('harness-shell-v1').then((cache) => cache.addAll(SHELL)))
+})
+self.addEventListener('fetch', (event) => {
+ const url = new URL(event.request.url)
+ if (url.pathname.startsWith('/api/')) return
+ event.respondWith(fetch(event.request).catch(() => caches.match(event.request)))
+})
+`
+
+const SCRIPT = `
+const $ = (tag, props = {}, ...children) => {
+ const node = document.createElement(tag)
+ for (const [key, value] of Object.entries(props)) {
+ if (key === 'onclick' || key === 'onsubmit') node[key] = value
+ else if (key === 'class') node.className = value
+ else node.setAttribute(key, value)
+ }
+ for (const child of children) node.append(child instanceof Node ? child : document.createTextNode(String(child)))
+ return node
+}
+const fromHash = new URLSearchParams(location.hash.slice(1)).get('token')
+if (fromHash) { localStorage.setItem('harness-token', fromHash); history.replaceState(null, '', '/') }
+let token = localStorage.getItem('harness-token')
+let current = null
+let source = null
+
+async function api(path, options = {}) {
+ const response = await fetch(path, {
+ ...options,
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token, ...(options.headers || {}) },
+ })
+ if (response.status === 401) { localStorage.removeItem('harness-token'); token = null; render(); throw new Error('unauthorized') }
+ return response.json()
+}
+
+function login() {
+ const input = $('input', { type: 'password', placeholder: 'Owner token', autocomplete: 'off' })
+ return $('form', { class: 'card', onsubmit: (event) => {
+ event.preventDefault(); token = input.value.trim(); localStorage.setItem('harness-token', token); render()
+ } }, $('h2', {}, 'Sign in'), input, $('button', {}, 'Sign in'))
+}
+
+async function refresh() {
+ if (!token) return
+ const [pairings, hosts, sessions] = await Promise.all([api('/api/pairings'), api('/api/hosts'), api('/api/sessions')])
+ const side = document.getElementById('side')
+ side.replaceChildren(
+ $('h3', {}, 'Pairing requests'),
+ ...(pairings.length ? pairings.map((pairing) => $('div', { class: 'row' },
+ $('span', {}, pairing.name + ' ', $('code', {}, pairing.code)),
+ $('button', { onclick: async () => { await api('/api/pair/approve', { method: 'POST', body: JSON.stringify({ code: pairing.code }) }); refresh() } }, 'Approve'))) : [$('p', { class: 'muted' }, 'None')]),
+ $('h3', {}, 'Hosts'),
+ ...(hosts.length ? hosts.map((host) => $('div', { class: 'row' },
+ $('span', { class: host.online ? 'dot on' : 'dot' }), $('span', {}, host.name + ' (' + host.harnesses.join(', ') + ')'), ...(host.allowRemoteStart ? [openForm(host)] : []))) : [$('p', { class: 'muted' }, 'No hosts yet')]),
+ $('h3', {}, 'Sessions'),
+ ...(sessions.length ? sessions.map((session) => $('button', { class: 'session ' + session.status, onclick: () => open(session) },
+ session.threadId + ' | ' + (session.harness || '') + ' | ' + session.status)) : [$('p', { class: 'muted' }, 'No sessions yet')]),
+ )
+}
+
+const sessionPath = (hostId, threadId) => '/api/sessions/' + encodeURIComponent(hostId) + '/' + encodeURIComponent(threadId)
+
+// Start or attach a thread on a host, then watch it. Only a host with
+// allowRemoteStart gets this form.
+function openForm(host) {
+ const input = $('input', { placeholder: 'Thread id', 'aria-label': 'Thread id', autocomplete: 'off' })
+ return $('form', { class: 'row open-thread', onsubmit: async (event) => {
+ event.preventDefault(); const threadId = input.value.trim(); if (!threadId) return
+ const result = await api(sessionPath(host.hostId, threadId) + '/open', { method: 'POST' })
+ open({ hostId: host.hostId, threadId })
+ if (result.error) current.messages.push({ kind: 'error', text: result.error })
+ if (result.status === 'queued') current.messages.push({ kind: 'notice', text: 'Queued: the host is offline.' })
+ draw(); refresh().catch(() => {})
+ } }, input, $('button', {}, 'Open'))
+}
+
+function open(session) {
+ current = { ...session, messages: [], interrupts: [], questions: new Map(), running: false }
+ if (source) source.close()
+ const path = sessionPath(session.hostId, session.threadId)
+ current.path = path
+ source = new EventSource(path + '/events?token=' + encodeURIComponent(token))
+ source.onmessage = (message) => { apply(JSON.parse(message.data)); draw() }
+ draw()
+}
+
+function apply(frame) {
+ if (!current) return
+ // A refused input, for example to a thread the host does not let the dashboard start.
+ // The relay sends the latest refusal again when the stream reconnects, so show each one once.
+ if (frame.type === 'harness.receipt' && frame.status === 'rejected' && !(frame.requestId && current.messages.some((message) => message.requestId === frame.requestId)))
+ current.messages.push({ kind: 'error', requestId: frame.requestId, text: 'Refused: ' + (frame.reason || 'no reason') })
+ if (frame.type !== 'harness.event') return
+ const event = frame.event
+ if (event.subagentRunId) { applyChild(event); return }
+ if (event.type === 'TEXT_MESSAGE_CONTENT') {
+ const last = current.messages[current.messages.length - 1]
+ if (last && last.kind === 'assistant' && last.op === frame.operationId) last.text += event.delta
+ else current.messages.push({ kind: 'assistant', op: frame.operationId, text: event.delta })
+ } else if (event.type === 'TOOL_CALL_START') {
+ current.messages.push({ kind: 'tool', text: 'tool ' + event.toolCallName })
+ } else if (event.type === 'RUN_ERROR') {
+ current.messages.push({ kind: 'error', text: event.message })
+ } else if (event.type === 'RUN_FINISHED' && event.outcome && event.outcome.type === 'interrupt') {
+ current.interrupts = event.outcome.interrupts
+ } else if (event.type === 'CUSTOM') {
+ if (event.name === 'harness.operation.started') current.running = true
+ if (event.name === 'harness.operation.finished') current.running = false
+ if (event.name === 'harness.question') current.questions.set(event.value.questionId, event.value)
+ if (event.name === 'harness.question.answered') current.questions.delete(event.value.questionId)
+ if (event.name === 'harness.auth_required') current.messages.push({ kind: 'notice', text: 'Sign in to ' + event.value.connector + (event.value.url ? ': ' + event.value.url : '') })
+ }
+}
+
+// A child agent shows as one block under the message it came from: its name,
+// status, tool calls, and text.
+function applyChild(event) {
+ const children = current.children || (current.children = new Map())
+ if (event.type === 'SUBAGENT_STARTED') {
+ const node = { kind: 'agent', name: event.name || 'agent', status: 'working', tools: [], text: '' }
+ children.set(event.subagentRunId, node)
+ current.messages.push(node)
+ return
+ }
+ const node = children.get(event.subagentRunId)
+ if (!node) return
+ if (event.type === 'TEXT_MESSAGE_CONTENT') node.text += event.delta
+ else if (event.type === 'TOOL_CALL_START') node.tools.push(event.toolCallName)
+ else if (event.type === 'SUBAGENT_FINISHED') node.status = 'done'
+ else if (event.type === 'SUBAGENT_ERROR') { node.status = 'failed'; node.text += (node.text ? '\\n' : '') + event.message }
+}
+
+function messageNode(message) {
+ if (message.kind !== 'agent') return $('div', { class: 'msg ' + message.kind }, message.text)
+ return $('div', { class: 'msg agent ' + message.status },
+ $('strong', {}, 'agent ' + message.name + ' (' + message.status + ')'),
+ ...(message.tools.length ? [$('div', { class: 'tools' }, message.tools.map((tool) => 'tool ' + tool).join(', '))] : []),
+ ...(message.text ? [$('div', {}, message.text)] : []))
+}
+
+async function send(input) {
+ if (input.op === 'prompt' || input.op === 'steer') current.messages.push({ kind: 'user', text: input.message })
+ const receipt = await api(current.path + '/input', { method: 'POST', body: JSON.stringify({ input }) })
+ if (receipt.status === 'queued') current.messages.push({ kind: 'notice', text: 'Queued: the host is offline.' })
+ draw()
+}
+
+function draw() {
+ const main = document.getElementById('main')
+ if (!current) { main.replaceChildren($('p', { class: 'muted' }, 'Pick a session.')); return }
+ const log = $('div', { class: 'log' }, ...current.messages.map(messageNode))
+ const actions = []
+ if (current.interrupts.length) {
+ const decide = (approved) => { const resume = current.interrupts.map((interrupt) => ({ interruptId: interrupt.id, status: 'resolved', payload: approved })); current.interrupts = []; send({ op: 'resolve', resume }) }
+ actions.push($('div', { class: 'card' }, $('strong', {}, 'Waiting for approval: '), current.interrupts.map((interrupt) => interrupt.message || interrupt.toolCallId || interrupt.id).join(', '),
+ $('div', { class: 'row' }, $('button', { onclick: () => decide(true) }, 'Approve'), $('button', { class: 'ghost', onclick: () => decide(false) }, 'Reject'))))
+ }
+ for (const question of current.questions.values()) {
+ const answer = $('input', { placeholder: 'Your answer' })
+ const isBoolean = question.schema && question.schema.type === 'boolean'
+ actions.push($('div', { class: 'card' }, $('strong', {}, question.message),
+ isBoolean
+ ? $('div', { class: 'row' }, $('button', { onclick: () => send({ op: 'answer', questionId: question.questionId, value: true }) }, 'Yes'), $('button', { class: 'ghost', onclick: () => send({ op: 'answer', questionId: question.questionId, value: false }) }, 'No'))
+ : $('form', { class: 'row', onsubmit: (event) => { event.preventDefault(); send({ op: 'answer', questionId: question.questionId, value: answer.value }) } }, answer, $('button', {}, 'Answer'))))
+ }
+ const box = $('input', { placeholder: current.running ? 'Steer the running turn' : 'Send a message' })
+ const form = $('form', { class: 'row prompt', onsubmit: (event) => {
+ event.preventDefault(); const text = box.value.trim(); if (!text) return; box.value = ''
+ send({ op: current.running ? 'steer' : 'prompt', message: text })
+ } }, box, $('button', {}, 'Send'), $('button', { type: 'button', class: 'ghost', onclick: () => send({ op: 'cancel' }) }, 'Stop'))
+ main.replaceChildren($('h2', {}, current.threadId + (current.running ? ' (working)' : '')), log, ...actions, form)
+ log.scrollTop = log.scrollHeight
+}
+
+function render() {
+ const app = document.getElementById('app')
+ if (!token) { app.replaceChildren(login()); return }
+ app.replaceChildren($('aside', { id: 'side' }), $('main', { id: 'main' }))
+ draw(); refresh().catch(() => {})
+}
+// No refresh while a side panel input has focus, so a half-typed thread id survives.
+setInterval(() => {
+ const side = document.getElementById('side'), focused = document.activeElement
+ if (!(side && focused && focused.tagName === 'INPUT' && side.contains(focused))) refresh().catch(() => {})
+}, 5000)
+if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {})
+render()
+`
+
+export const DASHBOARD_HTML = `
+
+
+
+
+
+
+TanStack AI Dashboard
+
+
+
+
+
+
+`
diff --git a/packages/ai-dashboard/tests/child-agents.test.ts b/packages/ai-dashboard/tests/child-agents.test.ts
new file mode 100644
index 0000000000..c5fcb36485
--- /dev/null
+++ b/packages/ai-dashboard/tests/child-agents.test.ts
@@ -0,0 +1,143 @@
+import { runInContext, createContext } from 'node:vm'
+import { describe, expect, it } from 'vitest'
+import { DASHBOARD_HTML } from '../src/ui'
+
+/** Just enough DOM for the page script: nodes with children and text. */
+class FakeNode {
+ children: Array = []
+ className = ''
+ attributes: Record = {}
+ constructor(
+ readonly tag: string,
+ readonly text = '',
+ ) {}
+ append(...children: Array) {
+ this.children.push(...children)
+ }
+ replaceChildren(...children: Array) {
+ this.children = children
+ }
+ setAttribute(key: string, value: string) {
+ this.attributes[key] = value
+ }
+ get textContent(): string {
+ return this.text + this.children.map((child) => child.textContent).join('')
+ }
+}
+
+function loadPage() {
+ const script = /