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.
| Option | Default | Use it to |
|---|---|---|
runner | Required | Start the command: on this machine, in an emulated shell, or in your own sandbox |
env | Required | Set the whole environment of the command |
cwd | The runner's cwd | Set the default working directory |
resolve | null | Rewrite the cwd input before the tool uses it |
formatter | defaultShellFormatter() | Change the text the tool returns |
messages | defaultShellMessages | Change the wording of status lines and notes |
limits | defaultShellLimits | Change the timeouts and the output limits |
spill | null | Save the full output, for example to a file |
beforeRun | [] | Refuse or rewrite a command |
authorize | null (allow all) | Refuse a command, or ask the user |
afterRun | [] | Change the output or the notes after the run |
digest | sha256Digest() | 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"
}| Status | When |
|---|---|
ok | The command exited with code 0 |
failed | The command exited with another code, or a signal stopped it |
timeout | The timeout stopped the command. The output so far is kept |
error | The 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 directoryTimed 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 stringSet 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 okSet 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.