Tool

Give agents real capabilities: files, commands, sub-agents, and human approvals.

What it is

Tools are callable functions the LLM can request during an agent run. They are the primary way an agent interacts with the outside world: reading/writing files, executing commands, delegating to sub-agents, or asking a human for confirmation.

Phero tools are just function tools from llm, registered on an agent.

Built-in tool packages

Phero includes ready-to-use tools under tool/. Each tool is constructed with a New… function and exposes a .Tool() method that returns the *llm.Tool to pass to AddTool. Exact behavior can be customized with llm.Tool.Use(...) middleware.

Tip: for anything with side effects (writes or shell commands), add an approval middleware.

Agent tool (tool/agent)

The agent tool lets an orchestrator delegate an open-ended task to a purpose-built sub-agent at runtime. The LLM provides the sub-agent's name, description (system prompt), and input (task instructions). The tool creates an agent, runs it, and returns the text result.

import agenttool "github.com/henomis/phero/v2/tool/agent"

// Pass the same LLM client used by the orchestrator.
agentTool, err := agenttool.New(llmClient)
if err != nil {
    panic(err)
}

if err := a.AddTool(agentTool.Tool()); err != nil {
    panic(err)
}

Optionally pass tools that every created sub-agent will have access to:

readTool, _  := file.NewReadTool()
writeTool, _ := file.NewWriteTool()

agentTool, err := agenttool.New(llmClient, readTool.Tool(), writeTool.Tool())
if err != nil {
    panic(err)
}

The JSON input schema expected from the LLM:

Errors: ErrLLMRequired, ErrNilInput, ErrNameRequired, ErrDescriptionRequired, ErrInputRequired.

Filesystem tools (tool/file)

Each filesystem operation is a separate constructor returning its own typed struct. All constructors accept shared Option values.

import "github.com/henomis/phero/v2/tool/file"

readTool, err  := file.NewReadTool(file.WithWorkingDirectory("/workspace"))
writeTool, err := file.NewWriteTool(file.WithWorkingDirectory("/workspace"))
editTool, err  := file.NewEditTool(file.WithWorkingDirectory("/workspace"))
globTool, err  := file.NewGlobTool(file.WithWorkingDirectory("/workspace"))
grepTool, err  := file.NewGrepTool(file.WithWorkingDirectory("/workspace"))

// Each exposes .Tool() to get the *llm.Tool:
if err := a.AddTool(readTool.Tool()); err != nil {
    panic(err)
}

read returns plain cat -n text (a 6-wide line number, a tab, then the line), not JSON. Tabs and quotes reach the model as they are in the file, so it can copy them into an edit's old_string. file.ReadOutput is deprecated since v2.3.0. Migrating from v2.2: code that calls the read tool's handler directly and decoded its JSON (file.ReadOutput) now gets a string: the numbered lines, followed by a note when the read was cut or paged.

Available options:

multi_edit (file.NewMultiEditTool) applies a list of exact replacements to one file, in order, each seeing the result of the ones before it. Either every edit applies or none does, so a failed edit never leaves the file half changed. edit, multi_edit and write are exclusive tools: when the model calls several at once, they run one at a time, in call order.

Each of the three tools has a Preview(input) method that returns the *file.Change a call would make, without writing it. It runs the call's own code and checks (uniqueness, replace_all, the size limit, the read tracker, the working directory), so it fails exactly as the call would. A permission dialog can show the diff before the call runs. The tools decode their arguments with encoding/json, so the dialog can decode Request.Arguments into file.EditInput the same way. file.WriteFileAtomic writes a file as the tools do (a temporary file, then a rename), for example to restore a change's Before.

journal := []file.Change{}
opts := []file.Option{
    file.WithWorkingDirectory("/workspace"),
    file.WithOnWrite(func(_ context.Context, c file.Change) { journal = append(journal, c) }),
}
editTool, _ := file.NewEditTool(opts...)

