Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
4149a98
feat(ai-harness): createSessionView, one live store for a session in …
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
7364757
Merge branch 'feat/harness-transport' into feat/harness-view
AlemTuzlak Oct 13, 2026
cbadcfd
feat(ai-harness-cli): run a harness from the terminal, with optional …
AlemTuzlak Oct 13, 2026
effa81f
fix(ai-harness): import ./session/index, so the built types resolve H…
AlemTuzlak Oct 13, 2026
ba3aa56
Merge branch 'feat/harness-transport' into feat/harness-view
AlemTuzlak Oct 13, 2026
d759390
fix(ai-harness): import ../session/index in the view
AlemTuzlak Oct 13, 2026
db61eba
Merge branch 'feat/harness-view' into feat/harness-cli
AlemTuzlak Oct 13, 2026
10c9cba
Merge remote-tracking branch 'origin/feat/harness-core' into feat/har…
AlemTuzlak Oct 13, 2026
7335075
Merge branch 'feat/harness-transport' into feat/harness-view
AlemTuzlak Oct 13, 2026
7f19f3f
Merge branch 'feat/harness-view' into feat/harness-cli
AlemTuzlak Oct 13, 2026
9242847
test(ai-harness): startLoopbackReceiver cases from #1555
AlemTuzlak Oct 13, 2026
20cd873
Merge branch 'feat/harness-transport' into feat/harness-view
AlemTuzlak Oct 13, 2026
9539b8d
Merge branch 'feat/harness-view' into feat/harness-cli
AlemTuzlak Oct 13, 2026
02287cd
docs(examples): harness-cli, a notes assistant in the terminal with a…
AlemTuzlak Oct 14, 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
171fa75
Merge remote-tracking branch 'origin/feat/harness-transport' into fea…
AlemTuzlak Oct 14, 2026
ed31851
fix(ai-harness-cli): adopt object params from #1779
AlemTuzlak Oct 14, 2026
49e41ae
fix(ai-harness-cli): answer view interrupts, and skip old child parts…
AlemTuzlak Oct 14, 2026
3b70e31
fix(ai-harness-cli): pass canAccess and mediaSecret to the harness ha…
jherr Oct 15, 2026
e059dc0
Merge remote-tracking branch 'origin/feat/harness-core' into feat/har…
AlemTuzlak Oct 15, 2026
fba476c
docs(harness-cli): document the --serve trust model
jherr Oct 15, 2026
791ab34
Merge remote-tracking branch 'origin/feat/harness-core' into feat/har…
jherr Oct 15, 2026
c288d69
Merge remote-tracking branch 'origin/feat/harness-core' into feat/har…
jherr 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
11 changes: 11 additions & 0 deletions .changeset/ai-harness-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@tanstack/ai-harness-cli': minor
---

Add `@tanstack/ai-harness-cli`: `runCli(harness)` runs a harness from the terminal.

- Line mode reads one message or `/command` per line, answers approvals and questions, hides a secret answer while the user types, and sends `@path` files. Pass `ui` to show your own screen on a session view instead.
- `-p "prompt"` runs one prompt for scripts and CI, and `--output ndjson` prints every AG-UI event.
- `--serve` serves the session protocol over HTTP behind a bearer token.
- `--acp`, `--mcp`, and `--dashboard` load `@tanstack/ai-acp`, `@tanstack/ai-mcp`, and `@tanstack/ai-dashboard` only when you use the flag. A missing package, or one without harness support, fails that flag with a clear message.
- `host` and `principal` options let the CLI use your durable host and run as your user.
5 changes: 5 additions & 0 deletions .changeset/harness-cli-handler-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-harness-cli': patch
---

