Better FS Tools
Integrations

AI SDK

Give the read, edit, write, apply_patch, and bash tools to AI SDK 7 with createAiSdkFsTools().

@better-fs-tools/ai-sdk makes the tools into AI SDK 7 tool objects. You put them in the tools of generateText or streamText. The package needs no Node module, so it runs in a Worker.

Install

npm install @better-fs-tools/ai-sdk @better-fs-tools/read @better-fs-tools/write ai

ai is a required peer, at ^7.0.77. Add a filesystem package too, for example @better-fs-tools/node or @better-fs-tools/cloudflare-shell. For the bash tool, add @better-fs-tools/shell.

On Node or Bun, use the Node filesystem, the Node digest, and a Node runner for bash:

import { createAiSdkFsTools } from "@better-fs-tools/ai-sdk";
import { nodeCommandRunner, nodeDigest, nodeFileSystem } from "@better-fs-tools/node";
import { shellEnv } from "@better-fs-tools/shell";

const cwd = process.cwd();
export const { tools } = createAiSdkFsTools({
  fs: nodeFileSystem({ cwd, allowedRoots: [cwd] }),
  digest: nodeDigest(), // faster on Node than the default sha256Digest()
  bash: { runner: nodeCommandRunner({ cwd }), env: shellEnv(() => process.env) },
});

tools holds each tool under its name: read, edit, write, and bash. Pass it to generateText or streamText as it is:

import { generateText, isStepCount } from "ai";
import type { LanguageModel } from "ai";
import { createAiSdkFsTools } from "@better-fs-tools/ai-sdk";
import { nodeFileSystem } from "@better-fs-tools/node";

const cwd = process.cwd();
const { tools } = createAiSdkFsTools({ fs: nodeFileSystem({ cwd, allowedRoots: [cwd] }) });

export async function change(model: LanguageModel, prompt: string): Promise<string> {
  const result = await generateText({ model, prompt, tools, stopWhen: isStepCount(10) });
  return result.text;
}

In a Worker, pass cloudflareShellFileSystem() or justBashFileSystem() as fs, and keep the default digest. See Cloudflare Agents.

These choices are options, not defaults. Each other option has the default of every integration. Choose an integration has the full table.

Bundle options

createAiSdkFsTools() takes the options of createFsTools() from @better-fs-tools/write, plus a signature in each tool's options, plus contextSchema. fs is the only required option:

OptionDefaultWhat it does
fsrequiredA backend, or a function that returns a backend for each tool call
statenullThe read store. null turns read before write off. memoryStore() turns it on. A function gives each call its own store
digestsha256Digest()The content digest. sha256Digest() is plain JavaScript and runs in a Worker
locksmemoryLocks()The lock manager of the write tools
clock() => new Date()The clock
read{}The options of the read tool, and its signature. converters defaults to [imageConverter()]
edit{}The options of the edit tool, and its signature
write{}The options of the write tool, and its signature
applyPatchofftrue, or an options object with an optional signature, adds an apply_patch tool
bashoffAn options object with a runner and an env adds a bash tool with the same digest and clock. See Bash tool
contextSchemanoneThe schema of the toolsContext entry of each tool. See Host context

Set fs, state, digest, locks, and clock once, at the top level. The tool options are the options of the read tool, the write tools, and the bash tool. This example turns on read before write, turns images off, and adds apply_patch and bash:

import { createAiSdkFsTools } from "@better-fs-tools/ai-sdk";
import { nodeCommandRunner, nodeFileSystem } from "@better-fs-tools/node";
import { memoryStore } from "@better-fs-tools/read";
import { shellEnv } from "@better-fs-tools/shell";

const cwd = process.cwd();
const bundle = createAiSdkFsTools({
  fs: nodeFileSystem({ cwd, allowedRoots: [cwd] }),
  state: memoryStore(),
  read: { converters: [] },
  applyPatch: true,
  bash: { runner: nodeCommandRunner({ cwd }), env: shellEnv(() => process.env) },
});

