Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion docs/harness/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ process.exitCode = await runCli(assistant, {
- When `ui` resolves, the CLI disposes the view and `runCli` returns.
- To write `Screen`, read [Build your own UI](./custom-ui).

For a full 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).
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

Expand Down
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],
})
166 changes: 166 additions & 0 deletions examples/harness-cli/src/tui.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { useState } from 'react'
import { Box, Static, Text, render, useApp, useInput } from 'ink'
import { useSelector } from '@tanstack/react-store'
import type {
SessionView,
ViewMessage,
ViewPart,
} from '@tanstack/ai-harness/view'

function Part({ part }: { part: ViewPart }) {
switch (part.type) {
case 'text':
return <Text>{part.text}</Text>
case 'reasoning':
return <Text dimColor>{part.text}</Text>
case 'tool-call':
return (
<Text color={part.status === 'failed' ? 'red' : 'yellow'}>
{`* ${part.name} (${part.status})`}
</Text>
)
case 'agent':
return (
<Box flexDirection="column" marginLeft={2}>
<Text color="cyan">{`${part.name} (${part.status})`}</Text>
{part.parts.map((child, index) => (
<Part key={index} part={child} />
))}
</Box>
)
case 'media':
return <Text color="magenta">{`[${part.kind}: ${part.name}]`}</Text>
}
}

function Message({ message }: { message: ViewMessage }) {
if (message.role === 'user') {
return <Text color="green">{`> ${message.text}`}</Text>
}
if (message.role === 'notice') {
return (
<Text color={message.kind === 'error' ? 'red' : 'gray'}>
{message.text}
</Text>
)
}
return (
<Box flexDirection="column">
{message.parts.map((part, index) => (
<Part key={index} part={part} />
))}
</Box>
)
}

/** The typed answer: JSON (a number, a boolean) unless the question wants text. */
function answerOf(text: string, schema: unknown): unknown {
const wantsText =
typeof schema !== 'object' ||
schema === null ||
Reflect.get(schema, 'type') === 'string'
if (wantsText) return text
try {
return JSON.parse(text)
} catch {
return text
}
}

/** The whole screen: the messages, then one line for what waits for you. */
function Screen({ view }: { view: SessionView }) {
const { exit } = useApp()
const state = useSelector(view.store)
const [line, setLine] = useState('')
// The turn goes on when every approval has an answer, so the screen asks
// them one by one.
const [answered, setAnswered] = useState<ReadonlyArray<string>>([])
const approval = state.approvals.find(({ id }) => !answered.includes(id))
const [question] = state.questions

useInput((input, key) => {
if (key.escape) {
void view.cancel()
return
}
// An approval takes one key: y or n.
if (approval) {
if (input !== 'y' && input !== 'n') return
if (input === 'y') approval.approve()
else approval.reject()
setAnswered((ids) => [...ids, approval.id])
return
}
if (key.return) {
const text = line.trim()
setLine('')
if (text === '/exit') exit()
else if (question) {
void question
.answer(answerOf(text, question.schema))
.then((receipt) => {
if (receipt.status === 'rejected') {
view.notice(`Answer again: ${receipt.reason ?? 'not accepted'}`)
}
})
}
// A `/command` runs the command. Text while a turn runs steers it.
else void view.send(text)
return
}
if (key.backspace || key.delete) {
setLine((value) => value.slice(0, -1))
return
}
// A paste can hold line breaks. The line stays one line.
if (!key.ctrl && !key.meta) {
setLine((value) => value + input.replace(/[\r\n]+/g, ' '))
}
})

// The turns before the last message you sent do not change, so Ink prints
// them once. Only the running turn redraws.
let lastSent = 0
state.messages.forEach((message, index) => {
if (message.role === 'user') lastSent = index
})
const done = state.messages.slice(0, lastSent)
const live = state.messages.slice(lastSent)

const typed = question?.secret ? '*'.repeat(line.length) : line
return (
<Box flexDirection="column">
<Static items={done}>
{(message) => <Message key={message.id} message={message} />}
</Static>
{live.map((message) => (
<Message key={message.id} message={message} />
))}
{state.signIns.map((signIn) => (
<Text key={signIn.connector} color="cyan">
{`Sign in to ${signIn.connector}: ${signIn.url ?? ''} ${signIn.userCode ?? ''}`}
</Text>
))}
{approval ? (
<Text color="yellow">
{`Run ${approval.tool} ${JSON.stringify(approval.args)}? (y/n)`}
</Text>
) : (
<Box flexDirection="column">
{question?.url ? (
<Text color="cyan">{`Open ${question.url}`}</Text>
) : null}
<Text>{`${question ? `? ${question.message} ` : '> '}${typed}`}</Text>
</Box>
)}
<Text dimColor>
{`${state.status}. Enter sends, Esc cancels, /exit quits.`}
</Text>
</Box>
)
}

/** The `ui` of runCli: show the screen until the user quits. */
export async function runTui(view: SessionView) {
await render(<Screen view={view} />).waitUntilExit()
}
14 changes: 14 additions & 0 deletions examples/harness-cli/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"],
"esModuleInterop": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
2 changes: 1 addition & 1 deletion examples/ts-react-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"@tanstack/react-router-devtools": "^1.158.4",
"@tanstack/react-router-ssr-query": "^1.158.4",
"@tanstack/react-start": "^1.168.60",
"@tanstack/react-store": "^0.8.0",
"@tanstack/react-store": "^0.11.1",
"@tanstack/router-plugin": "^1.168.42",
"@tanstack/store": "^0.11.1",
"dockerode": "^4.0.2",
Expand Down
Loading
Loading