Tools Guide
Tools are the actions an agent can perform. Abbyfile supports two kinds: CLI tools that wrap external commands, and builtin tools that run Go functions in-process.
CLI Tools
tools.CLI() wraps any command-line binary as a tool:
tools.CLI("date", "date", "Get the current date and time")
// ^name ^command ^description
When Claude Code calls this tool through MCP, the agent binary runs the command as a subprocess. Arguments are passed via the args field in the tool’s input schema.
The generated input schema for CLI tools is:
{
"type": "object",
"properties": {
"args": {
"type": "string",
"description": "Command-line arguments to pass to the tool"
}
}
}
The args string is split on whitespace and appended to any default arguments. You can set default arguments on the definition:
def := tools.CLI("lint", "golangci-lint", "Run Go linter")
def.Args = []string{"run", "--fast"}
If Claude passes {"args": "--fix"}, the final command becomes golangci-lint run --fast --fix.
Minimal example:
agent.WithTools(
tools.CLI("date", "date", "Get the current date and time"),
),
This is the simplest possible tool — it wraps the date command with no default arguments.
Builtin Tools
tools.BuiltinTool() creates a tool backed by a Go function:
tools.BuiltinTool(name, description, schema, handler)
The handler signature is:
func(input map[string]any) (string, error)
inputis the parsed JSON input from the MCP call- Return a string result on success, or an error
- The schema is a
map[string]anymatching JSON Schema format
Example: read_file builtin tool
func readFileTool() *tools.Definition {
return tools.BuiltinTool(
"read_file",
"Read the contents of a file. Returns the file content as text.",
map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file to read (relative to project root)",
},
},
"required": []string{"path"},
},
func(input map[string]any) (string, error) {
path, ok := input["path"].(string)
if !ok || path == "" {
return "", fmt.Errorf("missing required parameter: path")
}
clean := filepath.Clean(path)
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
return "", fmt.Errorf("path must be relative and within the project")
}
data, err := os.ReadFile(clean)
if err != nil {
return "", fmt.Errorf("reading %s: %w", clean, err)
}
return string(data), nil
},
).WithAnnotations(&tools.Annotations{
ReadOnlyHint: true,
IdempotentHint: true,
OpenWorldHint: tools.BoolPtr(false),
Title: "Read File",
})
}
This example uses the plain Handler for brevity, with its own hand-rolled path check. The shipped read_file builtin instead uses tools.BuiltinToolCtx and HandlerCtx, confining paths through the sandbox described in Sandbox rather than a local ../absolute-path check.
Example: go_test builtin tool
func goTestTool() *tools.Definition {
return tools.BuiltinTool(
"go_test",
"Run Go tests for a package. Returns test output including pass/fail status.",
map[string]any{
"type": "object",
"properties": map[string]any{
"package": map[string]any{
"type": "string",
"description": "Go package pattern to test (e.g. ./pkg/tools/..., ./...)",
"default": "./...",
},
},
},
func(input map[string]any) (string, error) {
pkg := "./..."
if p, ok := input["package"].(string); ok && p != "" {
pkg = p
}
cmd := exec.Command("go", "test", "-race", "-count=1", pkg)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Sprintf("FAIL\n%s", string(out)), nil
}
return string(out), nil
},
).WithAnnotations(&tools.Annotations{
DestructiveHint: tools.BoolPtr(false),
IdempotentHint: true,
OpenWorldHint: tools.BoolPtr(false),
Title: "Run Go Tests",
})
}
Note that go_test returns test failures as successful tool results (not errors). This lets Claude Code see the test output and reason about it. Reserve errors for infrastructure failures, not expected negative results.
Sandbox
Built-in tools are confined by a sandbox: block in agent frontmatter. Without the block you get the defaults shown here:
sandbox:
allowed_dirs: ["."] # file tools stay inside these; "." = the working directory the runtime starts the server in
bash: restricted # restricted (default) | unrestricted
allow_commands: [] # run_command refuses every call until you list commands
max_command_timeout: 120s # hard cap on the timeout the model may request
File tools (read_file, write_file, edit_file, glob_files, grep_search):
- Every path is resolved (relative paths against the working directory, symlinks evaluated) and must land inside an
allowed_dirsentry. glob_filesrefuses any pattern containing a..path element outright (pattern must not contain ".."— use thepathargument to choose the base directory instead). A plain, non-**pattern drops hits that escape the sandbox (via a symlinked directory) silently; only the**walk andgrep_searchreport how many entries they skipped, appending “(N entries outside the allowed directories were skipped)”.grep_searchopens the symlink-resolved path, not the original one.- The agent’s spill directory (
~/.abbyfile/<name>/spill/) is readable but not writable, soon_overflow: spillpointers still work. allowed_dirs: ["/"]opts out. It is warned about at startup and in--describe.
run_command, restricted mode:
- There is no shell. The command is split into words, with
'and"quoting honoured, and run directly. - Unquoted
;&|`$(><and newlines are rejected, not filtered. Pipes, redirects and chaining needbash: unrestricted. allow_commandsentries are words:go testallows exactlygo test.go test *allowsgo testwith any arguments.*is only valid as the last word.
- The effective timeout is
min(requested or 30s, max_command_timeout). - Subprocesses — both
run_commandand custom CLI tools — run in their own process group. The group is killed on timeout or cancellation, and it is also reaped after a normal exit, so an ordinary background child (&,nohup) does not outlive the call. A child that callssetsidor otherwise daemonizes into a new session escapes the group and can still outlive it. If a well-behaved child exited successfully but left a background process holding the output pipe open, the call reports success after a short (~2s) wait rather than hanging. - Output is capped in memory at 10 MB.
run_command, unrestricted mode: the old sh -c behaviour. abby build prints a warning and --describe reports it.
What the sandbox does not do:
- It does not confine
run_commandarguments.cat *can read any file.git *,find *,env *,xargs *andmake *(withwrite_file) can run arbitrary programs. Allow the narrowest commands you can. - It does not fully confine the Go toolchain either: an allowlisted
go test */go build */go vet *still permitsgo test -exec <prog>,go build -toolexec <prog>andgo vet -vettool=<prog>, each of which runs an arbitrary program;gofmt -w <path>can write to any path the process can reach, not justallowed_dirs; andgo testitself runs the package’s (possibly model-written) test code. Prefer exact argv entries with no trailing*(e.g.go test ./...rather thango test *) so the model cannot add these flags. - It is not an OS sandbox (no seatbelt or landlock).
- It checks each path when the call is made, so it does not defend against a local process racing to swap symlinks.
read_fileon a FIFO (named pipe) blocks until a writer opens the other end; this is a documented limitation, not something the sandbox or the executor timeout currently guards against.
Startup fallback. If the effective sandbox — compiled defaults plus any config.yaml override — fails to build, the agent prints an error to stderr and falls back rather than exiting; this runs on every subcommand, not only serve-mcp. When a config.yaml sandbox override caused the failure (for example a hand-edited config with sandbox.allowed_dirs: [""]), it retries the compiled-in sandbox with a config reset sandbox hint. If the compiled sandbox is itself invalid — with or without an override — file and command tools are disabled outright (a deny-all sandbox: no allowed directories, no allowed commands) rather than widening access to the default sandbox (the working directory).
--describe includes a sandbox object: allowedDirs, bash, allowCommands, maxCommandTimeout, and warnings (present only when there is at least one).
Changing it after install (restart the runtime session afterwards):
my-agent config set sandbox.allow_commands '["go test *","make *"]' # JSON array, or: "go test *,make *"
my-agent config set sandbox.allowed_dirs ".,../shared"
my-agent config set sandbox.bash unrestricted # prints a warning
my-agent config set sandbox.max_command_timeout 300s
my-agent config reset sandbox
config set sandbox.*validates the merged override — all four fields together, not just the one being set — and refuses to write one that would leave the sandbox unusable.- List values (
allow_commands,allowed_dirs) drop blank entries in both the JSON-array and comma-separated forms. - Every
sandbox.*set prints a restart hint on stdout, since a running MCP session already loaded the old sandbox. Settingbash unrestricted, orallowed_dirsto something containing/, also prints a warning to stderr.
Migrating from v0.10
tools: Bashnow needssandbox.allow_commands, orsandbox.bash: unrestricted. Until thenrun_commandrefuses every call andabby buildprints a note.- File tools are confined to the server’s working directory unless
sandbox.allowed_dirssays otherwise. - Restricted
run_commandno longer uses a shell. Pipes, redirects and chaining needsandbox.bash: unrestricted. write_fileandedit_fileare now annotated destructive.- Library users: builtins use
tools.Definition.HandlerCtx.Handlerstill works, but it runs under the default sandbox with no deadline.tools.DefaultCommandPolicy()no longer has a denylist. - Custom CLI tools, not just
run_command, now run in their own process group that is killed after the call returns, so a background child started with&ornohupno longer outlives the call (one that callssetsidor otherwise daemonizes into a new session still escapes and can outlive it). - CLI tool output capture is capped at 10 MB in memory (
tools.DefaultMaxOutputBytes); output beyond that is truncated with a[output truncated at N bytes]marker.
Annotations
Tool annotations provide MCP clients with hints about tool behavior. They are hints only – clients should not make security decisions based on them.
def.WithAnnotations(&tools.Annotations{
ReadOnlyHint: true, // tool does not modify state
DestructiveHint: tools.BoolPtr(false), // tool is not destructive (nil = MCP default true)
IdempotentHint: true, // safe to call multiple times
OpenWorldHint: tools.BoolPtr(false), // tool operates in a closed system (nil = MCP default true)
Title: "Human-Readable Name",
})
Annotation fields:
| Field | Type | Default | Meaning |
|---|---|---|---|
ReadOnlyHint |
bool |
false |
Tool does not modify state |
DestructiveHint |
*bool |
nil (MCP default: true) |
Tool may destructively modify state |
IdempotentHint |
bool |
false |
Calling multiple times with same input has same effect |
OpenWorldHint |
*bool |
nil (MCP default: true) |
Tool interacts with external systems |
Title |
string |
"" |
Human-readable title for the tool |
For pointer fields, use tools.BoolPtr(value) to set an explicit value. nil means “use the MCP default.”
Input Validation
Tool definitions validate input against their schema before execution. The ValidateInput() method checks:
- Required fields are present
- Property types match the declared JSON Schema type
def := tools.BuiltinTool("example", "desc", map[string]any{
"type": "object",
"properties": map[string]any{
"key": map[string]any{"type": "string"},
},
"required": []string{"key"},
}, handler)
err := def.ValidateInput(map[string]any{"key": 123.0})
// error: field "key": expected string, got number
Validation runs automatically in the run-tool subcommand before executing the tool. MCP tool calls also go through the executor, which handles errors and returns them to the client.
Supported types: string, number, integer, boolean, array, object.
Tool Timeout
Set a global tool execution timeout with WithToolTimeout():
agent.WithToolTimeout(60 * time.Second) // default is 30s
This applies to both CLI and builtin tools. CLI tools that exceed the timeout are killed. The executor returns a timeout error.
Registering Multiple Tools
Use variadic WithTools():
agent.WithTools(
tools.CLI("date", "date", "Get current date"),
tools.CLI("uptime", "uptime", "System uptime"),
readFileTool(),
goTestTool(),
),
Or call WithTools() multiple times – definitions accumulate:
agent.WithTools(cliTools()...),
agent.WithTools(builtinTools()...),
Memory Tools (Automatic)
When memory is enabled (WithMemory(true)), five builtin tools are automatically registered:
memory_read– read a value by keymemory_write– write a value (overwrites existing)memory_list– list all keysmemory_delete– delete a keymemory_search– search for a substring across all memory values
These appear in --describe and are exposed via MCP. You do not register them manually. See the Memory Guide for details.