bundle.tools then has the keys read, edit, write, apply_patch, and bash.

The bundle throws a TypeError for these options:

  • An option key that it does not know.
  • A shared key, such as digest, inside the options of one tool. The message is createAiSdkFsTools read options cannot set digest: set it once at the top level.
  • Two tools with the same name.
  • true as the value of bash. See Bash tool.

The result

PropertyValue
read, edit, writeThe three tools
applyPatch, bashThe tool, or null when it is off
toolsOnly the tools that are on, keyed by their names, for generateText
state, digest, locks, clockThe shared parts
invalidate(path, call?)Deletes the read record of path, so the next edit needs a read. It returns an InvalidateOutcome. When fs or state is a function, pass the call

When you set applyPatch or bash, the result type makes that property non-null.

Single tools

Each tool also has its own factory:

FactoryOptions typeDefault signature
createAiSdkReadTool()AiSdkReadToolOptionsdefaultReadSignature(): read with path, offset, and limit
createAiSdkEditTool()AiSdkEditToolOptionsdefaultEditSignature({ matchers }): edit with path, old_string, new_string, replace_all
createAiSdkWriteTool()AiSdkWriteToolOptionsdefaultWriteSignature(): write with path and content
createAiSdkApplyPatchTool()AiSdkApplyPatchToolOptionsdefaultPatchSignature(): apply_patch with patch
createAiSdkBashTool()AiSdkBashToolOptionsdefaultBashSignature({ runner, limits }): bash with command, timeout, and cwd

Each factory takes every option of its core factory, with the same defaults, plus signature and contextSchema. fs is required for the file tools. This example gives a read tool a new name and a line range:

import { generateText, isStepCount } from "ai";
import type { LanguageModel } from "ai";
import { createAiSdkReadTool } from "@better-fs-tools/ai-sdk";
import { nodeFileSystem } from "@better-fs-tools/node";
import { lineRangeSignature } from "@better-fs-tools/read/signature";

const cwd = process.cwd();
const read = createAiSdkReadTool({
  fs: nodeFileSystem({ cwd, allowedRoots: [cwd] }),
  signature: lineRangeSignature({
    name: "read_file",
    names: { path: "file_path", start: "start_line", end: "end_line" },
  }),
});

export async function ask(model: LanguageModel, prompt: string): Promise<string> {
  const result = await generateText({
    model,
    prompt,
    tools: { [read.name]: read },
    stopWhen: isStepCount(5),
  });
  return result.text;
}

A single factory shares nothing with another one. Each write tool makes its own lock manager, and there is no read store unless you pass one. To check reads, give every tool the same store, and the write tools the same locks:

import {
  createAiSdkEditTool,
  createAiSdkReadTool,
  createAiSdkWriteTool,
} from "@better-fs-tools/ai-sdk";
import { nodeFileSystem } from "@better-fs-tools/node";
import { memoryStore } from "@better-fs-tools/read";
import { memoryLocks } from "@better-fs-tools/write";

const cwd = process.cwd();
const fs = nodeFileSystem({ cwd, allowedRoots: [cwd] });
const state = memoryStore();
const locks = memoryLocks();
const read = createAiSdkReadTool({ fs, state });
const edit = createAiSdkEditTool({ fs, state, locks });
const write = createAiSdkWriteTool({ fs, state, locks });
const tools = { [read.name]: read, [edit.name]: edit, [write.name]: write };

Use the bundle when you install more than one tool. It does this for you.

Images

read.converters defaults to [imageConverter()], as in every integration. An image read gives a media result, and the model gets the image as a file part. A list that you pass replaces the default. converters: [] turns images off, and a list without imageConverter() refuses images.

Bash tool

This package starts no process, and it does not read process.env. A bash tool needs a runner and an env, as in every integration:

