Your server runs coding agents for many users. Each thread must work in its own folder or sandbox. A command must not see the API keys of your server. Two agents in one folder must not lose each other's edits. And a thread must continue when another server takes it.
workspace: { bind } gives each thread its own workspace. The session log keeps where that workspace is, so the next server finds it again.
Pass workspace to workspaceTools() in place of root. bind gets the run, and gives a backend with its root:
import { mkdir } from 'node:fs/promises'
import { defineHarness } from '@tanstack/ai-harness'
import { permissions } from '@tanstack/ai-harness/plugins'
import {
hostBackend,
workspaceRoot,
workspaceTools,
} from '@tanstack/ai-harness/plugins/coding'
import { openaiText } from '@tanstack/ai-openai'
export const coder = defineHarness({
name: 'acme/coder',
adapter: openaiText('gpt-6.1-sol'),
plugins: () => [
permissions(),
workspaceTools({
workspace: {
bind: async ({ run }) => {
const root = workspaceRoot('/srv/workspaces', run.tenantId ?? 'default', run.threadId)
await mkdir(root, { recursive: true })
return { ...hostBackend, root }
},
},
}),
],
})A folder on the server is not isolation: bash runs as the user of your server (see Keep server secrets out of commands). When many users share the server, give each thread a sandbox. Create it in bind, and give the backend owned: true. Then the thread owns the sandbox:
import { sandboxWorkspaceBackend } from '@tanstack/ai-sandbox/harness'
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
const docker = dockerSandbox({ image: 'node:22' })
export const sandboxed = workspaceTools({
workspace: {
bind: async () => ({
...sandboxWorkspaceBackend(await docker.create({}), { owned: true }),
root: '/workspace',
}),
},
})A command gets only a few variables of the server process:
Provider keys, DATABASE_URL, and the other variables of the server are not in the env of a command. The same rule applies to bash, rg, git, and the formatters. Git also runs without the hooks and the fsmonitor of the project.
With hostBackend, and with the local-process sandbox, this short env and the root are defense in depth. They are not isolation:
For users that you do not trust, use a sandbox for each thread.
To give commands a variable, add it to the env of the backend calls:
import type { WorkspaceBackend } from '@tanstack/ai-harness/plugins/coding'
const extra = { NPM_TOKEN: 'npm_read_only_token' }
const { spawn } = hostBackend
const withToken: WorkspaceBackend = {
...hostBackend,
exec: (command, options) =>
hostBackend.exec(command, { ...options, env: { ...options?.env, ...extra } }),
spawn:
spawn &&
((command, options) =>
spawn(command, { ...options, env: { ...options?.env, ...extra } })),
}
export const withTokenTools = workspaceTools({ root: '/srv/repo', backend: withToken })exec runs the foreground commands. spawn runs the background commands.
Two agents, or two sessions, can work in the same folder. No edit is lost:
With background: true, bash returns at once with a job id like bash-1. Two limits keep jobs small:
When a job ends, the model gets a note, and the wake turn runs as the user whose run started the job.
To show the jobs that run now, for example in a status bar, call session.jobs(). A view of host.observe has jobs() too:
import type { HarnessSession } from '@tanstack/ai-harness'
export function listJobs(session: HarnessSession) {
for (const job of session.jobs()) {
console.log(`${job.jobId}: ${job.label ?? 'job'}, started by ${job.user?.id ?? 'the agent'}`)
}
}Each entry has:
session.jobs() reads the log, so it also lists the jobs of other hosts. After a restart, it lists a job until the host notes that the job stopped.
Each job sends events. Listen for them in a plugin:
import { definePlugin } from '@tanstack/ai-harness'
import { JobEnded, JobOutput, JobStarted } from '@tanstack/ai-harness/plugins/coding'
export const jobLog = definePlugin({
name: 'acme/job-log',
setup: (ctx) => {
ctx.on(JobStarted, (job) => console.log(`${job.jobId} started: ${job.command}`))
ctx.on(JobOutput, (job) => process.stdout.write(job.output))
ctx.on(JobEnded, (job) => console.log(`${job.jobId} ended: ${job.exitCode}`))
},
})A client gets each event as a CUSTOM event with the name harness.plugin.event. Its value is { plugin, name, value }:
import { createHarnessClient } from '@tanstack/ai-harness/client'
import type { coder } from './harness'
const client = createHarnessClient<typeof coder>({
url: '/api/harness',
threadId: 'fix-login',
})
for await (const { event } of client.events()) {
if (event.type !== 'CUSTOM' || event.name !== 'harness.plugin.event') continue
const { value } = event
if (typeof value !== 'object' || value === null) continue
if (!('name' in value) || value.name !== 'tanstack/workspace-tools:job-output') continue
if ('value' in value) console.log(value.value)
}The coding plugins send these events:
| Name | When |
|---|---|
| tanstack/workspace-tools:job-started | A background job started. |
| tanstack/workspace-tools:job-output | New output of a job. At most one event each 250 ms, with at most the newest 64 KiB. |
| tanstack/workspace-tools:job-ended | A job ended, after its last output event. |
| tanstack/workspace-tools:stranded | The workspace of the thread is gone. See below. |
| tanstack/workspace-tools:file-changed | A file changed. The value has a unified diff of the change. See Show live diffs. |
| tanstack/snapshots:failed | A snapshot failed. The turn goes on. |
The formatter also sends tanstack/formatter:rewrote and tanstack/formatter:failed. See Format each file after a write.
One server owns a thread at a time. When it stops, another server takes the thread (see One host drives a thread). What happens to the workspace depends on where it is.
The log keeps the provider and the id of the sandbox. The next server calls reattach in place of bind. Resume the sandbox there:
export const movable = workspaceTools({
workspace: {
bind: async () => ({
...sandboxWorkspaceBackend(await docker.create({}), { owned: true }),
root: '/workspace',
}),
reattach: async ({ binding }) => {
if (binding.kind === 'host') return 'pinned'
if (binding.provider !== 'docker') return 'gone'
const handle = await docker.resume({ id: binding.sandboxId })
if (!handle) return 'gone'
return { ...sandboxWorkspaceBackend(handle, { owned: true }), root: binding.root }
},
},
})A folder on the disk of one server cannot move. Give each server a stable id:
import { createHarnessHost } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
const host = createHarnessHost({
persistence: memoryPersistence(),
ownership: { hostId: process.env.POD_NAME ?? 'web-1' },
})The server sends a heartbeat to the log store each ownership.renewEveryMs. When no heartbeat came for ownership.expiresAfterMs (default 30 seconds), its pins do not count. Then the next server that gets work for the thread calls reattach with the old binding. Give a new workspace there, for example a fresh clone, to move the thread. Keep a restart shorter than ttlMs, or raise ttlMs.
To move the threads of a server at once, unpin them with host.unpin. Its ownership must have expired first:
await host.unpin({ harness: coder, threadId: 'fix-login' })