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 aiai 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.
Recommended configuration
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:
| Option | Default | What it does |
|---|---|---|
fs | required | A backend, or a function that returns a backend for each tool call |
state | null | The read store. null turns read before write off. memoryStore() turns it on. A function gives each call its own store |
digest | sha256Digest() | The content digest. sha256Digest() is plain JavaScript and runs in a Worker |
locks | memoryLocks() | 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 |
applyPatch | off | true, or an options object with an optional signature, adds an apply_patch tool |
bash | off | An options object with a runner and an env adds a bash tool with the same digest and clock. See Bash tool |
contextSchema | none | The 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 iscreateAiSdkFsTools read options cannot set digest: set it once at the top level. - Two tools with the same name.
trueas the value ofbash. See Bash tool.
The result
| Property | Value |
|---|---|
read, edit, write | The three tools |
applyPatch, bash | The tool, or null when it is off |
tools | Only the tools that are on, keyed by their names, for generateText |
state, digest, locks, clock | The 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:
| Factory | Options type | Default signature |
|---|---|---|
createAiSdkReadTool() | AiSdkReadToolOptions | defaultReadSignature(): read with path, offset, and limit |
createAiSdkEditTool() | AiSdkEditToolOptions | defaultEditSignature({ matchers }): edit with path, old_string, new_string, replace_all |
createAiSdkWriteTool() | AiSdkWriteToolOptions | defaultWriteSignature(): write with path and content |
createAiSdkApplyPatchTool() | AiSdkApplyPatchToolOptions | defaultPatchSignature(): apply_patch with patch |
createAiSdkBashTool() | AiSdkBashToolOptions | defaultBashSignature({ 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 runnerThe 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 context | From ToolExecutionOptions |
|---|---|
ctx.call.host | The whole object |
ctx.call.signal | abortSignal, when it is set |
ctx.call.callId | toolCallId |
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
requiredand hasadditionalProperties: false. - An optional property becomes required and nullable.
nulljoins itstypeand itsenum, or the adapter wraps the schema inanyOfwith{ type: "null" }. The tool turns anullback into an absent parameter before the signature reads the input. oneOfbecomesanyOf.allOfmerges into its schema.constbecomes anenumwith one value. A rootdefinitionsbecomes$defs. A local$refstays 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, andmaxItems. The adapter drops other keywords, such asminLength,maxLength,title, anddefault. It also drops aformatthat 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, limitCustom 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 #/additionalPropertiesThese 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
anyOfandoneOf. - An
allOfover two object schemas, anallOfwith a$ref, or anallOfwith a keyword that differs from its schema. - A
$refoutside 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" }] }.