Skip to content
Open
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
7 changes: 4 additions & 3 deletions .changeset/ai-harness-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
'@tanstack/ai-harness': minor
---

Add first-party plugins in `@tanstack/ai-harness/plugins` (a Node entry):
Add first-party plugins. `@tanstack/ai-harness/plugins` loads no Node module. The plugins that read files are in the Node entry `@tanstack/ai-harness/plugins/node`.

- `permissions()`: rules for tools, paths, and commands, the `default`, `plan`, `acceptEdits`, and `bypass` modes, questions answered with once, always, or reject, saved "always" rules with `/permissions` and `/permissions forget <n>`, and `listSavedPermissions` / `deleteSavedPermission` for server code. The rules apply in subagent runs too.
- `agents()`: agent profiles with their own prompt, tools, model, rules, and step limit, in code or in Markdown files, `/agent` to switch the primary agent, and the built-in `build`, `plan`, `general`, and `explore` agents.
- `question()`, `todos()`, `title()`, `modelPicker()`, `projectInstructions()`, `fileCommands()`, `compact()`, `usage()` (with your own prices for calls that have no provider cost), and `boundToolOutput()`.
- `agents()` (in `plugins/node`): agent profiles with their own prompt, tools, model, rules, and step limit, in code or in Markdown files, `/agent` to switch the primary agent, and the built-in `build`, `plan`, `general`, and `explore` agents.
- `question()`, `todos()`, `title()`, `modelPicker()`, `compact()`, and `usage()` (with your own prices for calls that have no provider cost).
- `projectInstructions()`, `fileCommands()`, and `boundToolOutput()`, in `plugins/node`.

`PermissionRules`, `PermissionResources`, `PermissionDecisionCapability`, and `decidePermission` are also exported from the package root. They load no Node module, so an edge-safe plugin can read the permission rules.
6 changes: 3 additions & 3 deletions .changeset/ai-harness-transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

Serve a harness session to clients.

- `createHarnessHandler()` serves a session over HTTP. `POST run` is standard AG-UI, so `useChat` with `fetchServerSentEvents` works on a harness, with approvals, client tools, and a reload that joins the running turn. `POST control`, `GET events`, `GET transcript`, `GET agent-transcript` (the messages of a child agent run), `GET snapshot`, `GET describe`, `sessions`, and the media routes cover the rest.
- `handleHarnessSocket()` serves the same session over a WebSocket.
- `createHarnessHandler()` from `@tanstack/ai-harness/server` serves a session over HTTP. `POST run` is standard AG-UI, so `useChat` with `fetchServerSentEvents` works on a harness, with approvals, client tools, and a reload that joins the running turn. `POST control`, `GET events`, `GET transcript`, `GET agent-transcript` (the messages of a child agent run), `GET snapshot`, `GET describe`, `sessions`, and the media routes cover the rest.
- `handleHarnessSocket()`, from the same entry, serves the same session over a WebSocket.
- `expose` on `defineHarness()` names the agents, settings, config keys, and commands that a client can use. Any other input of those kinds gets `rejected` with `not_exposed`.
- `createHarnessClient()` from `@tanstack/ai-harness/client` is a typed, browser-safe client. It sends inputs, reads the session, and streams events that resume from a cursor.
- `oauthConnector()` and the OAuth helpers (`loopbackLogin`, `deviceLogin`, `exchangeCode`, `refreshCredential`, and more) keep a credential per sender. `token({ wait: true })` stops the turn with an `auth_required` interrupt until the user signs in.
- `oauthConnector()` and the OAuth helpers from `@tanstack/ai-harness/oauth` (`loopbackLogin`, `deviceLogin`, `exchangeCode`, `refreshCredential`, and more) keep a credential per sender. `token({ wait: true })` stops the turn with an `auth_required` interrupt until the user signs in.
2 changes: 1 addition & 1 deletion .changeset/harness-acp-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
'@tanstack/ai-acp': minor
---