import { generateText, isStepCount } from "ai";
import type { LanguageModel } from "ai";
import { createAiSdkBashTool } from "@better-fs-tools/ai-sdk";
import { nodeCommandRunner } from "@better-fs-tools/node";
import { shellEnv } from "@better-fs-tools/shell";

// The AI SDK package starts no process. Give it a runner.
const bash = createAiSdkBashTool({
  runner: nodeCommandRunner(),
  env: shellEnv(() => process.env),
});

export async function run(model: LanguageModel, prompt: string): Promise<string> {
  const result = await generateText({
    model,
    prompt,
    tools: { [bash.name]: bash },
    stopWhen: isStepCount(5),
  });
  return result.text;
}

Use nodeCommandRunner() from @better-fs-tools/node, or justBashCommandRunner() from @better-fs-tools/just-bash in a Worker. The default signature is bash with command, timeout, and cwd. timeout is in milliseconds, and the description names the runner.

In the bundle, set bash: { runner, env }. true throws a TypeError, because no bundle has a default runner:

TypeError: createAiSdkFsTools bash must be an object with a runner and an env: the portable bundle has no default runner

The bash guide has the other bash options.

Host context

execute gives the AI SDK ToolExecutionOptions object to the core tool as the call context:

Call contextFrom ToolExecutionOptions
ctx.call.hostThe whole object
ctx.call.signalabortSignal, when it is set
ctx.call.callIdtoolCallId

Your options and hooks can read call.host, for example call.host.messages or call.host.context. The type argument of a factory sets the type of context.

AI SDK 7 passes toolsContext[name] to the tool as context. Its types accept a toolsContext entry only for a tool with a contextSchema, and the AI SDK validates the entry with that schema before execute. Set contextSchema on a single tool, or once on the bundle for every tool. This bundle picks a filesystem for each call from the context:

import { jsonSchema } from "ai";
import { createAiSdkFsTools } from "@better-fs-tools/ai-sdk";
import { nodeFileSystem } from "@better-fs-tools/node";

interface Workspace {
  readonly root: string;
}

export const { tools } = createAiSdkFsTools<Workspace>({
  contextSchema: jsonSchema<Workspace>({
    type: "object",
    properties: { root: { type: "string" } },
    required: ["root"],
    additionalProperties: false,
  }),
  fs: (call) =>
    nodeFileSystem({ cwd: call.host.context.root, allowedRoots: [call.host.context.root] }),
});

To give each request its own session, you can also create the tools inside the request. Use one store for each session shows the same pattern with state. A function of the call works for state in the bundle and in each single tool.

Alternatives

The recommended configuration suits Node and Bun. These choices suit other hosts and other agents.

Read before write

state: memoryStore() makes edit and write refuse a change to a file that the model has not read in this session. See Read before write. The Bundle options example turns it on.

Cloudflare Agents

In a Worker, for example in a Cloudflare Agents host, pass a Cloudflare Shell filesystem as fs. No other wrapper is necessary:

import type { Workspace } from "@cloudflare/shell";
import { createAiSdkFsTools } from "@better-fs-tools/ai-sdk";
import { cloudflareShellFileSystem } from "@better-fs-tools/cloudflare-shell";

// read, edit, and write with one sha256Digest() and one lock manager. Nothing
// here needs Node, so it runs in a Worker.
export function workspaceTools(workspace: Workspace) {
  const fs = cloudflareShellFileSystem(workspace, { allowedRoots: ["/workspace"] });
  return createAiSdkFsTools({ fs }).tools;
}

The Cloudflare Shell backend reads the whole file before authorize runs. See Filesystems for what each backend can do.

A patch signature with a grammar

The default apply_patch signature takes the whole patch as a JSON string in patch. AI SDK tools take JSON only. A signature with a grammar, such as freeformPatchSignature(), still works, but the adapter does not send the grammar. The model sees the same JSON schema as for defaultPatchSignature():