`--serve` passes `canAccess` and `mediaSecret` to the harness handler, which needs both.
5 changes: 5 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,11 @@
"addedAt": "2026-10-13",
"updatedAt": "2026-10-14"
},
{
"label": "Run in the terminal",
"to": "harness/cli",
"addedAt": "2026-10-13"
},
{
"label": "Deploy a harness",
"to": "harness/deploy",
Expand Down
189 changes: 189 additions & 0 deletions docs/harness/cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
---
title: Run a harness in the terminal
id: harness-cli
order: 3
description: "Run your harness in a terminal with line mode or your own screen, a print mode for scripts and CI, NDJSON output, an ACP mode for editors, and an HTTP server."
keywords:
- tanstack ai
- harness
- cli
- terminal
- ink
---

You built a harness and want to use it like Claude Code: type in a terminal, watch it work, approve tools. You also want to run it in CI. `runCli` gives one harness all of these modes.

## Install

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

react: @tanstack/ai-harness-cli
vue: @tanstack/ai-harness-cli
solid: @tanstack/ai-harness-cli
svelte: @tanstack/ai-harness-cli
preact: @tanstack/ai-harness-cli
angular: @tanstack/ai-harness-cli
vanilla: @tanstack/ai-harness-cli
octane: @tanstack/ai-harness-cli

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

## 1. Write the entry file

```ts group=harness-cli
import { defineHarness } from '@tanstack/ai-harness'
import { runCli } from '@tanstack/ai-harness-cli'
import { openaiText } from '@tanstack/ai-openai'

const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
})

process.exitCode = await runCli(assistant)
```

## 2. Pick a mode

To use the harness yourself:

- No flags: line mode. Type a message and press Enter. With a `ui`, your own screen starts in its place (see [Run your own screen](#run-your-own-screen)).
- `-p "prompt"`: run one prompt, print the answer, and exit.
- `-p "prompt" --output ndjson`: print every AG-UI event as one JSON line.

To use the harness from another program:

- `--acp`: serve the harness as an ACP v2 agent over stdio, for editors. Needs `@tanstack/ai-acp` with harness support.
- `--mcp`: serve the harness as an MCP server over stdio, for Claude Code, Cursor, and other MCP clients. Needs `@tanstack/ai-mcp` with harness support. Add `--yes` to approve every tool call.
- `--serve`: serve the session protocol over HTTP on `127.0.0.1:8787`. Every request needs the bearer token. Pass `--token`, set `HARNESS_TOKEN`, or copy the token the CLI prints. With `@tanstack/ai-mcp` and its harness support, it also serves MCP at `/mcp`. One token, one user, every thread, so keep it on a network you trust (see [Serve over HTTP safely](#serve-over-http-safely)).

`--mcp` and `--serve` run only the commands in `expose.commands` of the harness. `--serve` also sets only the config keys in `expose.config`. Add the names to `expose` in `defineHarness`, for example `expose: { commands: ['connect:github'] }`. Line mode and `-p` run every command. See [Choose what clients can change](./connect#choose-what-clients-can-change).

Line mode reads one message or command per line and waits for each turn. It works the same in a terminal and with piped input. In a terminal, it also opens sign-in links in the browser.

## 3. Use it in CI

`-p` exits with a code your script can check:

| Code | Meaning |
|---|---|
| 0 | The turn finished. |
| 1 | The turn failed. |
| 2 | The turn waits for an approval. |
| 130 | The turn was cancelled. |

## Serve over HTTP safely

`--serve` is the quick way to put a harness on HTTP, so it makes the trust decisions for you: one bearer token, one user, every thread. Anyone with the token acts as that one user. They can open or drive any thread on the host, including the tools it runs (shell commands, file edits) on the machine the CLI runs on. Treat the token like a shell login.

- It binds to `127.0.0.1:8787`, reachable only from the same machine. Change the bind with `--host` and `--port`. `--host 0.0.0.0` puts it on the network, so do that only behind TLS and a proxy you trust.
- The CLI signs media links with the token, so rotating the token stops any link a turn already handed out.

Need per-user access, your own sign-in check, or a rule for which threads each user can open? Use [`createHarnessHandler`](./connect) instead. It requires `authorize` and `canAccess`, so no thread is open by accident.

## Use your own host and user

By default, the CLI builds its own host in memory and opens sessions with no user. Your server already has a durable host with leases, recovery, and stores, and each person has their own credentials. Give the CLI that host and the user:

```ts group=harness-cli-host
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { runCli } from '@tanstack/ai-harness-cli'
import { memoryLogStore, memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'

const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
})

const { runs, metadata, credentials } = memoryPersistence().stores
const host = createHarnessHost({
persistence: { stores: { log: memoryLogStore(), runs, metadata, credentials } },
})

process.exitCode = await runCli(assistant, { host, principal: { id: 'ada' } })
await host.close()
```

- You own the host, so the CLI does not close it. Close it yourself when `runCli` returns. Without `host`, the CLI builds a host and closes it.
- Give `host` or `persistence`, not both. With both, `runCli` writes `Give runCli a host or persistence, not both.` and returns code 1.
- Line mode, `-p`, and your own `ui` open the session as `principal`, so `/connect` saves keys for that user.
- `--serve` gives the same principal to each request with the token.
- `--acp`, `--mcp`, and `--dashboard` open their sessions without the principal.

## Commands in line mode

For the session:

- `/agents`: list the agents.
- `/agent <name> {"json":"input"}`: run an agent in the background. When it is done, a new turn starts with its result.
- `/config`: show the settings. `/config <key> <value>` changes one.
- `/connect <id>` and `/disconnect <id>`: sign in to a connector, or out.

For the running work:

- `/cancel`: cancel the running turn.
- `/status`: show what runs and what waits.
- `/exit`: quit.

Plugin commands (for example `/model` or `/todos`) show up in `/help`. When a turn stops for an approval or a plugin asks a question, type your answer. For yes-or-no questions, `y` approves and `n` refuses.

## Send files and save media

To send a file with a message, write `@` and the path of the file:

```text
what is wrong in @./bug.png
compare @"old logo.png" with @./new-logo.png
```

- The path is relative to the working folder. Put quotes around a path with spaces.
- `@path` works in line mode, with `-p`, with piped input, and in your own `ui`.
- A `@word` that is not a file stays in the text, for example `@types/node`.
- If the CLI does not know the type of the file, it does not send the message. To send the path as text, remove the `@`.

The CLI saves the media that a turn makes in a folder. The default folder is `./<harness name>-media` in the working folder, for example `./acme-assistant-media`. To use another folder, add `--media-dir <dir>`:

```bash
npx tsx cli.ts -p "Draw a logo for acme" --media-dir ./out
```

- Line mode prints `[image saved: <path>]` for each file.
- `-p` prints the same line on stderr, so stdout keeps only the answer.
- `-p --output ndjson` adds the saved `path` to the value of the `harness.media` event.

The CLI never replaces a file. If the name is taken, it adds `-1`, `-2`, and so on to the new name.

## Run your own screen

Line mode prints plain lines. For a full screen with your own layout, pass `ui` to `runCli`. It works with any TUI library, for example Ink, OpenTUI, or blessed.

`ui` gets a ready [session view](./custom-ui) and resolves when the user quits. This entry file starts an Ink screen:

```tsx ignore
import { render } from 'ink'
import { runCli } from '@tanstack/ai-harness-cli'
import { assistant } from './harness'
import { Screen } from './screen'

process.exitCode = await runCli(assistant, {
ui: async (view) => {
await render(<Screen view={view} />).waitUntilExit()
},
})
```

- `ui` runs only in an interactive terminal. Piped input uses line mode. `-p`, `--acp`, `--mcp`, `--serve`, and `--dashboard` do not use `ui`.
- When `ui` resolves, the CLI disposes the view and `runCli` returns.
- To write `Screen`, read [Build your own UI](./custom-ui).

For an Ink screen with approvals, questions, sign-ins, and child agents, copy [`examples/harness-cli/src/tui.tsx`](https://github.com/TanStack/ai/blob/main/examples/harness-cli/src/tui.tsx).

## What you have now

- One entry file that runs your harness as a terminal app, a script step, an editor agent, an MCP server, or an HTTP server.
- A CLI that uses your durable host and runs as your user.
- Your own terminal screen on the same session, with any TUI library.
- Files that you send with `@path`, and a folder with the media that your agents make.

Next: keep long turns alive through crashes with [durable sessions](./durable-sessions).
2 changes: 1 addition & 1 deletion docs/harness/connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,4 +356,4 @@ If a plugin picks the model of the harness, the harness has no adapter to read i
- Messages from the browser to a running agent.
- A WebSocket, and a harness you can call from `chat()`.

Next: send more than a prompt with [inputs](./inputs).
Next: run the same harness in a terminal with the [CLI](./cli).
2 changes: 2 additions & 0 deletions docs/harness/custom-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ await view.send('Summarize the README.')

Put the code of steps 1 and 2 in one `.tsx` file, then run it. The screen shows your prompt, then the answer while it streams, then the status `idle`. Press Ctrl+C to quit.

An Ink screen with approvals, questions, sign-ins, and child agents is in [`examples/harness-cli/src/tui.tsx`](https://github.com/TanStack/ai/blob/main/examples/harness-cli/src/tui.tsx). It runs from [`runCli({ ui })`](./cli#run-your-own-screen).

Each TanStack Store adapter reads the store the same way. Vue, Solid, Svelte, and Preact have `useSelector`. Angular has `injectSelector`. With no UI library, subscribe to the store:

```ts group=harness-custom-ui
Expand Down
19 changes: 19 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Choose an example based on your use case:
- **Want typed ticket routing?** → [Evaluate](#evaluate)
- **Want a blog desk with three agents?** → [Subagents](#subagents)
- **Want your own MCP server?** → [MCP server](#mcp-server)
- **Want an agent in the terminal?** → [Harness CLI](#harness-cli)
- **Want a full-stack TypeScript app?** → [TanStack Chat (ts-react-chat)](#tanstack-chat-ts-react-chat)
- **Need a live world stream?** → [World generation (ts-react-media)](#world-generation-ts-react-media)
- **Need a vanilla JS frontend?** → [Vanilla Chat](#vanilla-chat)
Expand Down Expand Up @@ -146,6 +147,24 @@ Open http://localhost:3100. Click Call the server.

---

### Harness CLI

A notes assistant in the terminal. A harness with two tools, run by `runCli`, with a small Ink screen on the session view.

**Path:** `examples/harness-cli`

**Getting Started:**

```bash
OPENAI_API_KEY=sk-... pnpm --filter harness-cli-example start
```

Ask it to save a note. Press `y` to approve the write.

📖 [Full Documentation](harness-cli/README.md)

---

### TanStack Chat (ts-react-chat)

A full-featured chat application built with the TanStack ecosystem.
Expand Down
4 changes: 4 additions & 0 deletions examples/harness-cli/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# The notes the assistant saves.
notes/
# Media the CLI saves (the default --media-dir).
*-media/
29 changes: 29 additions & 0 deletions examples/harness-cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Harness CLI example

A notes assistant in your terminal, built with `@tanstack/ai-harness` and `@tanstack/ai-harness-cli`. Ask it to write a note. It asks you before it saves the file.

## Run it

From the repo root:

1. `pnpm install`
2. `pnpm build:all` (the example uses the local packages)
3. `OPENAI_API_KEY=sk-... pnpm --filter harness-cli-example start`

Type `Save a note about the moon landing`. The screen shows the `save_note` call. Press `y`, and the note is in `examples/harness-cli/notes/`.

## What is in it

- `src/harness.ts`: the harness. One model, a `list_notes` tool, and a `save_note` tool that needs approval.
- `src/cli.ts`: `runCli(assistant, { ui: runTui })`. That is the whole entry file.
- `src/tui.tsx`: an Ink screen on the session view. It shows the messages, tool calls, child agents, sign-ins, approvals (`y` or `n`), and questions.

## Other modes

The same entry file has every mode of `runCli`. Run these from `examples/harness-cli`:

- `pnpm start -p "List my notes"` runs one prompt and prints the answer.
- `echo "List my notes" | pnpm start` uses line mode.
- `pnpm start --serve` serves the session protocol over HTTP with a bearer token.

Read [Run a harness in the terminal](../../docs/harness/cli.md) and [Build your own UI](../../docs/harness/custom-ui.md).
25 changes: 25 additions & 0 deletions examples/harness-cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "harness-cli-example",
"private": true,
"type": "module",
"description": "A notes assistant in the terminal, built with @tanstack/ai-harness and @tanstack/ai-harness-cli",
"scripts": {
"start": "tsx src/cli.ts",
"test:types": "tsc --noEmit"
},
"dependencies": {
"@tanstack/ai": "workspace:*",
"@tanstack/ai-harness": "workspace:*",
"@tanstack/ai-harness-cli": "workspace:*",
"@tanstack/ai-openai": "workspace:*",
"@tanstack/react-store": "^0.11.1",
"ink": "^7.1.1",
"react": "^19.2.3",
"zod": "^4.2.0"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"tsx": "^4.23.12"
}
}
7 changes: 7 additions & 0 deletions examples/harness-cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { runCli } from '@tanstack/ai-harness-cli'
import { assistant } from './harness'
import { runTui } from './tui'

// A terminal shows the Ink screen of ./tui. Piped input uses line mode, and
// `-p "prompt"` and `--serve` work as in every runCli app.
process.exitCode = await runCli(assistant, { ui: runTui })
39 changes: 39 additions & 0 deletions examples/harness-cli/src/harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { mkdir, readdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { toolDefinition } from '@tanstack/ai'
import { defineHarness } from '@tanstack/ai-harness'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'

// The notes go in a `notes` folder where you start the app.
const NOTES = join(process.cwd(), 'notes')

const listNotes = toolDefinition({
name: 'list_notes',
description: 'List the file names of the saved notes',
inputSchema: z.object({}),
}).server(async () => ({ notes: await readdir(NOTES).catch(() => []) }))

// A write asks the user first, so the screen shows an approval.
const saveNote = toolDefinition({
name: 'save_note',
description: 'Save a note as a Markdown file',
inputSchema: z.object({ title: z.string(), text: z.string() }),
needsApproval: true,
}).server(async ({ title, text }) => {
// The time keeps two notes with the same title apart.
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-') || 'note'
const file = `${Date.now()}-${slug}.md`
await mkdir(NOTES, { recursive: true })
await writeFile(join(NOTES, file), text)
return { saved: file }
})

export const assistant = defineHarness({
name: 'example/notes',
adapter: openaiText('gpt-6.1-sol'),
systemPrompts: [
'You help the user write short notes. Save a note only when the user asks.',
],
tools: [listNotes, saveNote],
})
Loading
Loading