Better FS Tools
Write tools

Write tools

Create the edit, write, and apply_patch functions, and configure them with options.

To give the write tools to an agent, use a provider integration such as AI SDK or Pi. The integration gives you the tool definitions, and its bundle can share the read records between the tools. It takes the options on this page, plus a signature for each tool definition.

The three write tools share their options and their result:

  • edit replaces text in a file that exists.
  • write creates a file, or replaces a whole file.
  • apply_patch applies a patch that adds, changes, moves, and deletes files.

createEditTool, createWriteTool, and createApplyPatchTool are the lower-level API. Each creates the function that implements one tool. Use them when you write your own integration, or when your code changes files for the agent.

Each factory needs a writable filesystem in fs. All other options are optional:

import { nodeFileSystem } from "@better-fs-tools/node";
import { createApplyPatchTool, createEditTool, createWriteTool } from "@better-fs-tools/write";

const cwd = process.cwd();
const fs = nodeFileSystem({ cwd, allowedRoots: [cwd] });
const edit = createEditTool({ fs });
const write = createWriteTool({ fs });
const applyPatch = createApplyPatchTool({ fs });

With only fs, the tools change files that the model has not read. To make them check, give them the state of the read tool. See Read before write.

On Node or Bun, pass nodeFileSystem() from @better-fs-tools/node, as above. In a Worker or a sandbox, pass another writable filesystem.

createFsTools({ fs }) creates read, edit, and write together. Set applyPatch: true to also create apply_patch. The default state of the bundle is null, so read before write is off. Set state: memoryStore() to turn it on. See Choose an integration.

OptionDefaultUse it to
fsRequiredSet the filesystem, the allowed folders, and the symlink policy
formatterdefaultWriteFormatter()Change the text the tool returns
messagesdefaultWriteMessagesChange the wording of notes
statenullRefuse changes to files the model has not read
digestsha256Digest()Hash file content. state needs it. null turns it off
preconditionssee Read before writeChange the read-before-write rules
locksmemoryLocks()Stop two calls from changing one file at the same time
resolvenullRewrite paths before the tool opens them
authorizenull (allow all)Refuse paths, or ask the user before a change
matchersdefaultEditMatchers() or defaultPatchMatchers()Decide how old text matches the file. Not on write
recoveryfalseHelp the model after a miss. Only on edit
patchParsercodexPatchParser()Read another patch format. Only on apply_patch
guards[]Refuse damaged changes, such as pasted line numbers
hooks[]Run your code after each changed file
classifiersdefaultClassifiers()Refuse files that are not text
codecs[utf8Codec()]Decode and encode file text
limitsdefaultWriteLimitsChange the size limits
clock() => new Date()Set the time used in stored records

limits, messages, and preconditions merge with their defaults key by key. Every other option replaces its default. The factories throw TypeError for an unknown option or an option with the wrong shape.

The functions

Each factory returns a function (input, ctx?) that returns a Promise<MutationResult>:

Functioninput
edit{ path, edits: [{ oldText, newText, replaceAll? }] }
write{ path, content }
applyPatch{ patch }
import { textOf } from "@better-fs-tools/write";

const result = await edit({
  path: "src/greet.ts",
  edits: [{ oldText: "Hello", newText: "Hi" }],
});

result.status; // "ok"
textOf(result); // the text for the model
result.changes; // [{ kind: "update", path: "src/greet.ts", linesAdded: 1, linesRemoved: 1, diff: "--- a/src/greet.ts\n...", ... }]

status is ok, no-change, or error. no-change is not an error. For example, the file already has the new text. On error, result.error.code tells you why. No file changed, unless the code is PARTIAL_COMMIT. result.changes has one entry for each changed file, with its full diff. result.notes holds each note as data. The write reference lists every field and code.

ctx is the call context of the read tool: signal, callId, and host. When signal aborts before the first write, the call returns ABORTED. After the first write, the call does not stop. It finishes, or it undoes the files it changed.

Formatting

By default the tool returns one line that tells what changed, then the notes:

Edited src/greet.ts: 1 replacement at line 2.

A failed call returns only notes:

[edit:no-match] Edit 1: the oldText was not found in src/greet.ts.

Set formatter to change the layout, and messages to change the wording of a note:

import { defaultWriteFormatter } from "@better-fs-tools/write";

const edit = createEditTool({
  fs,
  formatter: defaultWriteFormatter({ snippet: true }),
  messages: {
    notFound: ({ path }) => `No file at ${path}. Create it with the write tool.`,
  },
});

