Better FS Tools
Integrations

Tool definitions

Connect the read, edit, write, apply_patch, and bash tools to a framework of your own, and change the tool name, the parameter names, and the descriptions with a signature.

A tool definition binds a core tool to its signature. The signature is the shape of the tool that the model sees: a name, a description, and a JSON Schema for the parameters. The definition has what a framework needs to declare the tool and run it. Every integration is an adapter over the definitions, so a framework of your own gets the same behaviour from them.

The definition helpers

Each tool has a helper in the ./signature subpath of its package:

HelperImport fromOptions type
readToolDefinition()@better-fs-tools/read/signatureReadToolDefinitionOptions
editToolDefinition()@better-fs-tools/write/signatureEditToolDefinitionOptions
writeToolDefinition()@better-fs-tools/write/signatureWriteToolDefinitionOptions
applyPatchToolDefinition()@better-fs-tools/write/signatureApplyPatchToolDefinitionOptions
bashToolDefinition()@better-fs-tools/shell/signatureBashToolDefinitionOptions
fsToolDefinitions()@better-fs-tools/write/signatureFsToolDefinitionsOptions

Each helper takes the options of its core factory, with the same defaults, plus signature. fs is required for the file tools, and bash needs runner and env. fsToolDefinitions() is the bundle: it takes the options of createFsTools(), plus a signature in each tool's options. It throws TypeError where createFsTools() does, and when two tools have the same name.

What a definition holds

PropertyValue
nameThe tool name that the model sees: signature.name
descriptionThe tool description: signature.description
inputSchemaThe plain JSON Schema of the model input: signature.schema
grammar{ lark }, set only when the signature has a grammar, as freeformPatchSignature() has
signatureThe signature
validatevalidate(modelInput) checks the model input with the signature and with the core input check, and returns the canonical input. It throws TypeError that names the signature's parameters
executeexecute(modelInput, call) maps the model input with signature.toInput() and runs the core tool. It returns the tool result

A tool error is a result with status: "error", not a throw. Bad model input makes execute reject with TypeError. The read definition also has limits, formatter, and digest: the ones that the tool uses, for a host that formats the result again.

A definition also puts the signature's wording under messages, so the notes name the model's parameters. A message that you pass still wins.

Connect the tools to your framework

Declare each tool with name, description, and inputSchema. When the model calls a tool, give the model input and the call to execute. Then give the text of the result to the model:

import { memoryFileSystem } from "@better-fs-tools/fs";
import { memoryStore, textOf } from "@better-fs-tools/read";
import { fsToolDefinitions } from "@better-fs-tools/write/signature";

const fs = memoryFileSystem({ files: { "/a.ts": "x\n" } });
const defs = fsToolDefinitions({ fs, state: memoryStore() });
const tools = [defs.read, defs.edit, defs.write];

// What your framework declares to the model.
export const declarations = tools.map((tool) => ({
  name: tool.name,
  description: tool.description,
  parameters: tool.inputSchema,
}));

// What your framework calls for each tool call of the model.
export async function runToolCall(id: string, name: string, input: unknown) {
  const tool = tools.find((candidate) => candidate.name === name);
  if (tool === undefined) return { text: `Unknown tool: ${name}`, isError: true };
  const result = await tool.execute(input, { callId: id, host: undefined });
  return { text: textOf(result), isError: result.status === "error" };
}

runToolCall("1", "edit", { path: "/a.ts", old_string: "x", new_string: "y" }) gives this error, because the model has not read the file:

[edit:not-read] Read /a.ts with the read tool before changing it.

The second argument of execute is the call: { signal?, callId?, host }. host is your framework's own context of the call. Your hooks and authorizers get it as ctx.call.host, and a function fs or state gets the call. Set its type with the type argument, for example fsToolDefinitions<MyHost>({ ... }).

To check the input before you run the tool, call validate. A framework can use it as the validation of its schema, as the AI SDK integration does:

TypeError: Unknown edit input key: file. Expected path, old_string, new_string, replace_all

The result of fsToolDefinitions() also has the shared fs, state, digest, locks, and clock, and invalidate(path, call?), as createFsTools() has.

Default signatures