`@tanstack/ai-acp/agent` adds `createAcpAgent` and `serveAcp`, which serve a harness as an ACP v2 agent (experimental, like the draft protocol). Tool approvals become permission requests, cancel cancels the running turn, and image, audio, and embedded file blocks go to the media store of the session. `session/list` lists the sessions of the harness from the session index of the host. `@tanstack/ai-acp` now uses `@agentclientprotocol/sdk` 1.5. The existing ACP client adapters keep working.
`@tanstack/ai-acp/agent` adds `createAcpAgent` and `serveAcp`, which serve a harness as an ACP v2 agent (experimental, like the draft protocol). Tool approvals become permission requests, cancel cancels the running turn, and image, audio, and embedded file blocks go to the media store of the session. `session/list` lists the sessions of the harness from the session index of the host. `@tanstack/ai-acp` now uses `@agentclientprotocol/sdk` 1.8, pinned to `~1.8.0` because the agent uses its experimental v2 entry point. The existing ACP client adapters keep working.
2 changes: 1 addition & 1 deletion .changeset/harness-cli-handler-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
'@tanstack/ai-harness-cli': patch
---

`--serve` passes `canAccess` and `mediaSecret` to the harness handler, which needs both.
`--serve` passes `canAccess` and `media: { secret }` to the harness handler, which needs both.
2 changes: 1 addition & 1 deletion .changeset/harness-secure-defaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
Make the transport defaults safe for a server with many instances.

- `createHarnessHandler` and `handleHarnessSocket` need `canAccess`. To let every principal use every thread, pass `canAccess: () => true`.
- On a durable host (`stores.log`), `createHarnessHandler` throws without `mediaSecret`, so a media URL that one instance signs works on every instance.
- On a durable host (`stores.log`), `createHarnessHandler` throws without `media: { secret }`, so a media URL that one instance signs works on every instance.
- `oauthConnector` needs `login`. Use `'device'` on a server, and `'loopback'` only for a harness on the user's own machine, such as a CLI.
4 changes: 2 additions & 2 deletions .changeset/harness-tool-ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

Run a harness tool with no model turn, add host records from a client, and add a per-run system preamble.

- `session.tool(name, args?, options?)` runs one harness tool with no model
- `session.tool({ name, args?, meta?, inputId?, principal? })` runs one harness tool with no model
turn, for example from a schedule or a webhook. The call goes through the
same middleware, input check, and `permissions()` rules as a model call, and
the call and its result join the transcript. A tool that asks for approval
Expand All @@ -15,7 +15,7 @@ Run a harness tool with no model turn, add host records from a client, and add a
event. `args` is typed from the input schema of a harness tool.
- `expose.tools` lists the harness tools a client may run with a
`{ op: 'tool', name, args?, meta? }` input, or with the new
`client.tool(name, args?, options?)`. The list is typed from the harness
`client.tool({ name, args?, meta?, inputId? })`. The list is typed from the harness
`tools`, and `defineHarness` throws for a name that is not in `tools`. Any
other tool is refused with `not_exposed`. The capabilities document marks
each tool with `exposed`.
Expand Down
1 change: 1 addition & 0 deletions .changeset/subagent-context-extend.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ A host can add fields to the `ctx` an agent's `run` gets.