// In the permission Asker, before the call runs:
var in file.EditInput
if err := json.Unmarshal([]byte(req.Arguments), &in); err == nil {
    if change, err := editTool.Preview(&in); err == nil {
        showDiff(change.Path, change.Before, change.After)
    }
}

// Undo the last change:
last := journal[len(journal)-1]
if last.Existed {
    err = file.WriteFileAtomic(last.Path, last.Before, last.Perm)
} else {
    err = os.Remove(last.Path)
}

glob and grep are built for real repositories. They skip .git, .hg, .svn and node_modules, and what the .gitignore files ignore, from the repository root down (negation, directory-only and anchored patterns included). grep skips binary files and clips very long lines, such as minified code. With a working directory, a symlinked file whose target is outside it is skipped.

Using middleware to require user confirmation before a write:

writeTool, err := file.NewWriteTool(file.WithWorkingDirectory("/workspace"))
if err != nil {
    panic(err)
}

writeTool.Tool().Use(func(_ *llm.Tool, next llm.ToolHandler) llm.ToolHandler {
    return func(ctx context.Context, arguments string) (any, error) {
        var input file.WriteInput
        if err := json.Unmarshal([]byte(arguments), &input); err != nil {
            return nil, err
        }
        fmt.Printf("Write to %s? (y/N): ", input.FilePath)
        var answer string
        fmt.Scanln(&answer)
        if !strings.EqualFold(answer, "y") {
            return nil, fmt.Errorf("write denied by user")
        }
        return next(ctx, arguments)
    }
})

if err := a.AddTool(writeTool.Tool()); err != nil {
    panic(err)
}

Bash tool (tool/bash)

The bash tool runs shell commands with configurable guardrails and supports background execution. bash.New returns a *Tool that exposes three *llm.Tool values:

import (
    "time"

    "github.com/henomis/phero/v2/tool/bash"
)

bashTool, err := bash.New(
    bash.WithSafeMode(),                         // built-in dangerous-command blocklist + 30 s timeout
    bash.WithWorkingDirectory("/tmp/sandbox"),
)
if err != nil {
    panic(err)
}
defer bashTool.Close() // kill the background shells when the app ends

// Register all three tools so the agent can manage background processes:
if err := a.AddTool(bashTool.Tool());       err != nil { panic(err) }
if err := a.AddTool(bashTool.OutputTool()); err != nil { panic(err) }
if err := a.AddTool(bashTool.KillTool());   err != nil { panic(err) }

Guardrail options:

Streaming output

By default nothing is visible until a command exits, so a long go test looks like a hang. WithOutputHandler calls a function with each piece of output as the command writes it (stdout and stderr interleaved, as in the result). The model still gets the whole output when the command ends. The function gets the tool call's context, so llm.ToolCallFromContext tells which call a chunk belongs to. Background commands are not streamed: the model reads them with bash_output.

bashTool, err := bash.New(
    bash.WithOutputHandler(func(ctx context.Context, chunk []byte) {
        os.Stderr.Write(chunk) // show progress while the command runs
    }),
)

Sandbox (Linux)

The blocklist and allowlist match substrings of the command and are easy to get around: they are not a security boundary. On Linux, NewBubblewrapRunner runs every command in a bubblewrap sandbox: of the host file system only the system directories (bash.SystemPaths(): /usr, /etc, /lib*, …) are mounted, read-only, plus the workspace. /tmp, /run and $HOME are private and empty, so host Unix sockets (docker.sock, the D-Bus buses, agent sockets) and files such as ~/.ssh are out of reach: connect() on a socket ignores read-only mounts, so the sandbox does not mount them at all. A workspace or extra path that is / or contains $HOME is refused (bash.ErrPathTooBroad), and commands run in a new session, detached from the caller's terminal. There is no network by default; WithNetwork(true) shares the host's network namespace, so commands also reach every service on localhost (a Docker TCP socket, databases, a local LLM server) and abstract Unix sockets such as X11's. The environment is limited to DefaultEnvAllowlist(), and the sandbox has its own PID namespace, so a timeout kills everything a command started.