Every definition and integration has the same default signatures:

SignatureImport fromToolParameters
defaultReadSignature()@better-fs-tools/read/signaturereadpath, offset?, limit?
defaultEditSignature()@better-fs-tools/write/signatureeditpath, old_string, new_string, replace_all?
defaultWriteSignature()@better-fs-tools/write/signaturewritepath, content
defaultPatchSignature()@better-fs-tools/write/signatureapply_patchpatch, as JSON
defaultBashSignature()@better-fs-tools/shell/signaturebashcommand, timeout?, cwd?

Set signature on a definition, on an integration factory, or in a bundle's tool options:

import { createAiSdkReadTool } from "@better-fs-tools/ai-sdk";
import { nodeFileSystem } from "@better-fs-tools/node";
import { defaultReadSignature } from "@better-fs-tools/read/signature";

const cwd = process.cwd();
const read = createAiSdkReadTool({
  fs: nodeFileSystem({ cwd, allowedRoots: [cwd] }),
  signature: defaultReadSignature({
    description: "Read a file in the repository.",
    describe: { path: "Path relative to the repository root." },
  }),
});

In a bundle, set it for each tool, for example createPiFsTools({ fs, read: { signature } }).

Every default signature takes these options:

OptionSets
nameThe tool name
descriptionThe tool description
describeThe description of each parameter that you name
namesA new name for each parameter that you name

The keys of describe and names are the signature's own parameter names. A key that the signature does not have, or a parameter name that is used two times, throws a TypeError.

Rename the tool and its parameters

Set name and names. This read signature has file_path, start, and max_lines:

import { defaultReadSignature } from "@better-fs-tools/read/signature";

const signature = defaultReadSignature({
  name: "read_file",
  names: { path: "file_path", offset: "start", limit: "max_lines" },
});

The default description uses the new names. The definition also changes the notes, so a continue note gives the next call with the new names.

names gives the shapes that other agents use. For example, the edit names of OpenCode, and the write names of Claude Code:

import { createAiSdkFsTools } from "@better-fs-tools/ai-sdk";
import { nodeFileSystem } from "@better-fs-tools/node";
import { defaultEditSignature, defaultWriteSignature } from "@better-fs-tools/write/signature";

const cwd = process.cwd();
const { tools } = createAiSdkFsTools({
  fs: nodeFileSystem({ cwd, allowedRoots: [cwd] }),
  edit: {
    signature: defaultEditSignature({
      names: {
        path: "filePath",
        old_string: "oldString",
        new_string: "newString",
        replace_all: "replaceAll",
      },
    }),
  },
  write: {
    signature: defaultWriteSignature({ name: "write_file", names: { path: "file_path" } }),
  },
});

tools is keyed by tool name, so it has read, edit, and write_file. When the old text is not in the file, the note names oldString:

[edit:no-match] Edit 1: the oldString was not found in a.ts.

Input with the wrong names

The signature does not guess. Input with an unknown name throws a TypeError that lists the names it expects:

TypeError: Unknown read input key: path. Expected file_path, start, max_lines

The integrations give this error back to the model as a tool error, so the model can call again.

Read

This is the default description of defaultReadSignature():

Read a UTF-8 text file. `offset` is a one-based source line and `limit` is a number of source lines. Output is bounded by line, byte, and line-length limits. When the result reports truncation, follow the continuation it returns instead of guessing the next `offset`. Paths outside the allowed roots, binary content, and special files are refused.

The tool reads one path in each call. For a tool that reads many files in one call, call read once for each file.

Edit

defaultEditSignature() takes one replacement in each call. The old text must match one place in the file, or the model sets replace_all to change every place. toInput() gives { path, edits: [{ oldText, newText, replaceAll? }] }.

This is the default description, with the default matchers:

Edit a text file by replacing text. `old_string` must match exactly one place in the file: add surrounding lines to make it unique, or set `replace_all` to change every place. `old_string` should match the file exactly. When it does not, a close match is used, and the result says so. A close match may differ in: trailing whitespace, Unicode normalization, curly quotes, dashes, and special spaces. Copy old text from the file without the line-number prefix the read tool adds. Read the file with the read tool before you edit it. After an edit, the same file can be edited again without another read. To create a file or replace all of its content, use the write tool.

