- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- Build your first harnessUpdated
- Host optionsNew
- Send and show mediaUpdated
- Connect clientsUpdated
- Write a client in any languageNew
- Access and tenantsNew
- Run in the terminalUpdated
- Deploy a harnessUpdated
- Send inputs safelyUpdated
- Run a tool without a model
- Durable sessionsUpdated
- Run background work on workersNew
- List, rename, and fork sessionsUpdated
- Lifecycle and retentionNew
- Run side effects onceUpdated
- Wait for a personNew
- Build on the session logUpdated
- Control how a turn endsUpdated
- Store settings per threadUpdated
- Compact a long sessionUpdated
- Fork and reset a threadUpdated
- One log for a session treeUpdated
- Write a pluginUpdated
- Track usage and costUpdated
- Ask before risky tool callsUpdated
- Give your agent coding toolsUpdated
- Sandbox and extend the coding toolsUpdated
- Run the coding tools for many threadsNew
- Undo a turnUpdated
- Build a coding agentUpdated
- Delegate to coding agentsUpdated
- Switch between agent profilesUpdated
- Auth and connectorsUpdated
- Share a thread between peopleUpdated
- Connect model providersUpdated
- Build your own UIUpdated
- Code mode in a harnessUpdated
- Work until a goal is metUpdated
- Run agents from a harnessUpdated
- Hand off a conversationNew
- Add skillsUpdated
- Use MCP serversUpdated
- Give your agent a computerNew
- Use from any MCP clientUpdated
- Use a harness from an editor (ACP)Updated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Keep Keys on the ServerNew
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
Sandbox and extend the coding tools
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.
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.
- Install the sandbox packages:
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-docker- Start a sandbox, and give its backend to workspaceTools():
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) }),
],
})To give the agent the project folder on your machine, copy it into the container.
- 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. 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:
- Paths are POSIX paths in the sandbox, like /workspace/src/a.ts, also on a Windows host.
- The tools check where links lead with lstat and readlink in the sandbox. A sandbox without lstat cannot find links, so the tools refuse every path.
- 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.
- When the run stops, the file calls of a tool in the sandbox get the abort signal too, so they stop with the run.
Copy your project into the container#
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.
- The copy has every file that git does not ignore, with its uncommitted changes. Ignored files, like node_modules, stay on your machine, so install the dependencies in the container.
- The copy has the current branch with its history, so git status in the container shows your uncommitted changes. Staged changes show as unstaged.
- The folder must be the root of a git repository or a git worktree, and not a shallow clone. Otherwise, create() throws before it starts a container.
- Your machine and the image both need git. node:24 has git, and alpine does not.
- create() copies the folder once. Your folder does not get the changes that the agent makes in the container.
Write your own backend#
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 }. |
- exec resolves with the exit code of a command that fails. It does not throw.
- After options.timeoutMs, exec stops the command with every process that it started, and gives exit code 124. options.signal stops it too.
- options.env holds the variables to add. Give a command only these and the few variables that it needs to start. Do not give it the variables of your server.
- When realpath throws, the tool call fails. So a path with a link that the backend cannot resolve is never used.
- Each file method gets an optional last argument, { signal }. It is the abort signal of the tool call. Give it to your file calls, so a stopped run stops them too. readLines gets signal in its options.
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.
Run code after a read or a write#
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}`)
},
}),
],
}),
})- afterRead gets the absolute path. The text that it gives is added after the lines that read_file shows.
- afterWrite runs after write_file, edit_file, and patch.
- A hook runs while the tool holds the lock on that path. A hook that throws fails the tool call.
Add the plugin next to the tools: plugins: () => [workspaceTools({ root }), generatedFiles].
Refuse a write#
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,
}),
],
}),
})- beforeWrite gets the absolute path. It runs before write_file, edit_file, and patch change the file.
- A patch asks for each file that it changes before its first write. One refusal fails the whole patch, and no file changes.
- The hook runs before the tool takes the lock on the path.
Format each file after a write#
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}`,
},
],
})- command gets the absolute path, already quoted for the shell.
- 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.
- In a sandbox or on your own backend, give formatter() the same backend as workspaceTools().
- With workspaceTools({ workspace: { bind } }) before it, formatter() runs in the workspace of each thread. Leave out root and backend.
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.
One approval covers the write and its formatter#
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.
- When the formatter asks, the call asks one time for the write and the formatter, also in acceptEdits mode. Answer that approval with resolve.
- A reject stops the call. Nothing is written, and the formatter does not run.
- Answer always once, and the formatter runs without a question in this project. The answer is tied to a hash of the formatter config files, like prettier.config.js and package.json. When they change, the next write asks again.
- A rule that denies the formatter keeps the write. The tool result says that the formatter did not run.
- A rule can allow a formatter: { tool: 'formatter:prettier', decision: 'allow' }. Without permissions(), the formatter runs without a question.
Formatters shows what each mode does.
Keep tool output small#
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' }),
],
})- A result over 2000 lines or 50 KiB keeps its first lines and gets a note. Set other limits with maxLines and maxBytes.
- With dir, the full output goes to a file in dir, and the note gives the path. Files older than retentionDays (default 7) are removed.
- It cuts the result of every tool, MCP tools too, in the lead turn and in agent runs.
- An error stays an error. Images and other content parts do not change.
read_file and bash keep their output inside the default limits, so boundToolOutput() does not cut them again.
What you have now#
- The coding tools in a sandbox, with one option.
- Your project in a Docker container, with its uncommitted changes.
- 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.
Next: let the user undo the changes of a turn with snapshots.