- `subagents.binding.extend(ctx)` returns the fields to add. A child's `ctx.chat({ subagents })` passes it down, so the fields reach nested children too.
- `SubagentContextExtension` is an empty interface that `SubagentRunContext` includes. A package augments it to type its fields, for example `ctx.harness` from `@tanstack/ai-harness`.
- `registerSubagentContextDefaults` in `@tanstack/ai/adapter-internals` adds default fields to the `ctx` of every agent run, also in a plain `chat()`.
3 changes: 2 additions & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -1010,7 +1010,8 @@
{
"label": "Share one log between sessions",
"to": "harness/shared-logs",
"addedAt": "2026-10-13"
"addedAt": "2026-10-13",
"updatedAt": "2026-10-16"
},
{
"label": "Write a plugin",
Expand Down
11 changes: 6 additions & 5 deletions docs/harness/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import { readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { toolDefinition } from '@tanstack/ai'
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { agents, permissions } from '@tanstack/ai-harness/plugins'
import { permissions } from '@tanstack/ai-harness/plugins'
import { agents } from '@tanstack/ai-harness/plugins/node'
import { anthropicText } from '@tanstack/ai-anthropic'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { z } from 'zod'
Expand Down Expand Up @@ -63,18 +64,18 @@ const coder = defineHarness({
const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(coder, { threadId: 'dark-mode' })

await session.command('agent', 'plan')
await session.command({ name: 'agent', input: 'plan' })
const plan = await session.send('How do I add a dark mode?')
console.log(plan.text)

await session.command('agent', 'build')
await session.command({ name: 'agent', input: 'build' })
await session.send('Do step 1.')
```

1. The `plan` agent reads the code and gives numbered steps. It has no tool that changes a file.
2. The `build` agent changes the files, after your answer to each [permission question](./permissions).

- `session.command('agent')` with no input shows the current agent and the list: `Agent: build. Agents: build, plan.`
- `session.command({ name: 'agent' })` with no input shows the current agent and the list: `Agent: build. Agents: build, plan.`
- A switch applies at the next turn.

The same switch is the `agent` setting. `session.describe().config` lists its options for a menu:
Expand Down Expand Up @@ -115,7 +116,7 @@ Give `agents()` a profile. This one reviews the changes with a stronger model:

```ts group=harness-agents
import type { AnyTextAdapter } from '@tanstack/ai'
import type { AgentProfile } from '@tanstack/ai-harness/plugins'
import type { AgentProfile } from '@tanstack/ai-harness/plugins/node'

const review: AgentProfile = {
name: 'review',
Expand Down
2 changes: 1 addition & 1 deletion docs/harness/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ view.on('signIn', (signIn) => {
console.log(`Connect ${signIn.connector} to continue the turn.`)
})

export const connectGithub = () => view.command('connect:github')
export const connectGithub = () => view.command({ name: 'connect:github' })
```

After the sign-in, the turn finishes its work with no new message.
Expand Down
9 changes: 4 additions & 5 deletions docs/harness/coding-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ keywords:
- undo
---

You want your own coding agent in the terminal. It reads and edits files, and runs commands after you approve them. When a turn goes wrong, you undo it. The first-party plugins in `@tanstack/ai-harness/plugins` and `@tanstack/ai-harness/plugins/coding` give you those parts. You pick the model and the rules.
You want your own coding agent in the terminal. It reads and edits files, and runs commands after you approve them. When a turn goes wrong, you undo it. The first-party plugins in `@tanstack/ai-harness/plugins`, `@tanstack/ai-harness/plugins/node`, and `@tanstack/ai-harness/plugins/coding` give you those parts. You pick the model and the rules.

## 1. Define the agent

Expand All @@ -28,14 +28,13 @@ import {
} from '@tanstack/ai-harness'
import {
compact,
fileCommands,
modelPicker,
permissions,
projectInstructions,
title,
todos,
usage,
} from '@tanstack/ai-harness/plugins'
import { fileCommands, projectInstructions } from '@tanstack/ai-harness/plugins/node'
import { snapshots, workspaceTools } from '@tanstack/ai-harness/plugins/coding'
import { openaiText } from '@tanstack/ai-openai'
import { memoryPersistence } from '@tanstack/ai-persistence'
Expand Down Expand Up @@ -92,7 +91,7 @@ while (line !== 'exit') {
if (line.startsWith('/')) {
// `/undo`, `/model fast`, `/mode plan`, and the other plugin commands.
const [name = '', ...args] = line.slice(1).split(' ')
console.log(await session.command(name, args.join(' ') || undefined))
console.log(await session.command({ name, input: args.join(' ') || undefined }))
} else {
const turn = session.send(line)
turn.onEvent('TEXT_MESSAGE_CONTENT', ({ event }) => {
Expand All @@ -112,7 +111,7 @@ terminal.close()
await host.close()
```

Both plugin entries run only in Node. The coding tools use the file system and the shell of the machine. A line that starts with `/` runs a command of a plugin. Any other line is a prompt.
The `plugins/node` and `plugins/coding` entries run only in Node. The coding tools use the file system and the shell of the machine. A line that starts with `/` runs a command of a plugin. Any other line is a prompt.

## 2. Run it

Expand Down
2 changes: 1 addition & 1 deletion docs/harness/coding-tools-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ A formatter runs code from the project, like `prettier.config.js`. The model can
One tool result with a full log or a big JSON file fills the context of the model. `boundToolOutput()` cuts every tool result that is too long:

```ts group=harness-coding-tools-backends
import { boundToolOutput } from '@tanstack/ai-harness/plugins'
import { boundToolOutput } from '@tanstack/ai-harness/plugins/node'

export const bounded = defineHarness({
name: 'acme/bounded-coder',
Expand Down
2 changes: 1 addition & 1 deletion docs/harness/coding-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ const repos = defineHarness({

const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(repos, { threadId: 'fix-login' })
await session.configure({ cwd: 'web-app' })
await session.configure({ settings: { cwd: 'web-app' } })
```

- Relative paths start in `/srv/repos/web-app`, and `bash` runs there.
Expand Down
6 changes: 3 additions & 3 deletions docs/harness/compaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Set `durable: true`. Compaction then writes a `tanstack.compaction` record into

1. Create the compaction middleware with `durable: true`.
2. Add it to `middleware` in `defineHarness`.
3. Give the host `projectCompaction` in its `project` option.
3. Give the harness `projectCompaction` in its `durability.project` option.

```ts group=harness-compaction
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
Expand Down Expand Up @@ -50,12 +50,12 @@ const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
middleware: [compaction],
durability: { project: { version: 'v1', record: projectCompaction } },
})

const { runs, metadata } = memoryPersistence().stores
const host = createHarnessHost({
persistence: { stores: { log: memoryLogStore(), runs, metadata } },
project: { version: 'v1', record: projectCompaction },
})
```

Expand Down Expand Up @@ -95,7 +95,7 @@ Sometimes a summary does not apply:

## Fold your own records too

If your host already folds records of its own, call `projectCompaction` first:
If your harness already folds records of its own, call `projectCompaction` first:

```ts group=harness-compaction
import type { ProjectOptions } from '@tanstack/ai-harness'
Expand Down
16 changes: 9 additions & 7 deletions docs/harness/connect.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ Your harness runs on a server, but the people who use it are in a browser or ano
Mount one fetch handler on a route. `authorize` is required, so no endpoint is open by accident.

```ts group=harness-connect
import { defineHarness, createHarnessHandler, createHarnessHost } from '@tanstack/ai-harness'
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { createHarnessHandler } from '@tanstack/ai-harness/server'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'

Expand All @@ -42,7 +43,7 @@ export const handler = createHarnessHandler({

`canAccess` is required. It decides which threads each principal can use. To let every principal use every thread, pass `canAccess: () => true`.

On a durable host (`stores.log`), also set `mediaSecret`. It signs media URLs, so every instance must use the same value. Without a log store, the default is a random key, and signed URLs stop working after a restart.
On a durable host (`stores.log`), also set `media: { secret }`. The secret signs media URLs, so every instance must use the same value. Without a log store, the default is a random key, and signed URLs stop working after a restart.

The handler answers these paths under your route:

Expand Down Expand Up @@ -81,10 +82,10 @@ export const team = defineHarness({
| Field | What a client can do |
| --- | --- |
| `agents` | Run these agents, for example with `client.agents.<name>.start(input)`. |
| `settings` | Change these [thread settings](./thread-settings) with `client.configure(settings)`. |
| `settings` | Change these [thread settings](./thread-settings) with `client.configure({ settings })`. |
| `config` | Set these plugin config keys with `client.setConfig({ key, value })`. |
| `commands` | Run these commands with `client.command(name)`, or with `/name` in a [session view](./custom-ui) on a client. |
| `tools` | Run these harness tools with no model turn, with `client.tool(name, args)`. See [Run a tool without a model](./tool-runs). |
| `commands` | Run these commands with `client.command({ name })`, or with `/name` in a [session view](./custom-ui) on a client. |
| `tools` | Run these harness tools with no model turn, with `client.tool({ name, args })`. See [Run a tool without a model](./tool-runs). |
| `records` | Add records of these types to the thread with `client.append(records)`. See [Let a client add records](./session-log#let-a-client-add-records). |

- Any other input of these kinds gets `{ status: 'rejected', reason: 'not_exposed' }`.
Expand Down Expand Up @@ -127,7 +128,8 @@ A background agent runs for minutes, and the user wants to correct it from the p

```ts group=harness-connect-agents
import { defineAgent } from '@tanstack/ai'
import { createHarnessHandler, createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { createHarnessHandler } from '@tanstack/ai-harness/server'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'

Expand Down Expand Up @@ -309,7 +311,7 @@ The join has these rules:
For one connection that carries events and inputs, authorize the upgrade, then hand the socket to `handleHarnessSocket`:

```ts group=harness-connect
import { handleHarnessSocket } from '@tanstack/ai-harness'
import { handleHarnessSocket } from '@tanstack/ai-harness/server'
import type { WebSocketLike } from '@tanstack/ai'

export function onUpgrade(socket: WebSocketLike) {
Expand Down
Loading
Loading