Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3906d0a
feat(ai-harness): serve sessions over HTTP and WebSocket, with a type…
AlemTuzlak Oct 13, 2026
cac83c0
Merge remote-tracking branch 'origin/feat/harness-core' into feat/har…
AlemTuzlak Oct 13, 2026
178a4d6
fix(ai-harness): add back sessionOfTurn for the run route, and the OA…
AlemTuzlak Oct 13, 2026
cfdf7f0
feat(ai-harness, ai-dashboard): a self-hosted dashboard for harness s…
AlemTuzlak Oct 13, 2026
effa81f
fix(ai-harness): import ./session/index, so the built types resolve H…
AlemTuzlak Oct 13, 2026
1c24c9b
Merge remote-tracking branch 'origin/feat/harness-transport' into fea…
AlemTuzlak Oct 13, 2026
10c9cba
Merge remote-tracking branch 'origin/feat/harness-core' into feat/har…
AlemTuzlak Oct 13, 2026
b39a423
Merge remote-tracking branch 'origin/feat/harness-transport' into fea…
AlemTuzlak Oct 13, 2026
9242847
test(ai-harness): startLoopbackReceiver cases from #1555
AlemTuzlak Oct 13, 2026
c051417
Merge branch 'feat/harness-core' into feat/harness-transport
AlemTuzlak Oct 14, 2026
cbc4184
feat(ai-harness, ai-mcp): MCP servers in a harness, and a harness as …
AlemTuzlak Oct 14, 2026
5d9ccf9
feat(ai-harness): GET agent-transcript and client.agentTranscript
AlemTuzlak Oct 14, 2026
28d86c6
Merge branch 'feat/harness-core' into feat/harness-transport
AlemTuzlak Oct 14, 2026
9e88d6c
docs(ai-harness): list GET agent-transcript in the handler routes
AlemTuzlak Oct 14, 2026
fb14c5d
Merge branch 'feat/harness-core' into feat/harness-transport
AlemTuzlak Oct 14, 2026
6b15ac2
Merge branch 'feat/harness-core' into feat/harness-transport
AlemTuzlak Oct 14, 2026
f6561e4
docs(ai-harness): describe the card in client.agentTranscript
AlemTuzlak Oct 14, 2026
9f5ae29
Merge branch 'feat/harness-core' into feat/harness-transport
AlemTuzlak Oct 14, 2026
1b255c8
docs(ai-harness): read a child's messages from the browser
AlemTuzlak Oct 14, 2026
8264c81
Merge remote-tracking branch 'origin/feat/harness-transport' into fea…
AlemTuzlak Oct 14, 2026
0c62cdf
feat(ai-harness): createSessionView, one live store for a session in …
AlemTuzlak Oct 14, 2026
4348362
Merge remote-tracking branch 'origin/feat/harness-core' into feat/har…
AlemTuzlak Oct 14, 2026
18ad485
fix(ai-harness, ai-mcp): adopt object params from #1779
AlemTuzlak Oct 14, 2026
322b81b
docs(ai-harness): object params in the transport, view, MCP, and auth…
AlemTuzlak Oct 14, 2026
1e85a42
test(ai-harness): rename, update, and fork a session through the client
AlemTuzlak Oct 14, 2026
c3dbb34
Merge remote-tracking branch 'origin/feat/harness-transport' into fea…
AlemTuzlak Oct 14, 2026
9960c0c
Merge remote-tracking branch 'origin/feat/harness-core' into feat/har…
AlemTuzlak Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/ai-dashboard.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
},
Expand Down
149 changes: 149 additions & 0 deletions docs/harness/dashboard.md
Original file line number Diff line number Diff line change
@@ -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:

<!-- ::start:tabs variant="package-manager" mode="install" -->

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

<!-- ::end:tabs -->

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.
77 changes: 77 additions & 0 deletions packages/ai-dashboard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<div align="center">
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="https://tanstack.com/api/readme/ai.png?theme=dark"
/>
<source
media="(prefers-color-scheme: light)"
srcset="https://tanstack.com/api/readme/ai.png"
/>
<img
src="https://tanstack.com/api/readme/ai.png"
alt="TanStack AI"
width="900"
/>
</picture>
</div>

<br />

# @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
26 changes: 26 additions & 0 deletions packages/ai-dashboard/bin/dashboard.mjs
Original file line number Diff line number Diff line change
@@ -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)
85 changes: 85 additions & 0 deletions packages/ai-dashboard/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading