Better FS Tools
Bash tool

Bash tool

Create the bash function with createBashTool, and configure it with options.

To give a bash tool to an agent, use a provider integration such as AI SDK or Pi. The integration gives you the tool definition: the name, the description, and the parameter schema. createAiSdkBashTool takes the options on this page, plus a signature from defaultBashSignature() in @better-fs-tools/shell/signature.

createBashTool is the lower-level API. It creates the function that implements the tool. Use it when you write your own integration, or when your code runs commands for the agent.

createBashTool needs a runner to start the command, and an env with the environment of the command. All other options are optional:

import { nodeCommandRunner } from "@better-fs-tools/node";
import { createBashTool, shellEnv } from "@better-fs-tools/shell";

const bash = createBashTool({
  runner: nodeCommandRunner(),
  env: shellEnv(() => process.env),
});

nodeCommandRunner() from @better-fs-tools/node runs bash -c on the local machine, in process.cwd() by default. shellEnv(() => process.env) gives the command the host environment. Each host has a runner that suits it, for example piCwdCommandRunner() for Pi and durableEnvRunner() for pi-durable.

To get the bash tool with the file tools, set bash on a bundle: createFsTools({ fs, bash: { runner, env } }). The bash tool then gets the bundle's digest and clock. See Choose an integration.

OptionDefaultUse it to
runnerRequiredStart the command: on this machine, in an emulated shell, or in your own sandbox
envRequiredSet the whole environment of the command
cwdThe runner's cwdSet the default working directory
resolvenullRewrite the cwd input before the tool uses it
formatterdefaultShellFormatter()Change the text the tool returns
messagesdefaultShellMessagesChange the wording of status lines and notes
limitsdefaultShellLimitsChange the timeouts and the output limits
spillnullSave the full output, for example to a file
beforeRun[]Refuse or rewrite a command
authorizenull (allow all)Refuse a command, or ask the user
afterRun[]Change the output or the notes after the run
digestsha256Digest()Give a digest to your hooks. The tool hashes nothing
clock() => new Date()Give a clock to your hooks

limits and messages merge with their defaults key by key. Every other option replaces its default. createBashTool throws TypeError for an unknown option, a missing runner or env, or an option with the wrong shape.

The bash function

createBashTool returns bash(input, ctx?). input is { command, timeoutMs?, cwd? }. cwd is relative to the default working directory, or absolute. bash returns a Promise<ShellResult>:

import { textOf } from "@better-fs-tools/shell";

const result = await bash({ command: "ls", cwd: "src" });

result.status; // "ok"
textOf(result); // the text for the model
if (result.status !== "error") {
  result.run.exitCode; // 0
  result.output.head; // "greet.ts"
}
StatusWhen
okThe command exited with code 0
failedThe command exited with another code, or a signal stopped it
timeoutThe timeout stopped the command. The output so far is kept
errorThe tool did not run the command, or stopped it. result.error.code tells you why

failed is a normal result. The model sees the exit code and the output, and can fix the command. result.content holds the parts to send to the model, and textOf(result) joins its text parts. result.notes holds each note as data. The bash reference lists every field and error code.

Each call starts a new shell, with stdin closed. A cd or an export does not carry to the next call. The model uses the cwd input in its place.

ctx is optional. It holds signal to stop the command, callId, and host. The tool passes host to your options, for example to pick a runner for each session.

Formatting

By default the tool returns a status line, then the output, then notes:

Exit code 2 · 0 s
ls: cannot access 'lib': No such file or directory
Timed out after 0.1 s. The process tree was stopped.
(no output)

A call that did not run returns only a note:

[bash:invalid-input] The bash input was rejected: command must be a non-blank string

Set formatter to change the layout, and messages to change the wording. The formatter changes only result.content. See Customise formatting.

Runners and environment

The runner starts the command. nodeCommandRunner() runs bash -c on the local machine. justBashCommandRunner() from @better-fs-tools/just-bash runs an emulated shell with no process. You can also write a runner for your own sandbox.

env is the whole environment of the command. Nothing else gets to the command. shellEnv(() => process.env) passes the host environment, and shellEnv({ PATH: process.env.PATH }) passes only the variables you list. Every shellEnv() adds PAGER=cat, GIT_PAGER=cat, GIT_TERMINAL_PROMPT=0, NO_COLOR=1, and TERM=dumb, so a command does not wait for a pager or a prompt. See Runners and environment.

Timeouts and output

A command stops after 2 minutes, unless the model sets a timeout. A timeout over 10 minutes is cut to 10 minutes. At the timeout, the tool stops the whole process tree. The model sees at most 30,000 bytes or 2,000 lines of output: the first part, a line that says how much is not shown, and the last part. Set limits to change these values:

const bash = createBashTool({
  runner: nodeCommandRunner(),
  env: shellEnv(() => process.env),
  limits: { defaultTimeoutMs: 30_000, maxOutputLines: 5 },
});

With these limits, cat logs/server.log shows one line at the start and four at the end:

Exit code 0 · 0 s
2026-10-09T10:00:00Z request 0 ok
[… 4995 lines (183.7 KB) not shown]
2026-10-09T10:23:16Z request 4996 ok
2026-10-09T10:23:17Z request 4997 ok
2026-10-09T10:23:18Z request 4998 ok
2026-10-09T10:23:19Z request 4999 ok

Set spill to save the full output, so the model can read the part that is not shown. See Timeouts and output.

Policy and hooks

The allowedRoots of the filesystem do not limit a command. A command can read, change, or delete any file that the process can get to. The package ships no rules for commands. Add your policy with beforeRun and authorize, and run commands in a sandbox through the runner.

beforeRun hooks check or rewrite the command. authorize makes the last decision on the command that runs, for example with a prompt to the user. afterRun hooks change the output and the notes. This rule allows only the commands on a list:

import type { ShellAuthorizer } from "@better-fs-tools/shell";

const allowList: ShellAuthorizer = {
  id: "allow-list",
  authorize: (target) =>
    ["git status", "git diff", "bun test"].includes(target.command)
      ? { allow: true }
      : {
          allow: false,
          note: {
            code: "not-allowed",
            severity: "warning",
            message: `${target.command} is not on the allow list. Ask the user first.`,
          },
        },
};

const bash = createBashTool({
  runner: nodeCommandRunner(),
  env: shellEnv(() => process.env),
  authorize: allowList,
});
[bash:denied] rm -rf build is not on the allow list. Ask the user first.

A refusal from authorize returns status error with code DENIED. A refusal from beforeRun returns REFUSED. See Hooks and policy.

On this page