Your agent must read and change the files of a project, search them, and run commands like pnpm test. It must stay in that project, and it must ask before it changes a file. workspaceTools() gives the model these tools for one folder. permissions() asks the user before each risky call.
npm i @tanstack/ai @tanstack/ai-harness @tanstack/ai-persistence @tanstack/ai-openaipnpm add @tanstack/ai @tanstack/ai-harness @tanstack/ai-persistence @tanstack/ai-openaiyarn add @tanstack/ai @tanstack/ai-harness @tanstack/ai-persistence @tanstack/ai-openaibun add @tanstack/ai @tanstack/ai-harness @tanstack/ai-persistence @tanstack/ai-openaiTwo optional packages make the tools better:
npm i @vscode/ripgrep turndownpnpm add @vscode/ripgrep turndownyarn add @vscode/ripgrep turndownbun add @vscode/ripgrep turndownimport { randomUUID } from 'node:crypto'
import { createInterface } from 'node:readline/promises'
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import type { RunAgentResumeItem } from '@tanstack/ai'
import { permissions } from '@tanstack/ai-harness/plugins'
import { workspaceTools } from '@tanstack/ai-harness/plugins/coding'
import { openaiText } from '@tanstack/ai-openai'
import { memoryPersistence } from '@tanstack/ai-persistence'
const root = process.cwd()
const coder = defineHarness({
name: 'acme/coder',
adapter: openaiText('gpt-6.1-sol'),
plugins: () => [permissions({ root }), workspaceTools({ root })],
})
const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(coder, { threadId: 'main' })
const terminal = createInterface({
input: process.stdin,
output: process.stdout,
})
// Print the text of every turn, also of a turn that continues after an answer.
const stopPrinting = session.onEvent('TEXT_MESSAGE_CONTENT', ({ event }) => {
process.stdout.write(event.delta)
})
// A risky call stops the turn before it runs. Ask about each approval of
// the turn, then send all the answers in one resolve.
async function answerApprovals() {
let waits = session.snapshot().pendingWaits
while (waits.length > 0) {
const resume: Array<RunAgentResumeItem> = []
for (const wait of waits) {
const answer = await terminal.question(`\n${wait.message ?? wait.kind} (once/always/reject) `)
resume.push({ interruptId: wait.waitId, status: 'resolved', payload: answer })
}
const inputId = randomUUID()
await session.resolve({ resume, inputId })
await session.settled({ inputId })
waits = session.snapshot().pendingWaits
}
}
// Send each line as a prompt. Type `exit` to stop.
let line = await terminal.question('\n> ')
while (line !== 'exit') {
try {
await session.send(line)
await answerApprovals()
} catch (error) {
// A failed turn rejects. Show the error and keep the loop.
console.error(error)
}
line = await terminal.question('\n> ')
}
stopPrinting()
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. For code that you do not trust, run the tools in a sandbox.
The agent reads and searches without a question. For a full coding agent with undo, a todo list, and a model picker, see Build a coding agent.
These tools run without a question.
| Tool | What it does |
|---|---|
| read_file | Reads a text file as numbered lines, at most 2000 lines or 50 KiB a page. The model reads more with offset and limit. |
| list_files | Lists the files in a folder. An optional glob, like src/**/*.ts, filters them. |
| grep | Searches the file contents with a regular expression. Gives file:line: text. |
These tools ask first.
| Tool | What it does |
|---|---|
| write_file | Creates or replaces a file. |
| edit_file | Replaces text in a file. The old text must be in the file once, unless the model sets replaceAll. |
| patch | Applies a patch that adds, deletes, updates, or moves files. |
A model gets edit_file or patch, not both. Every model gets write_file. The editStyle option picks the edit tool:
edit_file keeps the line endings and the byte order mark of the file. It also finds text that is different only in Unicode form or in the spaces at the end of a line.
When a file changed after the model read it, for example by another agent, the three tools fail. The model reads the file again, then edits it.
To let the user undo the changes of a turn, add snapshots.
| Tool | What it does | Asks first |
|---|---|---|
| bash | Runs a shell command in the working folder. | Yes |
| webfetch | Reads a web page by its URL. | Yes |
| websearch | Searches the web. It is there only with a search provider. | No |
For bash:
With background: true, the call returns at once with a job id. When the job ends, the model gets a note with the output, and a new turn starts. That turn runs as the user whose run started the job.
On Windows, the default backend runs commands in cmd.exe. A program name like rg or gradlew comes from the PATH only, not from the working folder. So a cloned repo cannot put its own rg.cmd in place of rg. To run a script in the working folder, give its path: .\gradlew build.
For webfetch:
Change the web tools with the web option:
There is no built-in search engine. Give websearch a SearchProvider:
import type { SearchProvider } from '@tanstack/ai-harness/plugins/coding'
import { searchTheWeb } from './search'
const search: SearchProvider = {
search: (query, { limit, signal }) => searchTheWeb(query, { limit, signal }),
}
export const withSearch = workspaceTools({ root, web: { search } })The provider gives at most limit results. Each result is { title, url, snippet? }. The model reads a result with webfetch.
Each tool tells permissions() what kind of call it makes:
| Tools | default mode | plan mode |
|---|---|---|
| read_file, list_files, grep, websearch | Runs | Runs |
| write_file, edit_file, patch | Asks | Denied |
| bash | Asks | Denied |
| webfetch | Asks | Denied |
Let webfetch run without a question with one rule:
export const trustTheWeb = permissions({
root,
rules: [{ tool: 'webfetch', decision: 'allow' }],
})In plan mode, a call that asks is denied. This rule makes webfetch an allow, so webfetch runs in plan mode too.
Each call also tells permissions() the paths or the commands that it touches. So a rule with a resource can allow one folder or one command:
export const trustTests = permissions({
root,
rules: [
{ tool: 'edit_file', resource: 'src/**', decision: 'allow' },
{ tool: 'bash', resource: 'pnpm test*', decision: 'allow' },
],
})Permissions explains the rules, the modes, and the saved answers.
The tools refuse a path outside root. To let the user approve it, set outside: 'ask':
export const askOutside = workspaceTools({ root, outside: 'ask' })permissions() decides the outside path before the call runs. The path is a part of the one approval of the call, so the tool never stops in the middle of a call to ask. For a direct call, the turn stops, and you answer with resolve. In a code mode program, the program waits, and you answer with session.answer.
With a workspace for each thread, the root is not known before the call, so no approval can cover an outside path. Then only a rule that allows the path, or bypass mode, lets the call use it.
Each thread can work in its own folder in root. Set the cwd thread setting:
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { workspaceTools } from '@tanstack/ai-harness/plugins/coding'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { openaiText } from '@tanstack/ai-openai'
const repos = defineHarness({
name: 'acme/repos',
adapter: openaiText('gpt-6.1-sol'),
plugins: () => [workspaceTools({ root: '/srv/repos' })],
})
const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(repos, { threadId: 'fix-login' })
await session.updateThreadSettings({ settings: { cwd: 'web-app' } })Store settings per thread shows how a client sets cwd.
Next: run the same tools in a sandbox, or format each file after a write. See Sandbox and extend the coding tools. To give each thread its own folder on a server, see Run the coding tools for many threads.