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.
npm i @tanstack/ai-harness-clipnpm add @tanstack/ai-harness-cliyarn add @tanstack/ai-harness-clibun add @tanstack/ai-harness-cliimport { 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)To use the harness yourself:
To use the harness from another program:
--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.
Line mode reads one message or command per line. In a terminal, you can type while a turn runs:
With piped input, each line waits for the turn that the line before it started. A script runs in order. In a terminal, line mode also opens sign-in links in the browser.
-p exits with a code your script can check:
| Code | Meaning |
|---|---|
| 0 | The turn finished. |
| 1 | The turn failed. |
| 3 | The turn waits for a person: an approval, an answer, or a sign-in. |
| 130 | The turn was cancelled, or SIGINT (Ctrl+C) stopped the CLI. |
| 143 | SIGTERM cancelled the turn or stopped the CLI. |
EXIT from @tanstack/ai-harness-cli has these codes, for example EXIT.needsAction (3). -p exits 3 at the first wait of the turn, because nobody can answer in this mode. It writes what the turn waits for on stderr. On a durable host, the wait stays in the log, so a later run, or another server, can answer it.
The codes 130 and 143 apply in every mode. See Stop with Ctrl+C.
Ctrl+C works the same in every mode:
The CLI hears the signals on process. To send them yourself, for example in a test, pass a signals source. An EventEmitter works:
import { EventEmitter } from 'node:events'
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'),
})
const signals = new EventEmitter()
const exit = runCli(assistant, { argv: ['-p', 'Write a long story.'], signals })
// No turn runs yet, so this stops the CLI.
signals.emit('SIGTERM')
console.log(await exit) // 143--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.
Need per-user access, your own sign-in check, or a rule for which threads each user can open? Use createHarnessHandler on a host with access instead. The host reads the user of each request and checks each action, so no thread is open by accident.
By default, the CLI builds its own host in memory and opens sessions with no user. Your server already has a durable host with its own stores, and each person has their own credentials. Give the CLI that host and the user:
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { runCli } from '@tanstack/ai-harness-cli'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { memoryCredentialStore } from '@tanstack/ai/byok/server'
import { openaiText } from '@tanstack/ai-openai'
const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
})
const host = createHarnessHost({
persistence: memoryPersistence(),
credentials: memoryCredentialStore(),
})
process.exitCode = await runCli(assistant, { host, user: { id: 'ada' } })
await host.close()For the session:
For the running work:
Plugin commands (for example /model or /todos) show up in /help.
When a turn waits for you, each next line answers one wait, in order. The turn continues after the last one:
To send a file with a message, write @ and the path of the file:
what is wrong in @./bug.png
compare @"old logo.png" with @./new-logo.pngThe 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>:
npx tsx cli.ts -p "Draw a logo for acme" --media-dir ./outThe CLI never replaces a file. If the name is taken, it adds -1, -2, and so on to the new name.
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 and resolves when the user quits. This entry file starts an Ink screen:
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()
},
})For an Ink screen with approvals, questions, sign-ins, and child agents, copy examples/harness-cli/src/tui.tsx.
Next: keep long turns alive through crashes with durable sessions.