Write tools
Create the edit, write, and apply_patch functions, and configure them with options.
The three write tools share their options and their result:
editreplaces text in a file that exists.writecreates a file, or replaces a whole file.apply_patchapplies 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.
| Option | Default | Use it to |
|---|---|---|
fs | Required | Set the filesystem, the allowed folders, and the symlink policy |
formatter | defaultWriteFormatter() | Change the text the tool returns |
messages | defaultWriteMessages | Change the wording of notes |
state | null | Refuse changes to files the model has not read |
digest | sha256Digest() | Hash file content. state needs it. null turns it off |
preconditions | see Read before write | Change the read-before-write rules |
locks | memoryLocks() | Stop two calls from changing one file at the same time |
resolve | null | Rewrite paths before the tool opens them |
authorize | null (allow all) | Refuse paths, or ask the user before a change |
matchers | defaultEditMatchers() or defaultPatchMatchers() | Decide how old text matches the file. Not on write |
recovery | false | Help the model after a miss. Only on edit |
patchParser | codexPatchParser() | 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 |
classifiers | defaultClassifiers() | Refuse files that are not text |
codecs | [utf8Codec()] | Decode and encode file text |
limits | defaultWriteLimits | Change 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>:
| Function | input |
|---|---|
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 PatchThe 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.