Your agent has tools that change files or run commands. It must not do that without a yes from the user, and some files it must never touch. permissions() checks each tool call against rules. Each call runs, waits for the user, or does not run.
import { writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { toolDefinition } from '@tanstack/ai'
import { createHarnessHost, defineHarness } from '@tanstack/ai-harness'
import { permissions } from '@tanstack/ai-harness/plugins'
import { openaiText } from '@tanstack/ai-openai'
import { memoryPersistence } from '@tanstack/ai-persistence'
import { z } from 'zod'
const root = process.cwd()
const saveNote = toolDefinition({
name: 'save_note',
description: 'Write a note file in the project',
inputSchema: z.object({ path: z.string(), text: z.string() }),
}).server(async ({ path, text }) => {
await writeFile(resolve(root, path), text)
return `Saved ${path}.`
})
export const notes = defineHarness({
name: 'acme/notes',
adapter: openaiText('gpt-6.1-sol'),
tools: [saveNote],
plugins: () => [
permissions({
root,
rules: [{ tool: 'save_note', decision: 'ask', kind: 'edit' }],
}),
],
})
const host = createHarnessHost({ persistence: memoryPersistence() })
const session = await host.open(notes, { threadId: 'notes-1' })The rule makes each save_note call wait for the user.
The plugin decides each call before it runs. A call that asks opens one approval wait, and the turn stops with status requires_action. Answer every approval of the turn in one resolve:
await session.send('Save a note with the time of the meeting.')
// wait.message is, for example: Allow save_note {"path":"meeting.md","text":"..."}?
const waits = session.snapshot().pendingWaits
await session.resolve({
inputId: 'approve-1',
resume: waits.map((wait) => ({
interruptId: wait.waitId,
status: 'resolved',
payload: 'once',
})),
})
await session.settled({ inputId: 'approve-1' })The answer once lets the call run. The agent writes the file and continues. No process waits for the user while the turn is stopped, so the answer can come from any server, also after a restart.
The mode setting decides how much runs without a question:
| Mode | What happens |
|---|---|
| default | The rules apply as written. A call that asks waits for the user. |
| plan | Read-only. Edits, commands, and calls that ask do not run. The model gets a note to plan and to change no files. |
| acceptEdits | Edits run without a question. Commands and formatters still ask. |
| bypass | Every call runs, also a call that a rule denies. |
Switch the mode from server code:
await session.setPluginSetting({ key: 'mode', value: 'plan' })
// Or run the command. It returns 'Mode: plan.'
await session.runCommand({ name: 'mode', input: 'plan' })session.runCommand({ name: 'mode' }) with no input shows the current mode. In acceptEdits mode, an edit outside root or of a .env file still asks.
A mode change applies from the next turn. A turn that runs keeps the mode that it started with, so a switch to bypass does not change its later tool calls.
A client that can set mode can set bypass. Then every call runs with no question. So by default, a client cannot change the mode:
A client can set only the config keys in expose.config. It can run only the commands in expose.commands. This harness lets a client pick the model, but not the mode:
import { modelPicker } from '@tanstack/ai-harness/plugins'
export const team = defineHarness({
name: 'acme/team-notes',
adapter: openaiText('gpt-6.1-sol'),
tools: [saveNote],
plugins: () => [
permissions({ root }),
modelPicker({
choices: { sol: openaiText('gpt-6.1-sol'), pro: openaiText('gpt-6.1-sol-pro') },
}),
],
expose: { config: ['model'], commands: ['model'] },
})Add mode to expose.config or expose.commands only for clients that you trust. Each of these clients can turn the questions off.
Add your own rules with rules:
export const strict = permissions({
root,
rules: [
{ tool: 'read_file', resource: 'secrets/**', decision: 'deny' },
{ tool: 'edit_file', resource: 'src/**', decision: 'allow' },
{ tool: 'bash', resource: 'pnpm test*', decision: 'allow' },
{ tool: 'deploy_*', decision: 'deny' },
],
})A rule has these fields:
The plugin reads the rules in this order, and the last rule that matches a call wins:
Two more rules apply to each tool:
The plugin decides each call in the guard hook, onGuardToolCall. It runs after the input check and after every middleware that changes the arguments. So the rules see the arguments that the tool gets.
These paths ask, unless a rule with a resource allows them:
This rule lets the agent read .env.example without a question:
export const exampleEnv = permissions({
root,
rules: [{ tool: 'read_file', resource: '.env.example', decision: 'allow' }],
})workspaceTools() from @tanstack/ai-harness/plugins/coding adds these rules. Give your agent coding tools shows what each tool does.
| Tool | Decision | Kind |
|---|---|---|
| read_file, list_files, grep | allow | read |
| write_file, edit_file, patch | ask | edit |
| bash | ask | execute |
| webfetch | ask | none |
A formatter runs code from the project, for example prettier.config.js or a package in node_modules. The model can edit these files. So the rules decide each run of formatter(), like bash.
It adds one rule: { tool: 'formatter:*', decision: 'ask', kind: 'execute' }. Each run is named formatter: and the formatter name, for example formatter:prettier.
The rules decide the formatter as a part of the call that writes, before that call runs. The call gets one approval for the write and its formatter. The formatter runs after the write, so no question comes in the middle of a call.
| Mode | What happens |
|---|---|
| default | The write asks, and the approval also lets the formatter run. |
| acceptEdits | A write that a formatter formats asks, because the formatter asks. |
| bypass | The write and the formatter run without a question. |
| plan | Neither runs. No file changes in this mode. |
This rule lets prettier run without a question:
export const trustedFormatter = permissions({
root,
rules: [{ tool: 'formatter:prettier', decision: 'allow' }],
})The plugin decides every call of a model message before any of them runs. Each call that asks opens one approval wait of kind approval, and the turn stops. Send the answer as the payload of that wait in session.resolve. The turn continues when every wait of the turn has an answer.
| Answer | What happens |
|---|---|
| once or true | This call runs. |
| always | This call runs, and the plugin saves an allow rule for it. |
| reject or false | The call does not run. The model gets a tool error. |
Saved rules:
1. bash pnpm test
2. read_file .envA client can run the command only when expose.commands has permissions.
Server code can also read and delete the saved rules without a session. Pass the stores of your host and the same root that permissions() gets:
import { deleteSavedPermission, listSavedPermissions } from '@tanstack/ai-harness/plugins'
// The persistence that you give to createHarnessHost.
const persistence = memoryPersistence()
const saved = await listSavedPermissions(persistence.stores, root)
const [first] = saved
if (first) await deleteSavedPermission(persistence.stores, root, first)The same check runs in every agent run of the session:
A rule with a resource can match the calls of your tool only when the tool says what it touches. Add the tool to PermissionResources:
import { copyFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { toolDefinition } from '@tanstack/ai'
import { PermissionResources, PermissionRules, definePlugin } from '@tanstack/ai-harness'
import { z } from 'zod'
const root = process.cwd()
const copy = toolDefinition({
name: 'copy_file',
description: 'Copy a file in the project',
inputSchema: z.object({ from: z.string(), to: z.string() }),
}).server(async ({ from, to }) => {
await copyFile(resolve(root, from), resolve(root, to))
return `Copied ${from} to ${to}.`
})
/** A string field of the raw input. It throws when the field is missing. */
function field(input: unknown, key: string) {
const value =
typeof input === 'object' && input !== null ? Reflect.get(input, key) : undefined
if (typeof value !== 'string') throw new Error(`No ${key}.`)
return value
}
export const fileCopy = definePlugin({
name: 'acme/file-copy',
setup: () => ({
tools: [copy],
contribute: [
PermissionRules.item({ tool: 'copy_file', decision: 'ask', kind: 'edit' }),
PermissionResources.item({
copy_file: { paths: (input) => [field(input, 'from'), field(input, 'to')] },
}),
],
}),
})Now { tool: 'copy_file', resource: 'docs/**', decision: 'allow' } copies files in docs without a question.
Your plugin must know what permissions() decides for a tool before the model calls it. Read the answer with PermissionDecisionCapability:
import { PermissionDecisionCapability, definePlugin } from '@tanstack/ai-harness'
export const planTools = definePlugin({
name: 'acme/plan-tools',
optionalRequires: [PermissionDecisionCapability],
setup: (ctx) => ({
// Give the model only the tools that permissions() allows in plan mode.
prepareTools: ({ tools }) => {
const decide = ctx.getOptional(PermissionDecisionCapability)
if (!decide) return tools
return tools.filter((tool) => decide(tool.name, 'plan') === 'allow')
},
}),
})Your plugin runs a call itself, outside the tool calls of the model. For example, a command deploys, and the rules for deploy must apply. Read the decision for the call with PermissionCallCapability:
import {
PermissionCallCapability,
PermissionRules,
defineCommand,
definePlugin,
} from '@tanstack/ai-harness'
declare function deploy(target: string): Promise<string>
export const deployPlugin = definePlugin({
name: 'acme/deploy',
optionalRequires: [PermissionCallCapability],
setup: (ctx) => ({
contribute: [PermissionRules.item({ tool: 'deploy', decision: 'ask', kind: 'execute' })],
commands: {
deploy: defineCommand({
description: 'Deploy to production',
run: async (_input: unknown, { step }) => {
const decide = ctx.getOptional(PermissionCallCapability)
// Without permissions(), the call runs, as a direct call does.
const checked = decide
? await decide({ tool: 'deploy', args: { target: 'production' } })
: { decision: 'allow' as const }
if (checked.decision === 'deny') return checked.error
if (checked.decision === 'ask') {
const reply = await step.ask({
name: 'approve',
message: checked.message,
schema: checked.schema,
})
const refusal = await checked.settle(reply)
if (refusal !== undefined) return refusal
}
return step.do('deploy', () => deploy('production'))
},
}),
},
}),
})PermissionRules, PermissionResources, PermissionDecisionCapability, PermissionCallCapability, and decidePermission come from @tanstack/ai-harness. They load no Node module, so a plugin for an edge runtime can use them. The plugins themselves come from @tanstack/ai-harness/plugins, which is Node only.
Next: give the agent a planner and a builder with agent profiles.