The edit signatures also take matchers. This option does not change how the tool matches text. It changes one sentence in the description, so the model knows if a close match is used. The default is defaultEditMatchers(), the default of the edit tool. When you leave out signature, the definition gives the default signature the edit tool's matchers. When you give your own signature, give it the same list:

import { createAiSdkFsTools } from "@better-fs-tools/ai-sdk";
import { nodeFileSystem } from "@better-fs-tools/node";
import { exactMatcher } from "@better-fs-tools/write";
import { defaultEditSignature } from "@better-fs-tools/write/signature";

const cwd = process.cwd();
const matchers = [exactMatcher()];
const { tools } = createAiSdkFsTools({
  fs: nodeFileSystem({ cwd, allowedRoots: [cwd] }),
  edit: { matchers, signature: defaultEditSignature({ name: "str_replace", matchers }) },
});

With only exactMatcher(), the description says that old_string must match the file exactly, including whitespace and indentation.

Write

defaultWriteSignature() gives write with path and content. Both parameters are required. This is the default description:

Create a text file, or replace all of its content. Missing parent directories are created. To replace a file that exists, read all of it with the read tool first. To change part of a file, use the edit tool: it sends less text and keeps the rest of the file as it is.

Apply patch

defaultPatchSignature() gives apply_patch with one required parameter, patch. The model sends the whole patch as a JSON string. The description has a short example of the Codex patch format:

Apply a patch that adds, updates, moves, or deletes text files. The input is one patch text (the `patch` parameter) in the Codex apply_patch format:

*** Begin Patch
*** Update File: src/app.ts
@@ export function main() {
 const a = 1;
-const b = 2;
+const b = 3;
*** Add File: docs/notes.md
+# Notes
*** Delete File: old.txt
*** End Patch

In an Update, each line starts with a space (context), `-` (remove), or `+` (add). `@@ <line>` names a line above the change to find the right place. Put `*** Move to: <path>` after an Update line to rename the file. Every line of an Add starts with `+`. Every hunk is checked before any file changes: when one fails, no file changes. Read each file you update or delete with the read tool first.

Bash

defaultBashSignature() gives bash with command, timeout, and cwd. Only command is required. It takes name, description, describe, and names, and these options:

OptionDefaultSets
timeoutUnit"ms"The unit of timeout, "ms" or "s". toInput() gives timeoutMs in milliseconds
cwdtrueWith false, the schema has no cwd parameter, and names.cwd and describe.cwd are ignored
runnerNoneA runner name for the description. The description then ends with Commands run in: <runner>.
limitsdefaultShellLimitsThe limits that the description names: the default and maximum timeout, and the output limits

runner and limits change only the text that the model sees. They do not choose a runner or set the limits of the tool. Give the tool and the signature the same limits.

This is the default description, with no runner and the default limits:

Run a shell command with bash -c and return its exit code and output. Each call starts a new shell: a cd, an export, or a variable does not carry to the next call. stdin is closed, so a command that waits for input gets end of file. Do not start interactive programs. The command stops after 2 min unless you set a timeout. The whole process tree is stopped at the timeout. stdout and stderr are merged. Long output keeps the first and the last lines, up to 2000 lines or 30000 bytes. Prefer the read, edit, and write tools for files.

When you leave out signature, bashToolDefinition() uses defaultBashSignature() with the id of the runner and the tool's limits. When you give your own signature, set them yourself:

import { createAiSdkBashTool } from "@better-fs-tools/ai-sdk";
import { nodeCommandRunner } from "@better-fs-tools/node";
import { shellEnv } from "@better-fs-tools/shell";
import { defaultBashSignature } from "@better-fs-tools/shell/signature";

const runner = nodeCommandRunner();
const limits = { defaultTimeoutMs: 30_000, maxTimeoutMs: 60_000 };
const bash = createAiSdkBashTool({
  runner,
  env: shellEnv(() => process.env),
  limits,
  signature: defaultBashSignature({
    timeoutUnit: "s",
    names: { timeout: "timeout_seconds" },
    runner: runner.id,
    limits,
  }),
});

The schema then describes timeout_seconds as Timeout in seconds. Default 30 seconds, maximum 60 seconds. A model call { "command": "ls", "timeout_seconds": 5 } becomes { command: "ls", timeoutMs: 5000 }.

Presets

Three presets give a shape that names cannot give:

PresetImport fromToolParameters
multiEditSignature()@better-fs-tools/write/signatureeditpath, edits: [{ oldText, newText }]
lineRangeSignature()@better-fs-tools/read/signaturereadpath, start?, end?, with inclusive lines
freeformPatchSignature()@better-fs-tools/write/signatureapply_patchpatch, plus the Codex patch grammar

Each preset takes name, description, describe, and names, keyed by its own parameter names. multiEditSignature() also takes matchers.

Many replacements in one edit

multiEditSignature() takes a list of replacements in one call. This is the input of the Pi and Durable edit tools. Each oldText is matched against the original file, and the edits must not overlap. This preset has no replaceAll parameter, so no message tells the model to set one.

This is the default description:

Edit a text file with one or more replacements in one call. Each `edits[].oldText` must match exactly one place in the original file. Edits must not overlap or nest: merge nearby changes into one edit. `edits[].oldText` should match the file exactly. When it does not, a close match is used, and the result says so. A close match may differ in: trailing whitespace, Unicode normalization, curly quotes, dashes, and special spaces. Copy old text from the file without the line-number prefix the read tool adds. Read the file with the read tool before you edit it. After an edit, the same file can be edited again without another read. To create a file or replace all of its content, use the write tool.

A start line and an end line

lineRangeSignature() gives the model an inclusive line range in place of offset and limit:

import { memoryFileSystem } from "@better-fs-tools/fs";
import { textOf } from "@better-fs-tools/read";
import { lineRangeSignature, readToolDefinition } from "@better-fs-tools/read/signature";

const fs = memoryFileSystem({
  files: {
    "/src/greet.ts":
      "export function greet(name: string): string {\n  return `Hello, ${name}!`;\n}\n",
  },
});
const read = readToolDefinition({
  fs,
  signature: lineRangeSignature({
    name: "read_file",
    names: { path: "file_path", start: "start_line", end: "end_line" },
  }),
});

const result = await read.execute(
  { file_path: "/src/greet.ts", start_line: 1, end_line: 2 },
  { host: undefined },
);
console.log(textOf(result));

It prints:

1|export function greet(name: string): string {
2|  return `Hello, ${name}!`;

[read:continue] Output stopped at the line limit. Continue with {"file_path":"/src/greet.ts","start_line":3,"end_line":4}.

The result is the same as for any read. result.continuation.next is { path: "/src/greet.ts", offset: 3, limit: 2 }.

The Codex patch grammar

freeformPatchSignature() is the same as defaultPatchSignature(), and adds signature.grammar.lark. Its value is CODEX_PATCH_GRAMMAR from @better-fs-tools/write/patch: the Lark grammar of the Codex apply_patch format. A host with grammar tools can use it to constrain the patch text that the model writes:

import { CODEX_PATCH_GRAMMAR } from "@better-fs-tools/write/patch";
import { freeformPatchSignature } from "@better-fs-tools/write/signature";

const signature = freeformPatchSignature();
signature.grammar?.lark === CODEX_PATCH_GRAMMAR; // true

The definition copies the grammar to grammar. The Pi, pi-agent-core, and pi-durable integrations send it as constrainedSampling to models that support grammar tools. Other models get the JSON schema. AI SDK tools take JSON only, so the AI SDK integration accepts this signature but does not send the grammar.

Custom schemas and AI SDK strict mode

Every AI SDK tool is strict: true, so the integration changes the signature's schema to the JSON Schema subset that OpenAI strict mode accepts. An optional parameter becomes required and nullable, and the tool reads a null as a missing parameter. Keywords outside the subset, such as minLength, are removed from the schema that the provider gets. The tool still runs validate on each call, so the checks in toInput() still apply.

All the signatures on this page work in strict mode. A custom signature with a schema that strict mode cannot express, for example one with not, if, or patternProperties, throws a TypeError when you create the tool. The AI SDK page gives all the rules.

On this page