snippet: true adds the new lines around each change of an edit:

Edited src/greet.ts: 1 replacement at line 2.
1|export function greet(name: string): string {
2|  return `Hi, ${name}!`;
3|}
4|
5|export function farewell(name: string): string {

diff: true adds the full diff of each file to the text. See Customise formatting.

Read before write

Give the write tools the same state and digest as the read tool. They then refuse to change a file that the model has not read, or that changed after the read. Give them one locks too. This example sets nodeDigest(), which is faster on Node than the default sha256Digest():

import { nodeDigest } from "@better-fs-tools/node";
import { createReadTool, memoryStore } from "@better-fs-tools/read";
import { memoryLocks } from "@better-fs-tools/write";

const shared = { fs, digest: nodeDigest(), state: memoryStore() };
const read = createReadTool(shared);
const locks = memoryLocks();
const edit = createEditTool({ ...shared, locks });
const write = createWriteTool({ ...shared, locks });
[write:not-read] Read src/greet.ts with the read tool before changing it.

edit and apply_patch accept a read of part of the file. write needs a read of the whole file, or reads in ranges that together show every line. A new file needs no read. A file that changed after the read returns STALE. Without state, the tools do not check reads. See Read before write.

Permissions

The filesystem decides which folders the tools can change, as for the read tool. Set authorize to refuse changes inside those folders:

import { denyPaths } from "@better-fs-tools/read";
import { protectPaths, writeAuthorizers } from "@better-fs-tools/write";

const write = createWriteTool({
  fs,
  authorize: writeAuthorizers(denyPaths(["**/.env", "**/.env.*"]), protectPaths()),
});
[write:denied] AGENTS.md was refused by policy (the path is protected).

protectPaths() refuses AGENTS.md, CLAUDE.md, and .git/**. askBeforeWrite() shows the user every change of a call, with its diff, and asks once. See Permissions.

Matching

Each oldText of an edit must match one place in the file. Set replaceAll: true to change every match. When the text matches more than once, the call fails:

[edit:ambiguous-match] Edit 1: the oldText matches 2 places in src/greet.ts (lines 1, 5). Add surrounding lines to make it unique, or set replaceAll.

When the exact text is not found, the tool tries a looser matcher that ignores, for example, trailing spaces. A loose match adds a fuzzy-match note. Set matchers to change the list. When no matcher finds the text, the call fails with NO_MATCH. Set recovery: true to show the model the closest region of the file. See Match old text.

Patches

apply_patch takes a patch in the Codex format:

*** Begin Patch
*** Update File: src/greet.ts
@@
 export function farewell(name: string): string {
-  return `Goodbye, ${name}!`;
+  return `Bye, ${name}!`;
 }
*** Delete File: src/old.ts
*** End Patch

The tool checks every operation before it changes a file. If a write fails, it undoes the files it already changed. The bundles create apply_patch only when you set applyPatch: true. See Apply patches.

Guards and hooks

Guards check each change before the tool writes it. The default guards is [], so no guard runs. recommendedGuards() refuses pasted line numbers, read tool notes, placeholders such as // ... rest of code, a change that breaks a JSON file, and content that is not text:

import { recommendedGuards } from "@better-fs-tools/write";

const edit = createEditTool({ fs, guards: recommendedGuards() });
[edit:guard-refused] The newText for src/greet.ts has a placeholder instead of code: "// ... rest of code". It would delete the lines it stands for. Write those lines out in full, or make the oldText smaller so they stay unchanged.

Hooks run after each changed file. executableShebang() makes a new file that starts with #! executable, and verifyWrite() reads each file back:

import { executableShebang, verifyWrite } from "@better-fs-tools/write";

const write = createWriteTool({ fs, hooks: [executableShebang(), verifyWrite()] });

A hook that throws adds no note. The file is already changed, so the status stays ok. See Guards and hooks.

Limits and file types

The tools refuse an existing file larger than 8 MiB, new content larger than 8 MiB, and a patch larger than 4 MiB or with more than 100 operations. An edit takes at most 100 pairs. Set limits to change these values. The write reference lists every limit.

The tools change only text files. classifiers decides which files are text, and a file that is not text returns NOT_TEXT. The default classifiers read a Jupyter notebook as JSON text, so the tools can change it. utf8Codec() keeps the byte order mark and the line endings of a file.

On this page