Bash tool plugins
Bash tool plugins are out-of-process ToolPlugin implementations backed by a
single bash script. They are a good fit for small shell-native tools, command
wrappers, and simple file/text utilities that are easiest to express as shell
scripts.
This page is the canonical authoring guide for bash_tool plugins.
Runtime references (source of truth):
- Loader / plugin source:
core/python/agent_app/plugin_sources/bash_tool.py - Python proxy plugin:
core/python/agent_core/tool_hosts/bash_tool_plugin.py - Config + file contract:
docs/plugins/application-config.md
Reference implementations:
- Template:
plugins/template-bash-tool - Minimal real example:
plugins/bash-read-line-range
See also:
What Bash tools are for
Use a Bash tool when:
- the tool is naturally a shell script
- you want a very small dependency footprint
- the tool mainly wraps existing CLI utilities
- the logic is straightforward enough to express through shell subcommands
Prefer an in-process Python tool when:
- you need richer data structures or complex validation
- you need the payload-first/custom-freeform Python tool API
- you want easier portability or deeper unit testing
Important current limitations:
- one bash tool file maps to one tool
- the schema contract is currently centered on classic function-style schemas
- the bash host does not expose the newer Python payload-first/custom-freeform contract directly
Development workflow
Recommended workflow:
- start from
plugins/template-bash-tool - implement
schema,preview, andrun - test the script directly from the shell
- test it through the Python plugin manager
- load it in a real app config with
allow_bash_tools: true
Step 0: package layout
Minimal repo layout:
my-bash-tool/
agent_plugin.json
my_tool.bash
pyproject.toml
tests/
test_my_tool.py
Example agent_plugin.json:
{
"bash_tools": [
{ "file": "my_tool.bash" }
]
}
For single-file tools, you can also skip the repo descriptor and load the file
directly via a bash_tool.file config spec.
Step 1: write the bash tool file
Each bash tool file is invoked as:
bash /abs/path/to/tool.bash <subcommand> [args...]
Supported subcommands:
schemapreviewrunerror(optional)
Runtime environment variables injected by the host include:
AGENT_TOOL_PYTHON- absolute path to the exact Python interpreter running the bash tool host
- useful for bash tools that need to invoke helper Python scripts without
rediscovering
python3throughPATH AGENT_TOOL_TIMED_OUT- set to
1when the host invokes the optionalerrorsubcommand after a timeout AGENT_TOOL_TIMEOUT_SECONDS- timeout value associated with a timed-out invocation
Minimal working example:
#!/usr/bin/env bash
set -euo pipefail
subcommand="${1:-}"
shift || true
case "$subcommand" in
schema)
cat <<'JSON'
{
"id": "template_bash_echo",
"version": "0.1.0",
"args_mode": "positional",
"positional": [
{"name": "value", "required": true},
{"name": "uppercase", "default": false}
],
"tools": [
{
"type": "function",
"function": {
"name": "template_bash_echo",
"description": "Echo a value (optionally uppercase it).",
"parameters": {
"type": "object",
"properties": {
"value": {"type": "string"},
"uppercase": {"type": "boolean", "default": false}
},
"required": ["value", "uppercase"]
}
}
}
]
}
JSON
;;
preview)
value="${1:-}"
uppercase="${2:-false}"
echo "template_bash_echo value=$(printf %q "$value") uppercase=$uppercase"
;;
run)
value="${1:-}"
uppercase="${2:-false}"
if [[ "$uppercase" == "true" ]]; then
printf '%s' "$value" | tr '[:lower:]' '[:upper:]'
else
printf '%s' "$value"
fi
;;
error)
exit_code="${1:-1}"
echo "Error: template_bash_echo failed (exit_code=$exit_code)"
exit 0
;;
*)
echo "Usage: $0 {schema|preview|run|error}" >&2
exit 2
;;
esac
This mirrors plugins/template-bash-tool/template_bash_echo.bash.
Step 2: load it in config
Single-file form:
{
"plugins": [
{ "bash_tool": { "file": "./tools/my_tool.bash" } }
],
"plugin_policy": {
"allow_bash_tools": true
}
}
Directory form:
{
"plugins": [
{ "bash_tool": { "path": "./plugins/my-bash-tool" } }
],
"plugin_policy": {
"allow_bash_tools": true
}
}
Git form:
{
"plugins": [
{
"bash_tool": {
"git": "https://github.com/acme/my-bash-tool.git",
"ref": "v0.1.0"
}
}
],
"plugin_policy": {
"allow_bash_tools": true
}
}
String shortcuts:
{
"plugins": [
"bash:./plugins/my-bash-tool",
"bash+git:https://github.com/acme/my-bash-tool.git#v0.1.0"
],
"plugin_policy": {
"allow_bash_tools": true
}
}
Bash tool file contract
schema
schema must print a JSON object to stdout.
Required fields:
{
"id": "my_tool",
"version": "0.1.0",
"args_mode": "flags",
"tools": [
{
"type": "function",
"function": {
"name": "my_tool",
"parameters": {
"type": "object",
"properties": {}
}
}
}
]
}
Current v1 constraints:
- exactly one tool schema per bash file
schema.idmust matchtools[0].function.name
Optional fields:
config_keys- list of config keys that the host should expose to the bash subprocess
- each key is passed as an environment variable named:
AGENT_TOOL_CONFIG_<UPPER_SNAKE_CASE_KEY>
- only scalar config values are passed through; lists/dicts are ignored
Example:
{
"id": "send_to_kindle",
"version": "0.1.0",
"args_mode": "json",
"config_keys": [
"send_to_kindle_smtp_user",
"send_to_kindle_default_to"
],
"tools": [
{
"type": "function",
"function": {
"name": "send_to_kindle",
"parameters": {"type": "object", "properties": {}}
}
}
]
}
preview
preview should print a short single-line preview to stdout and exit 0.
Example:
preview)
path="${1:-}"
echo "read_line_range path=$(printf %q "$path")"
;;
If preview exits non-zero, the host falls back to an empty preview string.
run
run is the actual tool execution path. Stdout becomes streamed output and
contributes to the final tool message.
Example:
run)
path="${1:-}"
start="${2:-1}"
end="${3:-1}"
sed -n "${start},${end}p" "$path"
;;
error (optional)
When run fails or times out, the host may invoke:
bash tool.bash error <exit_code> [args...]
If error exits 0 and prints non-empty stdout, that text becomes the final
tool message content. Otherwise the host falls back to default formatting.
On timeout, the host sets:
AGENT_TOOL_TIMED_OUT=1AGENT_TOOL_TIMEOUT_SECONDS=<n>
In normal schema / preview / run / error invocations, the host also sets:
AGENT_TOOL_PYTHON=<sys.executable>
This lets bash tools reliably reuse the same Python environment as the host. Recommended pattern:
PYTHON_BIN="${AGENT_TOOL_PYTHON:-$(command -v python3)}"
"${PYTHON_BIN}" ./helper.py
Argument passing
The contract is selected by args_mode.
flags
Arguments are converted to CLI flags.
Rules:
- scalar:
--name <value> - boolean
true:--name - boolean
false:--no-name
Example schema fragment:
{
"args_mode": "flags"
}
positional
Arguments are mapped to ordered argv values using schema.positional.
Example:
{
"args_mode": "positional",
"positional": [
{ "name": "path", "required": true },
{ "name": "start_line", "required": true },
{ "name": "end_line", "required": true },
{ "name": "show_line_numbers", "default": false }
]
}
This is the mode used by plugins/bash-read-line-range.
json
For json mode, the host calls:
run --args-jsonpreview --args-jsonerror <code> --args-json
The JSON arguments object is written to stdin.
This is useful when:
- arguments are awkward to represent as flags/positionals
- you want to parse them with
jq
Example pattern:
run)
if [[ "${1:-}" == "--args-json" ]]; then
args_json="$(cat)"
value="$(printf '%s' "$args_json" | jq -r '.value')"
printf '%s' "$value"
exit 0
fi
;;
Runtime behavior
Working directory
The host uses config["working_directory"] when present to set the subprocess
cwd. If it is absent, the default shell/process cwd is used.
Selected config propagation
When a bash tool schema declares config_keys, the host reads those keys from
the resolved request config (state["config"]) and exposes them as
environment variables:
- config key:
send_to_kindle_smtp_user - subprocess env:
AGENT_TOOL_CONFIG_SEND_TO_KINDLE_SMTP_USER
This is the recommended way for bash tools to consume agent/provider/mixin
defaults that may themselves come from ${env:...} placeholders in config.
Recommended pattern:
SMTP_USER="${AGENT_TOOL_CONFIG_SEND_TO_KINDLE_SMTP_USER:-}"
Python interpreter propagation
The host exports:
AGENT_TOOL_PYTHON
with the value of the host process's sys.executable.
This is the preferred interpreter for bash tools that call Python helpers because it preserves:
- the same virtual environment or packaged runtime as the host
- the same installed dependencies
- the same certificate store / interpreter-specific runtime behavior
Prefer this over rediscovering python3 via shell PATH when possible.
Streaming
Tool stdout is streamed as partial output.
When enabled by policy, stderr may also be streamed.
The final tool message is emitted after the process exits and formatting is applied.
Timeouts
The host uses:
plugin_policy.bash_timeout_secondswhen provided at load time- otherwise the default timeout in
BashToolPlugin
If the process times out, it is reported as a failure and the optional error
subcommand may still be used to format the final tool message.
Tool name handling
The current bash host assumes one tool per file and enforces that the tool name
matches the schema/file identity. This is why schema.id and
tools[0].function.name must match.
Testing Bash tools
Recommended layers:
- direct shell testing of
schema,preview,run, anderror - Python plugin-manager tests
- full app config tests with
allow_bash_tools: true
Direct shell checks
These are the fastest loop when developing:
bash ./my_tool.bash schema
bash ./my_tool.bash preview hello true
bash ./my_tool.bash run hello true
bash ./my_tool.bash error 1
Python-side tests
Useful pattern:
pytest plugins/template-bash-tool/tests -q
pytest plugins/bash-read-line-range/tests -q
Good things to verify:
- schema JSON is valid
idmatches the function name- argument passing works for the chosen
args_mode - timeout/failure formatting is reasonable
- directory and single-file loading both work
- helper scripts use
AGENT_TOOL_PYTHONcorrectly when Python is involved - declared
config_keysarrive as expected in subprocess env
Common pitfalls
- Forgetting to enable
plugin_policy.allow_bash_tools - Loading a bash tool repo with
path:...instead ofbash:...or{"bash_tool": ...} - Printing invalid JSON from
schema - Mismatching
schema.idandtools[0].function.name - Returning multiple tools from one file
- Relying on shell features or external commands not present in target environments
- Forgetting to quote shell arguments safely
- Assuming the bash host supports the in-process Python payload-first/freeform API
Reference examples
plugins/template-bash-toolplugins/bash-read-line-rangedocs/plugins/application-config.md