import { createAiSdkApplyPatchTool } from "@better-fs-tools/ai-sdk";
import { nodeFileSystem } from "@better-fs-tools/node";
import { freeformPatchSignature } from "@better-fs-tools/write/signature";

const cwd = process.cwd();
const applyPatch = createAiSdkApplyPatchTool({
  fs: nodeFileSystem({ cwd, allowedRoots: [cwd] }),
  signature: freeformPatchSignature(),
});

Strict schemas

Every tool is strict: true. The adapter turns the JSON Schema of the signature into the subset that OpenAI strict mode accepts:

  • Every object lists every property in required and has additionalProperties: false.
  • An optional property becomes required and nullable. null joins its type and its enum, or the adapter wraps the schema in anyOf with { type: "null" }. The tool turns a null back into an absent parameter before the signature reads the input.
  • oneOf becomes anyOf. allOf merges into its schema. const becomes an enum with one value. A root definitions becomes $defs. A local $ref stays as it is.
  • The schema keeps only type, description, properties, required, additionalProperties, items, anyOf, $ref, $defs, enum, pattern, format, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, minItems, and maxItems. The adapter drops other keywords, such as minLength, maxLength, title, and default. It also drops a format that strict mode does not list.
  • The same rules apply inside anyOf, oneOf, allOf, array items, and $defs.

For the default read signature, the model sees this schema. The descriptions and the path pattern are left out here:

{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "path": { "type": "string" },
    "offset": { "type": ["integer", "null"], "minimum": 1, "maximum": 9007199254740991 },
    "limit": { "type": ["integer", "null"], "minimum": 1, "maximum": 9007199254740991 }
  },
  "required": ["path", "offset", "limit"]
}

A model call { "path": "src/a.ts", "offset": null, "limit": null } is the same as { "path": "src/a.ts" }.

The schema's validate hook runs signature.toInput and the input check of the core tool. A check that the adapter dropped from the schema still applies to the input. A refusal names the parameters of the signature, and generateText gives it to the model as a tool error:

TypeError: Unknown read input key: file. Expected path, offset, limit

Custom signatures

If you write your own signature, its schema must survive this change. A schema that strict mode cannot express throws a TypeError when you create the tool. The message names the tool and the schema path:

TypeError: read: strict mode cannot express an object that allows other properties at #/additionalProperties

These forms throw:

  • A root that is not an object.
  • An object that allows other properties.
  • A schema with no type.
  • not, if, patternProperties, dependentRequired, or tuple items.
  • A schema with both anyOf and oneOf.
  • An allOf over two object schemas, an allOf with a $ref, or an allOf with a keyword that differs from its schema.
  • A $ref outside the schema.

Do not put a check only in a keyword that strict mode drops, such as minLength. The model does not see it. Put the check in the signature's parse, so validate refuses the input.

Model output

execute returns the whole result: a ReadResult, a MutationResult, or a ShellResult. toModelOutput gives the model the content of the result as AI SDK content output. A text part stays a text part. A media part, such as an image, becomes a base64 file part with its mediaType.

toAiSdkOutput(result) does the same change. Use it when you make your own AI SDK tool from a core tool:

import { jsonSchema, tool } from "ai";
import { toAiSdkOutput } from "@better-fs-tools/ai-sdk";
import { memoryFileSystem } from "@better-fs-tools/fs";
import { createReadTool } from "@better-fs-tools/read";

const fs = memoryFileSystem({ files: { "/notes.txt": "one\n" } });
const readFile = createReadTool({ fs });

const readNotes = tool({
  description: "Read the notes file.",
  inputSchema: jsonSchema<Record<string, never>>({
    type: "object",
    properties: {},
    additionalProperties: false,
  }),
  execute: () => readFile({ path: "/notes.txt" }),
  toModelOutput: ({ output }) => toAiSdkOutput(output),
});

For the notes file, the output is { type: "content", value: [{ type: "text", text: "1|one" }] }.

On this page