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 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.
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.
npm i @tanstack/ai-sandbox @tanstack/ai-sandbox-dockerpnpm add @tanstack/ai-sandbox @tanstack/ai-sandbox-dockeryarn add @tanstack/ai-sandbox @tanstack/ai-sandbox-dockerbun add @tanstack/ai-sandbox @tanstack/ai-sandbox-dockerimport { 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) }),
],
})To give the agent the project folder on your machine, copy it into the container.
Every provider works. Get the handle from create(), or from resume() for a sandbox that runs already. See Providers. To create a sandbox for each thread, and resume it on another server, see Run the coding tools for many threads.
In a sandbox, some things are different:
A clone has only the commits that you pushed. To give the agent your project as it is now, copy its folder into the container. In step 2, replace the create({}) and git.clone() lines with this code:
import { defineWorkspace, localSource } from '@tanstack/ai-sandbox'
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
// Run this script in the root folder of your repository.
const sandbox = await dockerSandbox({ image: 'node:24' }).create({
workspace: defineWorkspace({ source: localSource(process.cwd()) }),
})The tools now work on your project in /workspace.
A WorkspaceBackend is where the tools read and write files and run commands. Write one for another place, for example a remote machine. Every path that the tools give it is absolute.
The backend must have:
| Method | What it does |
|---|---|
| readFile(path) | Gives the bytes of a file. Throws when there is no file. |
| writeFile(path, data) | Creates or replaces a file. Creates the missing parent folders. |
| stat(path) | Gives { type, size, mtimeMs }, or undefined when nothing is at path. It does not read the file. |
| realpath(path) | Gives the path with every link resolved, or undefined when nothing is at path. Throws when it cannot resolve a path. |
| readdir(path) | Gives the entries of a folder. A link has the type 'link'. |
| exec(command, options) | Runs a shell command and waits. Gives { exitCode, stdout, stderr }. |
The backend can also have:
| Member | What it does | Without it |
|---|---|---|
| shell | 'sh' or 'cmd'. Sets the path style and how arguments are quoted. | sh quotes, and the path style of this machine. |
| remove(path) | Removes one file. | patch cannot delete or move files. |
| readLines(path, { offset, limit }) | Gives one page of a file: { head, size, lines, total }. head is the first 8 KiB. | read_file reads the whole file for each page. |
For background jobs and for threads that move to another server:
| Member | What it does | Without it |
|---|---|---|
| spawn(command, options) | Starts a command in the background. Gives { wait, kill, output, jobId? }. jobId resolves with the id of the process, for killJob. | background: true gives an error. |
| killJob(jobId) | Stops a job that spawn started, also on an earlier process or machine. Resolves false when no such job runs. Rejects when it cannot check that the process is the job: then the log keeps the job. | A job that a crash left running is not stopped. |
| exec option onStart(jobId) | Gives the id of a foreground command when it starts, so a command that moves to the background can be stopped by a later host too. | A moved command has no id in the log. |
| binding() | Gives { kind: 'sandbox', provider, sandboxId } for a sandbox that another machine can resume. | The workspace is a folder on this host. |
| close({ reason }) | Releases the backend. 'closed': the session closed. 'moved': the session continues on another machine, so keep what it can resume. | Nothing to release. |
close must never destroy a sandbox that the backend did not create.
The fastest start is to wrap hostBackend. This backend logs each command before it runs:
import { hostBackend, workspaceTools } from '@tanstack/ai-harness/plugins/coding'
import type { WorkspaceBackend } from '@tanstack/ai-harness/plugins/coding'
const root = process.cwd()
const logged: WorkspaceBackend = {
...hostBackend,
exec: (command, options) => {
console.log(`$ ${command}`)
return hostBackend.exec(command, options)
},
}
export const loggedTools = workspaceTools({ root, backend: logged })Only hostBackend itself uses the @vscode/ripgrep binary. A wrapped backend uses rg on the PATH.
Another plugin can add code that runs after the tools read or write a file. Add a WorkspaceHooks item:
import { definePlugin } from '@tanstack/ai-harness'
import { WorkspaceHooks } from '@tanstack/ai-harness/plugins/coding'
export const generatedFiles = definePlugin({
name: 'acme/generated-files',
setup: () => ({
contribute: [
WorkspaceHooks.item({
afterRead: async (path) =>
path.endsWith('.gen.ts')
? 'This file is generated. Change its source, not this file.'
: undefined,
afterWrite: async (path) => {
console.log(`changed ${path}`)
},
}),
],
}),
})Add the plugin next to the tools: plugins: () => [workspaceTools({ root }), generatedFiles].
Add a beforeWrite hook to stop a write before it changes a file. Return { refuse }, and the tool call fails with that text:
import { definePlugin } from '@tanstack/ai-harness'
import { WorkspaceHooks } from '@tanstack/ai-harness/plugins/coding'
export const protectGenerated = definePlugin({
name: 'acme/protect-generated',
setup: () => ({
contribute: [
WorkspaceHooks.item({
beforeWrite: async (path) =>
path.endsWith('.gen.ts')
? { refuse: `${path} is generated. Change its source.` }
: undefined,
}),
],
}),
})The model writes code that your formatter changes later, and the diff gets noisy. formatter() formats each file that the tools write:
import { defineHarness } from '@tanstack/ai-harness'
import { permissions } from '@tanstack/ai-harness/plugins'
import { formatter } from '@tanstack/ai-harness/plugins/coding'
import { openaiText } from '@tanstack/ai-openai'
export const formatted = defineHarness({
name: 'acme/formatted-coder',
adapter: openaiText('gpt-6.1-sol'),
plugins: () => [
permissions({ root }),
workspaceTools({ root }),
formatter({ root }),
],
})At the first write of a session, formatter() looks at the files in root. For each file, the first formatter that the project uses for that file type runs.
For JavaScript, TypeScript, JSON, CSS, and other web files:
| Formatter | Runs when root has |
|---|---|
| prettier | .prettierrc*, prettier.config.*, or a prettier field in package.json |
| biome | biome.json or biome.jsonc |
| oxfmt | .oxfmtrc*, or oxfmt in the devDependencies |
These three run with npx --no-install, so the project must have them installed.
For other languages:
| Formatter | Files | Runs when root has |
|---|---|---|
| ruff | .py, .pyi | ruff.toml, .ruff.toml, or [tool.ruff] in pyproject.toml |
| gofmt | .go | go.mod |
| rustfmt | .rs | Cargo.toml |
Add your own formatter. Your formatters come before the built-in ones:
export const withToml = formatter({
root,
formatters: [
{
name: 'taplo',
extensions: ['.toml'],
command: (file) => `taplo fmt ${file}`,
},
],
})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. A formatter that changes the file sends FormatterRewrote with { path, formatter }:
import { FormatFailed } from '@tanstack/ai-harness/plugins/coding'
export const formatAlerts = definePlugin({
name: 'acme/format-alerts',
setup: (ctx) => {
ctx.on(FormatFailed, (failure) =>
console.warn(`${failure.path}: ${failure.message}`),
)
},
})Clients get the same events as a harness.plugin.event with the name tanstack/formatter:failed or tanstack/formatter:rewrote.
A formatter runs code from the project, like prettier.config.js. The model can change that code with an edit. So with permissions(), the rules decide the formatter as a part of the call that writes. The rule names the run formatter:prettier, formatter:biome, and so on.
The rules decide before the call runs, and the formatter runs after the write. The call never stops in the middle to ask.
Formatters shows what each mode does.
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:
import { boundToolOutput } from '@tanstack/ai-harness/plugins/node'
export const bounded = defineHarness({
name: 'acme/bounded-coder',
adapter: openaiText('gpt-6.1-sol'),
plugins: () => [
workspaceTools({ root }),
boundToolOutput({ dir: '.agent/tool-output' }),
],
})read_file and bash keep their output inside the default limits, so boundToolOutput() does not cut them again.
Next: let the user undo the changes of a turn with snapshots.