runner, err := bash.NewBubblewrapRunner(repoDir,
    bash.WithWritablePaths(goBuildCache, goModCache), // tools that write outside the workspace
    bash.WithReadOnlyPaths(toolchainDir),              // tools installed outside the system directories
    bash.WithReadOnlyPaths(repoDir+"/.git"),           // inside the workspace: the most specific mount wins
    // bash.WithNetwork(true),                         // only if commands need it: it shares the host's network
)
if err != nil { panic(err) } // bash.ErrBubblewrapNotFound when bwrap is not installed

bashTool, err := bash.New(bash.WithRunner(runner), bash.WithWorkingDirectory(repoDir))

Any type implementing bash.Runner can replace it, for example one that runs commands in a container: it only builds the *exec.Cmd, and the tool keeps timeouts, process groups, background shells and output handling.

Background execution fields in Input:

Background shells

A background command has its own lifetime, so a dev server or a watcher keeps running. The foreground limits (WithTimeout, WithDefaultTimeout, WithMaxTimeout, and the safe-mode timeout) do not apply to it.

A command killed at its timeout is not an error: the result holds what it printed until then, followed by "command timed out after …", with TimedOut set, so the model sees how far it got. A cancelled caller context still returns the context's error.

On Unix, each command runs in its own process group. A timeout, a cancelled context or kill_shell kills the whole group, so processes the command started (a dev server, a test runner's workers) do not outlive it.

Running a coding agent safely

A coding agent runs commands and changes files that a model chose. Each guard below covers a different risk. examples/coding-agent puts them together.

Recommended setup: bubblewrap, an env allowlist, the file tools confined to the workspace, a permission policy with an Asker for everything not allowed, and a deadline per turn.

Human tool (tool/human)

The human tool pauses the agent to collect structured input from the user. You provide the interactor — the function that presents questions and reads answers — via WithInteractor. This keeps the tool backend-agnostic (terminal, web UI, etc.).

import "github.com/henomis/phero/v2/tool/human"

humanTool, err := human.New(
    human.WithInteractor(consoleInteractor),
)
if err != nil {
    panic(err)
}

if err := a.AddTool(humanTool.Tool()); err != nil {
    panic(err)
}

The Interactor signature:

type Interactor func(ctx context.Context, input *human.Input) (map[string]human.Answer, error)

The LLM supplies an Input with 1–4 questions. Each Question has:

A minimal terminal interactor:

func consoleInteractor(_ context.Context, in *human.Input) (map[string]human.Answer, error) {
    answers := make(map[string]human.Answer)
    for _, q := range in.Questions {
        fmt.Printf("[%s] %s\n", q.Header, q.Question)
        for i, opt := range q.Options {
            fmt.Printf("  %d) %s — %s\n", i+1, opt.Label, opt.Description)
        }
        fmt.Print("Your choice: ")
        var line string
        fmt.Scanln(&line)
        answers[q.Header] = human.Answer{Selections: []string{strings.TrimSpace(line)}}
    }
    return answers, nil
}

See examples/human-in-the-loop for a complete working example with a multi-step approval flow.

Skill dispatcher (tool/skill)

Use the skill dispatcher to expose a single skill tool that loads SKILL.md instructions and a base path into the current conversation, keeping the agent toolset compact.

import skilltool "github.com/henomis/phero/v2/tool/skill"

dispatcher, err := skilltool.New("./skills")
if err != nil {
    panic(err)
}

if err := a.AddTool(dispatcher.Tool()); err != nil {
    panic(err)
}

The dispatcher scans skillsRootPath for subdirectories containing SKILL.md files, builds a catalog, and embeds it in the tool description so the model can select the right skill by name at runtime. A skill that cannot be used (it does not parse, has no name or description, or its name is taken) is left out, and dispatcher.Skipped() says why; the others still load.

Available options:

The model passes arguments for a skill in the arguments field: they replace $ARGUMENTS in the skill's body, or are appended after it. The result lists the skill's allowed tools.

Web fetch (tool/web)

The web_fetch tool fetches a page and returns it as text: HTML becomes readable text with headings, lists and links (as text [url]), without script or style; other text and JSON come back as they are; binary content is refused. Responses are capped (WithMaxBytes, WithMaxOutputChars, WithTimeout) and the result says when it was truncated.

fetcher := web.New(web.WithAllowedHosts("go.dev", "*.go.dev", "github.com"))

fetchTool, err := fetcher.Tool()
if err != nil { panic(err) }

if err := a.AddTool(fetchTool); err != nil { panic(err) }

The URL comes from the model, so the tool refuses addresses on loopback, private, link-local and carrier-grade NAT networks and 0.0.0.0/8, including cloud metadata endpoints, and IPv6 addresses that carry such an IPv4 address (NAT64, 6to4, Teredo). The check runs on the address it connects to, so a redirect or a DNS answer cannot get around it. WithPrivateNetworks(true) allows them, for example to read a local dev server. WithAllowedHosts limits the hosts, redirects included; only http and https URLs are fetched.

Task list (tool/todo)

Over many turns an agent loses track of a long task. The todo_write tool lets it write the steps down, mark the one it is on and tick them off. Each call replaces the whole list; at most one item is in_progress. The tool answers with the list as a checklist.

list := todo.New(todo.WithOnChange(func(items []todo.Item) {
    fmt.Println(todo.Render(items)) // or update a UI
}))

todoTool, err := list.Tool()
if err != nil { panic(err) }

if err := a.AddTool(todoTool); err != nil { panic(err) }

list.Items() returns the current items, for example to show them after the run. To resume a session, save the items (they marshal to JSON) and start the next list with todo.WithItems:

var saved []todo.Item
if data, err := os.ReadFile(".phero/todo.json"); err == nil {
    _ = json.Unmarshal(data, &saved)
}

list := todo.New(
    todo.WithItems(saved), // must be valid items, as Items() returns
    todo.WithOnChange(func(items []todo.Item) {
        data, _ := json.Marshal(items)
        _ = os.WriteFile(".phero/todo.json", data, 0o600)
    }),
)

Permissions (tool/permission)

A permission.Policy decides, for each tool call, whether it runs, is denied, or is asked of a human first. Rules name a tool and, optionally, a pattern on the call's subject (the command for bash, the file path for read, write and edit, the search path for glob and grep); * matches any run of characters.

policy, err := permission.New(
    permission.WithAllow("read", "grep", "glob", "bash(go test *)", "bash(go build *)", "edit(/repo/*)"),
    permission.WithAsk("edit(/repo/go.mod)"),
    permission.WithDeny("bash(git push *)", "edit(/repo/secrets/*)"), // bash rules guard against mistakes
    permission.WithAsker(func(ctx context.Context, req permission.Request) (permission.Answer, error) {
        fmt.Printf("Allow %s(%s)? [y/a/N] ", req.Tool, req.Subject)
        // read the answer: permission.AllowOnce, permission.AllowAlways or permission.Reject
        return permission.AllowOnce, nil
    }),
)
if err != nil { panic(err) }

policy.Apply(readTool.Tool(), bashTool.Tool(), editTool.Tool())

Run an example

These are good starting points to see tools in action (provider setup is described in each example README):

# Human-in-the-loop: multi-step approval flow
go run ./examples/human-in-the-loop

# Skills + built-in file/bash tools
cd ./examples/skills && go run .

# Multi-agent workflow (uses a custom restricted command tool)
go run ./examples/multi-agent-workflow

Related packages