Skip to content
Harness

Build a coding agent

You want your own coding agent in the terminal. It reads and edits files, and runs commands after you approve them. When a turn goes wrong, you undo it. The first-party plugins in @tanstack/ai-harness/plugins, @tanstack/ai-harness/plugins/node, and @tanstack/ai-harness/plugins/coding give you those parts. You pick the model and the rules.

1. Define the agent

ts
import { randomUUID } from 'node:crypto'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { createInterface } from 'node:readline/promises'
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import type { RunAgentResumeItem } from '@tanstack/ai'
import {
  compact,
  modelPicker,
  permissions,
  title,
  todos,
  usage,
} from '@tanstack/ai-harness/plugins'
import { fileCommands, projectInstructions } from '@tanstack/ai-harness/plugins/node'
import { snapshots, workspaceTools } from '@tanstack/ai-harness/plugins/coding'
import { openaiText } from '@tanstack/ai-openai'
import { memoryPersistence } from '@tanstack/ai-persistence'

const root = process.cwd()
const smart = openaiText('gpt-6.1-sol')
const fast = openaiText('gpt-6-luna')

const coder = defineHarness({
  name: 'acme/coder',
  adapter: smart,
  systemPrompts: ['You are a careful coding agent. Read before you edit.'],
  plugins: () => [
    permissions({ root }),
    workspaceTools({ root }),
    snapshots({ root, dataDir: join(homedir(), '.acme-coder', 'snapshots') }),
    todos(),
    modelPicker({ choices: { smart, fast }, default: 'smart' }),
    fileCommands({ dir: `${root}/.claude/commands` }),
    compact({ adapter: fast }),
    title({ adapter: fast }),
    usage(),
    // Keep this plugin last. See "Project instructions" below.
    projectInstructions({ 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:
// once, always, or reject. 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
  }
}

// Type `exit` to stop.
let line = await terminal.question('\n> ')
while (line !== 'exit') {
  if (line.startsWith('/')) {
    // `/undo`, `/model fast`, `/mode plan`, and the other plugin commands.
    const [name = '', ...args] = line.slice(1).split(' ')
    console.log(await session.runCommand({ name, input: args.join(' ') || undefined }))
  } else {
    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()

The plugins/node and plugins/coding entries run only in Node. The coding tools use the file system and the shell of the machine. A line that starts with / runs a command of a plugin. Any other line is a prompt.

2. Run it

  1. Run npx tsx coder.ts in your project.
  2. Ask for a change. The agent reads files freely, and asks before it writes a file or runs a command.
  3. Answer once to allow the call, always to allow it from now on, or reject to refuse it.
  4. If you do not like the result, run /undo. The files go back to how they were before the turn.

What each plugin adds

PluginAdds
permissions({ root })Asks before risky tool calls. /mode switches between default, plan (read-only), acceptEdits (edits run without asking), and bypass. See Ask before risky tool calls.
workspaceTools({ root })Tools that read, search, and change the files in root, run commands, and read web pages. A path outside root is refused, or asks first with outside: 'ask'. See Give your agent coding tools.
snapshots({ root, dataDir })/undo puts the files back as they were before the last turn, and /redo brings them back. It needs git. See Undo a turn.
todos()A todo_write tool the model uses for multi-step work, and /todos.
modelPicker({ choices })/model <name> switches the model at the next turn.
fileCommands({ dir })Each .md file becomes a slash command. $ARGUMENTS is replaced by what you type after it.
compact({ adapter })/compact replaces a long conversation with a summary.
title({ adapter })Names the session from its first message.
usage()/usage shows the tokens and the cost of the session, with a line for each model. See Track usage and cost.
projectInstructions({ root })Adds AGENTS.md, CLAUDE.md, and facts about the environment to the system prompt.

To compact on its own before the context limit, see Compact a harness session.

Project instructions

Your repository has an AGENTS.md or a CLAUDE.md with its rules. projectInstructions({ root }) gives them to the model:

  • It reads AGENTS.md and CLAUDE.md in each folder from the repository root (the folder with .git) down to root. Set other file names with files.
  • global adds files that come first, for example ['~/.config/AGENTS.md'].
  • When read_file reads a file in a subfolder, the model also gets the instruction files of the folders on the way, once each.
  • An environment block gives the date, the platform, the working folder, and the git repository and branch. env: false turns it off.
  • When you change an instruction file, the next turn sends the new text as a note.

Put projectInstructions() last in the plugin list. Then a changed file goes into the conversation as a mid-conversation change, and the prompt cache stays. When the prompt of another plugin comes after it, a change builds the system prompt again, and the prompt cache starts again.

Session titles and cost

title({ adapter }) and usage() write to the session index, one entry per thread:

  • title names the session from its first message. The turn does not wait for it.
  • When usage knows the cost of the session, it writes the cost. A cost that the provider reports is used as it is.
  • To price the calls of a provider that reports no cost, pass usage({ model }). Track usage and cost shows how.

A sidebar of sessions reads the title and the cost from the index. The usage() state also has contextTokens, the size of the lead model's context at its latest call, for a context meter.

Add more

  • agents({ adapter }): named agents with their own prompt, tools, and model. /agent plan switches to a read-only agent. See Switch between agent profiles.
  • question(): a question tool. The model asks the user to pick an option, and waits for the answer.
  • formatter({ root }): formats each file after a write. See Format each file after a write.

Add your own rules

Tool plugins add permission rules to the PermissionRules extension point. Add your own for any tool:

ts
import { PermissionRules } from '@tanstack/ai-harness/plugins'
import { definePlugin } from '@tanstack/ai-harness'

export const noDeploys = definePlugin({
  name: 'acme/no-deploys',
  setup: () => ({
    contribute: [PermissionRules.item({ tool: 'deploy_*', decision: 'deny' })],
  }),
})

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

The workspace tools run on your machine with your permissions. For code that you do not trust, 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 shows how.

What you have now

  • A terminal coding agent with file tools, approvals, modes, a todo list, and a model picker.
  • /undo and /redo for the file changes of a turn.
  • The rules of your repository in every turn. A change to them keeps the prompt cache.
  • A title and a cost for each session.

Next: let the user undo more than one turn with Undo a turn.