Introduction
Most AI tooling assumes you want a chatbot. A long-running session with a massive context window doing everything at once. But that’s not how good software works. Good software is small, focused, and composable.
Axe treats LLM agents the same way Unix treats programs. Each agent does one thing well. You define it in a TOML file, give it a focused skill, and run it from the command line. Pipe data in, get results out. Chain agents together. Trigger them from cron, git hooks, or CI. Whatever you already use. No daemon, no GUI, no framework to buy into. Just a binary and your configs.
Overview
Axe orchestrates LLM-powered agents defined via TOML configuration files. Each agent has its own system prompt, model selection, skill files, context files, working directory, persistent memory, and the ability to delegate to sub-agents.
Axe is the executor, not the scheduler. It is designed to be composed with standard Unix tools — cron, git hooks, pipes, file watchers — rather than reinventing scheduling or workflow orchestration.
Features
- Multi-provider support — Anthropic, OpenAI, Ollama (local models), OpenCode, and AWS Bedrock
- TOML-based agent configuration — declarative, version-controllable agent definitions
- Sub-agent delegation — agents can call other agents via LLM tool use, with depth limiting and parallel execution
- Persistent memory — timestamped markdown logs that carry context across runs
- Memory garbage collection — LLM-assisted pattern analysis and trimming
- Skill system — reusable instruction sets that can be shared across agents
- Stdin piping — pipe any output directly into an agent (
git diff | axe run reviewer) - Local agent directories — auto-discovers agents from
<cwd>/axe/agents/before the global config, or use--agents-dirto point anywhere - Dry-run mode — inspect resolved context without calling the LLM
- JSON output — structured output with metadata for scripting
- Built-in tools — file operations (read, write, edit, list) sandboxed to working directory; shell command execution; URL fetching; web search
- Output allowlist — restrict
url_fetchandweb_searchto specific hostnames; private/reserved IPs are always blocked (SSRF protection) - Token budgets — cap cumulative token usage per agent run via
[budget]config or--max-tokensflag - MCP tool support — connect to external MCP servers for additional tools via SSE or streamable-HTTP transport
- Configurable retry — exponential, linear, or fixed backoff for transient provider errors (429, 5xx, timeouts)
- Minimal dependencies — four direct dependencies (cobra, toml, mcp-go-sdk, x/net); all LLM calls use the standard library
Installation
Requires Go 1.25+.
Install via Go
go install github.com/jrswab/axe@latest
Build from Source
git clone https://github.com/jrswab/axe.git
cd axe
go build .
Initialize Configuration
After installing, initialize the configuration directory:
axe config init
This creates the directory structure at $XDG_CONFIG_HOME/axe/ with a sample skill and a default config.toml for provider credentials.
Quick Start
1. Initialize Configuration
axe config init
Creates $XDG_CONFIG_HOME/axe/ with a sample skill and default config.toml.
2. Scaffold an Agent
axe agents init my-agent
3. Edit the Agent
axe agents edit my-agent
4. Run the Agent
axe run my-agent
Piping Input
Axe accepts stdin, so you can pipe output from other tools directly into an agent:
git diff --cached | axe run pr-reviewer
cat error.log | axe run log-analyzer
Examples
The examples/ directory contains ready-to-run agents you can copy and use immediately — a code reviewer, commit message generator, and text summarizer, each with a focused SKILL.md.
# Copy an example agent into your config
cp examples/code-reviewer/code-reviewer.toml "$(axe config path)/agents/"
cp -r examples/code-reviewer/skills/ "$(axe config path)/skills/"
# Set your API key and run
export ANTHROPIC_API_KEY="your-key-here"
git diff | axe run code-reviewer
Commands
| Command | Description |
|---|---|
axe run <agent> | Run an agent |
axe agents list | List all configured agents |
axe agents show <agent> | Display an agent’s full configuration |
axe agents init <agent> | Scaffold a new agent TOML file |
axe agents edit <agent> | Open an agent TOML in $EDITOR |
axe config path | Print the configuration directory path |
axe config init | Initialize the config directory with defaults |
axe gc <agent> | Run memory garbage collection for an agent |
axe gc --all | Run GC on all memory-enabled agents |
axe version | Print the current version |
Run Flags
| Flag | Default | Description |
|---|---|---|
--model <provider/model> | from TOML | Override the model (e.g. anthropic/claude-sonnet-4-20250514) |
--skill <path> | from TOML | Override the skill file path |
--workdir <path> | from TOML or cwd | Override the working directory |
--timeout <seconds> | 120 | Request timeout |
--max-tokens <int> | 0 (unlimited) | Cap cumulative token usage for the run (exit code 4 if exceeded) |
--dry-run | false | Show resolved context without calling the LLM |
--verbose / -v | false | Print debug info (model, timing, tokens, retries) to stderr |
--json | false | Wrap output in a JSON envelope with metadata |
-p / --prompt <string> | (none) | Inline prompt used as the user message; takes precedence over stdin |
--agents-dir <path> | (auto-discover) | Override agent search directory |
User Message Precedence
The user message sent to the LLM is resolved in this order:
-p/--promptflag — If provided with a non-empty, non-whitespace value, it is used as the user message.- Piped stdin — If
-pis absent or empty/whitespace-only, piped stdin is used. - Built-in default — If neither
-pnor stdin provides content, the default message"Execute the task described in your instructions."is used.
When -p is provided alongside piped stdin, the piped stdin is silently ignored. An empty or whitespace-only -p value is treated as absent and falls through to stdin, then the default.
Example:
axe run my-agent -p "Summarize the README"
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Runtime error |
| 2 | Configuration error |
| 3 | Provider/network error |
| 4 | Token budget exceeded |
JSON Output
The --json flag wraps the agent’s output in a JSON envelope with metadata for scripting and observability.
axe run my-agent --json
Envelope Fields
| Field | Type | Description |
|---|---|---|
output | string | The agent’s text response |
output_tokens | int | Total output tokens across all turns |
tool_calls | int | Total number of tool calls made |
tool_call_details | array | Per-call details (see below) |
refused | bool | Whether refusal was detected in the response |
retry_attempts | int | Number of retries that occurred |
budget_max_tokens | int | Token budget cap (only present when a budget is set) |
budget_used_tokens | int | Cumulative tokens used (only present when a budget is set) |
budget_exceeded | bool | Whether the budget was exceeded (only present when a budget is set) |
tool_call_details
Each entry in the tool_call_details array represents one tool invocation:
| Field | Type | Description |
|---|---|---|
name | string | Tool name (e.g., read_file, run_command) |
input | object | Arguments passed to the tool |
output | string | Tool output, truncated to 1024 bytes |
is_error | bool | Whether the tool call resulted in an error |
Example
{
"output": "Here is the summary...",
"output_tokens": 312,
"tool_calls": 2,
"tool_call_details": [
{
"name": "read_file",
"input": { "path": "main.go" },
"output": "package main\n...",
"is_error": false
},
{
"name": "run_command",
"input": { "command": "go test ./..." },
"output": "ok \tgithub.com/jrswab/axe\t0.42s",
"is_error": false
}
],
"refused": false,
"retry_attempts": 0
}
Refusal Detection
The refused field is set to true when axe detects that the LLM declined to complete the task. Detection is heuristic — it scans the response for phrases like “I cannot”, “I’m unable to”, “I must decline”, etc. See the Refusal Detection page for details.
Refusal Detection
Axe automatically detects when an LLM declines to complete a task and surfaces this in the --json output envelope as "refused": true.
How It Works
Detection is heuristic — axe scans the response content for patterns indicating refusal:
- “I cannot”, “I can’t”
- “I’m unable to”, “I am unable to”
- “I’m not able to”, “I am not able to”
- “I must decline”
- “I don’t have the ability”, “I do not have the ability”
- “as an AI” combined with limiting language
Usage
Refusal detection is only active when using --json output:
axe run my-agent --json | jq '.refused'
Limitations
This is a heuristic and not exhaustive. A model that declines in an unusual phrasing may not be detected. It is intended as a lightweight signal for scripting and observability — not a guarantee.
Agent Configuration
Agents are defined as TOML files in $XDG_CONFIG_HOME/axe/agents/.
All fields except name and model are optional.
name = "pr-reviewer"
description = "Reviews pull requests for issues and improvements"
model = "anthropic/claude-sonnet-4-20250514"
system_prompt = "You are a senior code reviewer. Be concise and actionable."
skill = "skills/code-review/SKILL.md"
files = ["src/**/*.go", "CONTRIBUTING.md"]
workdir = "/home/user/projects/myapp"
tools = ["read_file", "list_directory", "run_command"]
sub_agents = ["test-runner", "lint-checker"]
allowed_hosts = ["api.example.com", "docs.example.com"]
[sub_agents_config]
max_depth = 3 # maximum nesting depth (hard max: 5)
parallel = true # run sub-agents concurrently
timeout = 120 # per sub-agent timeout in seconds
[memory]
enabled = true
last_n = 10 # load last N entries into context
max_entries = 100 # warn when exceeded
[[mcp_servers]]
name = "my-tools"
url = "https://my-mcp-server.example.com/sse"
transport = "sse"
headers = { Authorization = "Bearer ${MY_TOKEN}" }
[params]
temperature = 0.3
max_tokens = 4096
[budget]
max_tokens = 50000 # 0 = unlimited (default)
[retry]
max_retries = 3
backoff = "exponential"
initial_delay_ms = 500
max_delay_ms = 30000
[[mcp_servers]]
name = "filesystem"
transport = "stdio"
command = "/usr/local/bin/mcp-server-filesystem"
args = ["--root", "/home/user/projects"]
Retry
Agents can retry on transient LLM provider errors — rate limits (429), server errors (5xx), and timeouts. Retry is opt-in and disabled by default.
| Field | Default | Description |
|---|---|---|
max_retries | 0 | Number of retry attempts after the initial request. 0 disables retry. |
backoff | "exponential" | Strategy: "exponential" (with jitter), "linear", or "fixed" |
initial_delay_ms | 500 | Base delay in milliseconds before the first retry |
max_delay_ms | 30000 | Maximum delay cap in milliseconds |
Only transient errors are retried. Authentication errors (401/403) and bad requests (400) are never retried.
When --verbose is enabled, each retry attempt is logged to stderr. The --json envelope includes a retry_attempts field for observability.
[retry]
max_retries = 3
backoff = "exponential"
initial_delay_ms = 500
max_delay_ms = 30000
Token Budget
Cap cumulative token usage (input + output, across all turns and sub-agent calls) for a single run.
Configuration
[budget]
max_tokens = 50000 # 0 = unlimited (default)
Override via Flag
axe run my-agent --max-tokens 10000
The flag takes precedence over TOML when set to a value greater than zero.
Behavior
When the budget is exceeded, the current response is returned but no further tool calls execute. The process exits with exit code 4. Memory is not appended on a budget-exceeded run.
With --verbose, each turn logs cumulative usage to stderr. With --json, the output envelope includes budget_max_tokens, budget_used_tokens, and budget_exceeded fields (omitted when unlimited).
Output Allowlist
Agents that use url_fetch or web_search can be restricted to specific hostnames:
allowed_hosts = ["api.example.com", "docs.example.com"]
| Behavior | Detail |
|---|---|
| Empty or absent | All public hostnames allowed |
| Non-empty list | Only exact hostname matches permitted (case-insensitive, no wildcard subdomains) |
| Private IPs | Always blocked regardless of allowlist — loopback, link-local, RFC 1918, CGNAT, IPv6 private |
| Redirects | Each redirect destination is re-validated against the allowlist and private IP check |
| Sub-agents | Inherit the parent’s allowed_hosts unless the sub-agent TOML explicitly sets its own |
Built-in Tools
When tools are enabled, the agent enters a conversation loop — the LLM can make tool calls, receive results, and continue reasoning for up to 50 turns.
Enable tools by adding them to the agent’s tools field:
tools = ["read_file", "list_directory", "run_command"]
Available Tools
| Tool | Description |
|---|---|
list_directory | List contents of a directory relative to the working directory |
read_file | Read file contents with line-numbered output and optional pagination (offset/limit) |
write_file | Create or overwrite a file, creating parent directories as needed |
edit_file | Find and replace exact text in a file, with optional replace-all mode |
run_command | Execute a shell command via sh -c and return combined output |
url_fetch | Fetch URL content with HTML stripping and truncation |
web_search | Search the web and return results |
call_agent | Delegate a task to a sub-agent (controlled via sub_agents, not tools) |
Note: The
call_agenttool is not listed intools— it is automatically available whensub_agentsis configured and the depth limit has not been reached.
Path Security
All file tools (list_directory, read_file, write_file, edit_file) are sandboxed to the agent’s working directory. Absolute paths, .. traversal, and symlink escapes are rejected.
run_command Sandbox
run_command also applies heuristic path validation — it rejects commands containing:
..path components that would escape the working directory- Absolute paths outside the working directory
~in shell expansion position (home directory expansion)
Note: This is a heuristic guard, not airtight sandboxing. Shell is Turing-complete, and sufficiently creative constructs (variable expansion, command substitution,
eval, aliases) can bypass it. For full sandboxing, run axe inside Docker.
Tool Output Truncation
Tool call outputs are truncated to 1024 bytes. Truncated outputs end with ... (truncated). This affects the tool_call_details array in JSON output — plan accordingly if you’re scripting against it.
Parallel Execution
When an LLM returns multiple tool calls in a single turn, they run concurrently by default. This applies to both built-in tools and sub-agent calls. Disable with parallel = false in [sub_agents_config].
MCP Tools
Agents can use tools from external MCP servers. Declare servers in the agent TOML with [[mcp_servers]]:
[[mcp_servers]]
name = "my-tools"
url = "https://my-mcp-server.example.com/sse"
transport = "sse"
headers = { Authorization = "Bearer ${MY_TOKEN}" }
At startup, axe connects to each declared server, discovers available tools via tools/list, and makes them available to the LLM alongside built-in tools.
Configuration Fields
| Field | Required | Description |
|---|---|---|
name | Yes | Human-readable identifier for the server |
url | Yes | MCP server endpoint URL |
transport | Yes | "sse" or "streamable-http" |
headers | No | HTTP headers; values support ${ENV_VAR} interpolation |
stdio Transport
For local MCP servers using stdio transport:
[[mcp_servers]]
name = "filesystem"
transport = "stdio"
command = "/usr/local/bin/mcp-server-filesystem"
args = ["--root", "/home/user/projects"]
Precedence
MCP tools are controlled entirely by [[mcp_servers]] — they are not listed in the tools field. If an MCP tool has the same name as an enabled built-in tool, the built-in takes precedence.
Skills
Skills are reusable instruction sets that provide an agent with domain-specific knowledge and workflows. They are defined as SKILL.md files following the community SKILL.md format.
Skill Resolution
The skill field in an agent TOML is resolved in order:
- Absolute path — used as-is (e.g.
/home/user/skills/SKILL.md) - Relative to config dir — e.g.
skills/code-review/SKILL.mdresolves to$XDG_CONFIG_HOME/axe/skills/code-review/SKILL.md - Bare name — e.g.
code-reviewresolves to$XDG_CONFIG_HOME/axe/skills/code-review/SKILL.md
Script Paths
Skills often reference helper scripts. Since run_command executes in the agent’s workdir (not the skill directory), script paths in SKILL.md must be absolute. Relative paths will fail because the scripts don’t exist in the agent’s working directory.
# Correct — absolute path
/home/user/.config/axe/skills/my-skill/scripts/fetch.sh <args>
# Wrong — relative path won't resolve from the agent's workdir
scripts/fetch.sh <args>
Directory Structure
$XDG_CONFIG_HOME/axe/
├── config.toml
├── agents/
│ └── my-agent.toml
└── skills/
└── my-skill/
├── SKILL.md
└── scripts/
└── fetch.sh
Providers
| Provider | Provider Name | API Key Env Var | Default Base URL |
|---|---|---|---|
| Anthropic | anthropic | ANTHROPIC_API_KEY | https://api.anthropic.com |
| OpenAI | openai | OPENAI_API_KEY | https://api.openai.com |
| Google Gemini | google | GEMINI_API_KEY | https://generativelanguage.googleapis.com |
| MiniMax | minimax | MINIMAX_API_KEY | https://api.minimax.io/anthropic |
| Ollama | ollama | (none required) | http://localhost:11434 |
| OpenCode | opencode | OPENCODE_API_KEY | Configurable |
| AWS Bedrock | bedrock | (uses AWS credentials) | Region-based |
The “Provider Name” column is the value used in the model field of your agent TOML — e.g., model = "google/gemini-2.0-flash".
Configuring Credentials
API keys are resolved in this order for each provider:
- Environment variable — e.g.,
GEMINI_API_KEY config.toml— under[providers.<name>]
config.toml
You can store credentials and base URL overrides in $XDG_CONFIG_HOME/axe/config.toml instead of environment variables:
[providers.anthropic]
api_key = "sk-ant-..."
[providers.google]
api_key = "AIza..."
[providers.minimax]
api_key = "..."
[providers.openai]
api_key = "sk-..."
base_url = "https://your-openai-proxy.example.com"
[providers.bedrock]
region = "us-east-1"
Base URLs can also be overridden with AXE_<PROVIDER>_BASE_URL environment variables (takes precedence over config.toml).
AWS Bedrock
- Region resolution order:
AWS_REGION→AWS_DEFAULT_REGION→AXE_BEDROCK_REGION→[providers.bedrock] regioninconfig.toml - Credentials: Uses environment variables (
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_SESSION_TOKEN) or~/.aws/credentialsfile (supportsAWS_PROFILEandAWS_SHARED_CREDENTIALS_FILE) - Model IDs: Use full Bedrock model IDs (e.g.,
bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0)
Building the Image
docker build -t axe .
Multi-architecture builds (linux/amd64, linux/arm64) are supported via buildx:
docker buildx build --platform linux/amd64,linux/arm64 -t axe:latest .
Running Agents
Mount your config directory and pass API keys as environment variables:
docker run --rm \
-v ./my-config:/home/axe/.config/axe \
-e ANTHROPIC_API_KEY \
axe run my-agent
Pipe stdin with the -i flag:
git diff | docker run --rm -i \
-v ./my-config:/home/axe/.config/axe \
-e ANTHROPIC_API_KEY \
axe run pr-reviewer
Without a config volume mounted, axe exits with code 2 (config error) because no agent TOML files exist.
Running a Single Agent
If you only need to run one agent with one skill, mount just those files to their expected XDG paths inside the container. No config.toml is needed when API keys are passed via environment variables.
docker run --rm -i \
-e ANTHROPIC_API_KEY \
-v ./agents/reviewer.toml:/home/axe/.config/axe/agents/reviewer.toml:ro \
-v ./skills/code-review/:/home/axe/.config/axe/skills/code-review/:ro \
axe run reviewer
The agent’s skill field resolves automatically against the XDG config path inside the container, so no --skill flag is needed.
Overriding the Skill
To use a different skill than the one declared in the agent’s TOML, use --skill and mount only the replacement skill:
docker run --rm -i \
-e ANTHROPIC_API_KEY \
-v ./agents/reviewer.toml:/home/axe/.config/axe/agents/reviewer.toml:ro \
-v ./alt-review.md:/home/axe/alt-review.md:ro \
axe run reviewer --skill /home/axe/alt-review.md
Sub-agents
If the agent declares sub_agents, all referenced agent TOMLs and their skills must also be mounted.
Persistent Memory
Agent memory persists across runs when you mount a data volume:
docker run --rm \
-v ./my-config:/home/axe/.config/axe \
-v axe-data:/home/axe/.local/share/axe \
-e ANTHROPIC_API_KEY \
axe run my-agent
Volume Mounts
| Container Path | Purpose | Default Access |
|---|---|---|
/home/axe/.config/axe/ | Agent TOML files, skills, config.toml | Read-write |
/home/axe/.local/share/axe/ | Persistent memory files | Read-write |
Config is read-write because axe config init and axe agents init write into it. Mount as :ro if you only run agents.
Environment Variables
| Variable | Required | Purpose |
|---|---|---|
ANTHROPIC_API_KEY | If using Anthropic | API authentication |
OPENAI_API_KEY | If using OpenAI | API authentication |
AXE_OLLAMA_BASE_URL | If using Ollama | Ollama endpoint (default in compose: http://ollama:11434) |
AXE_ANTHROPIC_BASE_URL | No | Override Anthropic API endpoint |
AXE_OPENAI_BASE_URL | No | Override OpenAI API endpoint |
AXE_OPENCODE_BASE_URL | No | Override OpenCode API endpoint |
TAVILY_API_KEY | If using web_search | Tavily web search API key |
AXE_WEB_SEARCH_BASE_URL | No | Override web search endpoint |
Docker Compose
A docker-compose.yml is included for running axe alongside a local Ollama instance.
Cloud Provider Only (no Ollama)
docker compose run --rm axe run my-agent
With Ollama Sidecar
docker compose --profile ollama up -d ollama
docker compose --profile cli run --rm axe run my-agent
Pull an Ollama Model
docker compose --profile ollama exec ollama ollama pull llama3
Note: The compose
axeservice declaresdepends_on: ollama. Docker Compose will attempt to start the Ollama service whenever axe is started via compose, even for cloud-only runs. For cloud-only usage without Ollama, usedocker rundirectly instead ofdocker compose run.
Ollama on the Host
If Ollama runs directly on the host (not via compose), point to it with:
- Linux:
--add-host=host.docker.internal:host-gateway -e AXE_OLLAMA_BASE_URL=http://host.docker.internal:11434 - macOS / Windows (Docker Desktop):
-e AXE_OLLAMA_BASE_URL=http://host.docker.internal:11434
Security
The container runs with the following hardening by default (via compose):
- Non-root user — UID 10001
- Read-only root filesystem — writable locations are the config mount, data mount, and
/tmp/axetmpfs - All capabilities dropped —
cap_drop: ALL - No privilege escalation —
no-new-privileges:true
These settings do not restrict outbound network access. To isolate an agent that only talks to a local Ollama instance, add --network=none and connect it to the shared Docker network manually.
Local Agent Directories
By default, agents are loaded from $XDG_CONFIG_HOME/axe/agents/. Axe also supports project-local agent directories for per-repo agent definitions.
Auto-Discovery
If <cwd>/axe/agents/ exists, axe searches it before the global config directory. A local agent with the same name as a global agent shadows the global one.
my-project/
└── axe/
└── agents/
└── my-agent.toml ← found automatically
Explicit Override
Use --agents-dir to point to any directory:
axe run my-agent --agents-dir ./custom/agents
This flag is available on all commands: run, agents list, agents show, agents init, agents edit, and gc.
Resolution Order
--agents-dir(if provided)<cwd>/axe/agents/(auto-discovered)$XDG_CONFIG_HOME/axe/agents/(global fallback)
The first directory containing a matching <name>.toml wins.
Smart Scaffolding
axe agents init <name> writes to <cwd>/axe/agents/ if that directory already exists, otherwise falls back to the global config directory.