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
10 changes: 10 additions & 0 deletions .changeset/ai-sandbox-harness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/ai-sandbox': minor
---

Add `@tanstack/ai-sandbox/harness` for `@tanstack/ai-harness`:

- `codingAgents({ sandbox, agents, workspace? })` is a harness plugin. The lead model gets one tool per coding agent (Claude Code, Codex, Grok Build, or any ACP agent). Each agent works in the sandbox, keeps its own session per thread (also after a restart), and starts read-only in the `plan` mode of `permissions()`. `/fresh [agent]` starts a new session. `workspace: 'shared'` (default) runs the agents of a thread one at a time in one sandbox. `'per-agent'` gives each agent its own sandbox.
- `sandboxWorkspaceBackend(sandbox)` runs the coding tools of `@tanstack/ai-harness/plugins/coding` (`workspaceTools()`, `snapshots()`, `formatter()`) in a sandbox.

`@tanstack/ai-harness` is an optional peer dependency.
13 changes: 10 additions & 3 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -1007,21 +1007,28 @@
"updatedAt": "2026-10-14"
},
{
"label": "Extend the coding tools",
"label": "Sandbox and extend the coding tools",
"to": "harness/coding-tools-backends",
"addedAt": "2026-10-13"
"addedAt": "2026-10-13",
"updatedAt": "2026-10-13"
},
{
"label": "Undo a turn",
"to": "harness/snapshots",
"addedAt": "2026-10-13"
"addedAt": "2026-10-13",
"updatedAt": "2026-10-13"
},
{
"label": "Build a coding agent",
"to": "harness/coding-agent",
"addedAt": "2026-10-13",
"updatedAt": "2026-10-14"
},
{
"label": "Delegate to coding agents",
"to": "harness/coding-agents",
"addedAt": "2026-10-13"
},
{
"label": "Switch between agent profiles",
"to": "harness/agents",
Expand Down
6 changes: 5 additions & 1 deletion docs/harness/coding-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,11 @@ export const noDeploys = definePlugin({

A trailing `*` matches every tool that starts with the text. The last matching rule wins. [Ask before risky tool calls](./permissions) explains the rules, the modes, and the saved answers.

The workspace tools run on your machine with your permissions. To run them on another machine, [write your own backend](./coding-tools-backends#write-your-own-backend).
The workspace tools run on your machine with your permissions. For code that you do not trust, [run the tools in a sandbox](./coding-tools-backends#run-the-tools-in-a-sandbox).

## Hand work to Claude Code or Codex

Your agent can also give tasks to coding agents you already use. [Delegate to coding agents](./coding-agents) shows how.

## What you have now

Expand Down
129 changes: 129 additions & 0 deletions docs/harness/coding-agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
---
title: Delegate to coding agents
id: harness-coding-agents
order: 11
description: "Let a harness hand coding work to Claude Code, Codex, Grok Build, or any ACP agent. Each one works in a sandbox and keeps its own session."
keywords:
- tanstack ai
- harness
- claude code
- codex
- grok build
- sandbox
- subagents
---

Your lead agent plans the work, but you want Claude Code or Codex to do the edits, because they are good at code and you already use them. `codingAgents` gives the lead model one tool per coding agent. Each agent works in a sandbox and keeps its own session between calls.

## 1. Add the plugin

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

react: @tanstack/ai-harness @tanstack/ai-sandbox @tanstack/ai-sandbox-local-process @tanstack/ai-claude-code @tanstack/ai-codex
vue: @tanstack/ai-harness @tanstack/ai-sandbox @tanstack/ai-sandbox-local-process @tanstack/ai-claude-code @tanstack/ai-codex
solid: @tanstack/ai-harness @tanstack/ai-sandbox @tanstack/ai-sandbox-local-process @tanstack/ai-claude-code @tanstack/ai-codex
svelte: @tanstack/ai-harness @tanstack/ai-sandbox @tanstack/ai-sandbox-local-process @tanstack/ai-claude-code @tanstack/ai-codex
preact: @tanstack/ai-harness @tanstack/ai-sandbox @tanstack/ai-sandbox-local-process @tanstack/ai-claude-code @tanstack/ai-codex
angular: @tanstack/ai-harness @tanstack/ai-sandbox @tanstack/ai-sandbox-local-process @tanstack/ai-claude-code @tanstack/ai-codex
octane: @tanstack/ai-harness @tanstack/ai-sandbox @tanstack/ai-sandbox-local-process @tanstack/ai-claude-code @tanstack/ai-codex
vanilla: @tanstack/ai-harness @tanstack/ai-sandbox @tanstack/ai-sandbox-local-process @tanstack/ai-claude-code @tanstack/ai-codex

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

```ts group=harness-coding-agents
import { defineHarness } from '@tanstack/ai-harness'
import { permissions } from '@tanstack/ai-harness/plugins'
import { claudeCodeText } from '@tanstack/ai-claude-code'
import { codexText } from '@tanstack/ai-codex'
import { openaiText } from '@tanstack/ai-openai'
import { defineSandbox, defineWorkspace, localSource } from '@tanstack/ai-sandbox'
import { codingAgents } from '@tanstack/ai-sandbox/harness'
import { localProcessSandbox } from '@tanstack/ai-sandbox-local-process'

const repo = '/path/to/your/repo'

export const lead = defineHarness({
name: 'acme/lead',
adapter: openaiText('gpt-6.1-sol'),
plugins: () => [
permissions(),
codingAgents({
sandbox: defineSandbox({
id: 'repo',
provider: localProcessSandbox({ dir: repo }),
workspace: defineWorkspace({ source: localSource(repo) }),
}),
agents: {
claude_code: {
adapter: claudeCodeText('claude-opus-4-8', { permissionMode: 'acceptEdits' }),
description: 'Larger changes, refactors, and reviews',
},
codex: {
adapter: codexText('gpt-5.3-codex', { sandboxMode: 'workspace-write' }),
description: 'Quick fixes and tests',
},
},
}),
],
})
```

`localProcessSandbox({ dir: repo })` works in your repo folder itself, so every thread and every agent shares that folder. Leave out `dir` to give each sandbox its own copy of the repo.

The lead model now has a `claude_code` tool and a `codex` tool. Each tool takes one `task`: the whole job, in words. The agent sees only that text and the files in its sandbox.

## 2. Ask for work

Open a session, and give the lead a task for both agents:

```ts group=harness-coding-agents
import { createHarnessHost } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'

const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(lead, { threadId: 'date-parser' })

const turn = await session.prompt(
'Have claude_code add a test for the date parser, then have codex fix what fails.',
)
console.log(turn.text)
```

The lead calls `claude_code` with a task, and gets back its answer. Then it calls `codex`. Each agent works in the same sandbox, one after the other.

## Sessions

Each agent keeps its own session per harness thread. The next task for `claude_code` resumes the same Claude Code session, so it still knows the files it read. The session ids live in the plugin state, so they also survive a restart of the host.

To start over, run `/fresh claude_code`, or `/fresh` for every agent.

## Workspaces

`workspace` chooses where the agents work:

| Value | Where each agent works | At the same time |
| --- | --- | --- |
| `'shared'` (default) | One sandbox per harness thread | One agent at a time |
| `'per-agent'` | A sandbox per agent | Yes |

Use `'shared'` when the agents build on each other's changes. Use `'per-agent'` when they work on separate copies and you merge the results.

## Plan mode

When the `permissions()` plugin is in [`plan` mode](./permissions#pick-a-mode), the agents start read-only:

- Claude Code gets `permissionMode: 'plan'`.
- Codex gets `sandboxMode: 'read-only'`.
- Any other agent gets its `planModelOptions`, for example `{ permissionMode: 'default' }` for an ACP agent that asks before each edit.
- An agent with no `planModelOptions` does not run in `plan` mode. Its tool call gets an error.

## Other agents and sandboxes

- `adapter` takes any coding-agent adapter: `grokBuildText` from `@tanstack/ai-grok-build`, or `acpCompatibleText` from `@tanstack/ai-acp` for any ACP agent.
- `sandbox` takes any sandbox provider. [Sandbox providers](../sandbox/providers) lists them.
- `modelOptions` on an agent is added to every call, for example a fixed `permissionMode`.

## What you have now

- A lead model that hands tasks to Claude Code and Codex.
- One sandbox per thread, and one saved session per agent.
67 changes: 63 additions & 4 deletions docs/harness/coding-tools-backends.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,76 @@
---
title: Extend the coding tools
title: Sandbox and extend the coding tools
id: harness-coding-tools-backends
order: 7
description: "Run the coding tools on your own backend, format each written file, and keep tool output small."
description: "Run the coding tools in a sandbox or on your own backend, format each written file, and keep tool output small."
keywords:
- tanstack ai
- harness
- coding agent
- workspace tools
- sandbox
- formatter
- workspace hooks
---

Your agent must work on files that are not on this machine. Or you want code to run after each write, like a formatter. The [coding tools](./coding-tools) send every read, write, and command through a backend, and they run hooks after each read and write. This page shows how to change both.
Your agent must work on files that are not on this machine, or on code that you do not trust. Or you want code to run after each write, like a formatter. The [coding tools](./coding-tools) send every read, write, and command through a backend, and they run hooks after each read and write. This page shows how to change both.

## Run the tools in a sandbox

By default, the tools run on your machine, with the rights of your user. For code that you do not trust, run them in a sandbox. `sandboxWorkspaceBackend()` sends every read, write, and command to the sandbox.

1. Install the sandbox packages:

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

react: @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
vue: @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
solid: @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
svelte: @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
preact: @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
angular: @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
octane: @tanstack/ai-sandbox @tanstack/ai-sandbox-docker
vanilla: @tanstack/ai-sandbox @tanstack/ai-sandbox-docker

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

2. Start a sandbox, and give its backend to `workspaceTools()`:

```ts group=harness-coding-tools-sandbox
import { defineHarness } from '@tanstack/ai-harness'
import { permissions } from '@tanstack/ai-harness/plugins'
import { workspaceTools } from '@tanstack/ai-harness/plugins/coding'
import { openaiText } from '@tanstack/ai-openai'
import { sandboxWorkspaceBackend } from '@tanstack/ai-sandbox/harness'
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'

const sandbox = await dockerSandbox({ image: 'node:22' }).create({})
// Without `dir`, the clone goes to the workspace root, /workspace.
await sandbox.git.clone({ url: 'https://github.com/acme/web-app.git' })

const root = '/workspace'

export const sandboxed = defineHarness({
name: 'acme/sandboxed-coder',
adapter: openaiText('gpt-6.1-sol'),
plugins: () => [
permissions({ root }),
workspaceTools({ root, backend: sandboxWorkspaceBackend(sandbox) }),
],
})
```

3. When you are done, stop the sandbox with `await sandbox.destroy()`.

Every provider works. Get the handle from `create()`, or from `resume()` for a sandbox that runs already. See [Providers](../sandbox/providers).

In a sandbox, some things are different:

- Paths are POSIX paths in the sandbox, like `/workspace/src/a.ts`, also on a Windows host.
- The tools do not check where links lead. The sandbox backend has no `realpath`.
- `background: true` needs a sandbox with `capabilities.backgroundProcesses`.
- A sandbox with `capabilities.killableProcesses` off cannot stop a command after the time limit. The call returns, and the command continues in the sandbox.
- `list_files` and `grep` use `rg` in the sandbox. The `@vscode/ripgrep` binary is a host file, so the sandbox does not use it.

## Write your own backend

Expand Down Expand Up @@ -152,7 +210,7 @@ export const withToml = formatter({
- `when(project)` limits a formatter to some projects. Without it, the formatter is used in every project.
- `builtins: false` turns the built-in formatters off.
- `timeoutMs` limits each run. The default is 20 seconds.
- On your own backend, give `formatter()` the same `backend` as `workspaceTools()`.
- In a sandbox or on your own backend, give `formatter()` the same `backend` as `workspaceTools()`.

A formatter that fails, stops at the time limit, or is not installed does not fail the tool call. The file stays as the tool wrote it, and the plugin sends `FormatFailed`:

Expand Down Expand Up @@ -208,6 +266,7 @@ export const bounded = defineHarness({

## What you have now

- The coding tools in a sandbox, with one option.
- A backend of your own for another place.
- Code that runs after each read and each write.
- Formatted files after each write, and tool results that fit the context.
Expand Down
4 changes: 2 additions & 2 deletions docs/harness/coding-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ terminal.close()
await host.close()
```

`@tanstack/ai-harness/plugins/coding` runs only in Node. The tools use the file system and the shell of the machine.
`@tanstack/ai-harness/plugins/coding` runs only in Node. The tools use the file system and the shell of the machine. For code that you do not trust, [run the tools in a sandbox](./coding-tools-backends#run-the-tools-in-a-sandbox).

## 3. Run it

Expand Down Expand Up @@ -301,4 +301,4 @@ await session.configure({ cwd: 'web-app' })
- A question before each edit, command, and web fetch.
- A working folder for each thread, and a question before a path outside `root`.

Next: run the same tools on your own backend, or format each file after a write. See [Extend the coding tools](./coding-tools-backends).
Next: run the same tools in a sandbox, or format each file after a write. See [Sandbox and extend the coding tools](./coding-tools-backends).
1 change: 1 addition & 0 deletions docs/harness/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ The same check runs in every agent run of the session:
- A question from a child waits in the same session. Answer it the same way.
- In `plan` mode, a child cannot change a file. Its call gets the tool error `This tool is not allowed in plan mode.`
- The `permissions` of an [agent profile](./agents) count only while that profile is the primary agent. They do not apply when the profile runs as a subagent.
- [Coding agents](./coding-agents#plan-mode) such as Claude Code start read-only in `plan` mode.

## Rules for your own tool

Expand Down
20 changes: 20 additions & 0 deletions docs/harness/snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,26 @@ await session.unrevert()

In your own UI, call `client.revert(messageId)` and `client.unrevert()`. The client needs `'undo'` in `expose.commands`. More about reverts: [Go back to an earlier message](./sessions#go-back-to-an-earlier-message).

## In a sandbox

When the [coding tools run in a sandbox](./coding-tools-backends#run-the-tools-in-a-sandbox), give `snapshots()` the same `backend`:

```ts group=harness-snapshots-sandbox
import { snapshots } from '@tanstack/ai-harness/plugins/coding'
import { sandboxWorkspaceBackend } from '@tanstack/ai-sandbox/harness'
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'

const sandbox = await dockerSandbox({ image: 'node:22' }).create({})

export const sandboxHistory = snapshots({
root: '/workspace',
dataDir: '/tmp/snapshots',
backend: sandboxWorkspaceBackend(sandbox),
})
```

Git then runs in the sandbox, and `dataDir` is a path in the sandbox. The sandbox image needs `git`.

## Known limits

- One level of undo. A second `/undo` answers `Nothing to undo.` After a `/redo`, `/undo` works again.
Expand Down
9 changes: 9 additions & 0 deletions packages/ai-sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
"types": "./dist/esm/index.d.ts",
"import": "./dist/esm/index.js"
},
"./harness": {
"types": "./dist/esm/harness.d.ts",
"import": "./dist/esm/harness.js"
},
"./ngrok": {
"types": "./dist/esm/ngrok.d.ts",
"import": "./dist/esm/ngrok.js"
Expand Down Expand Up @@ -84,10 +88,14 @@
"peerDependencies": {
"@ngrok/ngrok": "^1.0.0",
"@tanstack/ai": "workspace:^",
"@tanstack/ai-harness": "workspace:^",
"@tanstack/ai-persistence": "workspace:^",
"vitest": "^4.1.10"
},
"peerDependenciesMeta": {
"@tanstack/ai-harness": {
"optional": true
},
"@ngrok/ngrok": {
"optional": true
},
Expand All @@ -101,6 +109,7 @@
"devDependencies": {
"@ngrok/ngrok": "^1.7.0",
"@tanstack/ai": "workspace:*",
"@tanstack/ai-harness": "workspace:*",
"@tanstack/ai-persistence": "workspace:*",
"@vitest/coverage-v8": "4.1.10",
"vitest": "^4.1.10"
Expand Down
Loading
Loading