bashkit

Scripted tool orchestration#

Give an LLM ten tools and a ten-step task, and you pay for ten round-trips, each call is a separate turn, with the model re-reading context every time. ScriptedTool collapses that into one call: the model writes a single bash script that invokes your tools, pipes their output through jq, loops, branches, and returns one composed result.

Each tool you register becomes a builtin command inside a locked-down bash interpreter. The LLM orchestrates them with the full shell grammar it already knows, variables, pipelines, for, if, instead of a sequence of isolated tool calls.

LLM one call bash script pipes · vars · loops logic-only shell get_user --id 1 → callback list_orders --user_id 1 create_discount --pct 10

Building one#

A tool is a ToolDef (name, description, JSON-Schema input) paired with a callback that returns stdout on success or an error string on failure:

use bashkit::{ScriptedTool, ToolArgs, ToolDef, Tool};

fn get_user(args: &ToolArgs) -> Result<String, String> {
    let id = args.param_i64("id").ok_or("missing --id")?;
    Ok(format!(r#"{{"id":{id},"name":"Ada","tier":"gold"}}"#))
}

let def = ToolDef::new("get_user", "Fetch user by ID").with_schema(serde_json::json!({
    "type": "object",
    "properties": { "id": {"type": "integer", "description": "User ID"} },
    "required": ["id"],
}));

let tool = ScriptedTool::builder("ecommerce_api")
    .short_description("User, order, and inventory tools")
    .tool_fn(def, get_user)
    .build();

// The LLM sends one script; tools compose with pipes and jq.
let out = tool
    .execution(serde_json::json!({ "commands": "get_user --id 1 | jq -r '.name'" }))?
    .execute()
    .await?;
assert_eq!(out.result["stdout"], "Ada\n");

Flags parse from the schema: --id 1 becomes {"id": 1} (coerced per the schema’s property types). Use .async_tool_fn(def, cb) for async callbacks, sync and async tools mix freely in one ScriptedTool. The full e-commerce demo lives in examples/scripted_tool.rs.

Code mode, not a file shell#

ScriptedTool always runs in logic mode: bash is the control-flow and data-transformation language, not a virtual filesystem shell. This is a deliberate, narrower sandbox than BashTool.

KeptRejected
variables, arrays, functions, arithmeticfile commands (cat, ls, cp, rm, mkdir, …)
if / case / for / whilepath execution (/tmp/x.sh, $PATH lookup)
pipelines, heredocs, command substitutionfile redirection (>, >>, <) except /dev/null
your tool commands + help + discoverprocess substitution
stdin transforms: jq, grep, sed, awk, sort, cut, tr, wc, head, tail, seq, expr

Reach for BashTool instead when a virtual filesystem is part of the task.

Arguments a ToolDef cannot express#

Tool commands are parsed from their JSON Schema, which covers --key value and --key=value and nothing else. No positionals, no short flags, no --, and a missing required field surfaces as a deserialization error after dispatch rather than as usage. When a command needs a real CLI shape, register it as a builtin instead and parse the raw argv yourself:

let tool = ScriptedTool::builder("control_plane")
    .builtin("get_agent", Box::new(GetAgent))   // clap owns this one
    .tool_fn(list_agents_def, list_agents)      // schema owns this one
    .build();

GetAgent can implement ClapBuiltin, so get_agent a-42 -f json works, and get_agent alone fails at parse time with a real usage message. The builtin runs in the same logic-only shell and gets the same disabled filesystem, so this does not widen what a script can touch. Tool commands win on a name collision, so a builtin cannot shadow one, nor help or discover. Full example: scripted_tool_clap_builtin.rs.

For schema-parsed commands, adding "additionalProperties": false turns an unrecognised flag into an error listing the valid ones, instead of silently keeping it as an unused string property, so list_agents --limti 10 fails loudly rather than quietly ignoring the limit.

Runtime discovery#

The LLM doesn’t need every schema in its context up front. Two built-in commands let it explore at runtime:

  • help --list, help <tool>, help <tool> --json, names, usage, and machine-readable schemas (enum values, required fields).
  • discover --categories | --category X | --tag Y | --search text, filter by the tags / category you set on each ToolDef.

For large tool sets, ScriptedToolBuilder::compact_prompt(true) shrinks the system prompt to names + one-liners and defers full schemas to help.

ScriptingToolSet formalises this: in WithDiscovery mode it exposes a compact script tool plus a companion discover tool, so the model browses schemas before writing a script, ideal alongside other tools or for 50+ tool sets.

Safety#

ScriptedTool inherits every sandbox guarantee (resource limits, no network unless configured) and adds a disabled filesystem backend and reduced builtin surface. Each execute() gets a fresh interpreter, so there is no state bleed between calls; persistence is your callbacks’ concern (capture an Arc). Callback error strings are sanitised by default so host-side secrets, paths, and stack traces never reach script-visible stderr.

See also#