Batonv0.3.9

Baton agent (built-in, any model provider)

adapter = "baton" runs Baton's own coding agent in-process. Nothing needs to be installed: it talks to a model provider directly (through the Vercel AI SDK), runs its own tool loop and reports everything as normal Baton events: streamed text and thinking, tool calls with diffs and terminal output, permission requests, usage and cost.

Use it to put models without their own coding CLI on your team (local Ollama / LM Studio models, OpenRouter, Groq, DeepSeek, any OpenAI-compatible endpoint), or to run a light worker on a cheap model next to Claude Code and Codex.

Quick start

With an API key in the environment (or one of the other credentials listed below), it works without any config:

[agents.helper]
adapter = "baton"
model = "claude-sonnet-4-5"      # optional; defaults: anthropic → claude-sonnet-4-5, openai → gpt-5

When no provider is configured, a kind/ prefix on the model picks the provider, but only if that provider can be used (it is local, or its key is set). For example, model = "ollama/qwen2.5-coder" selects Ollama.

Credential detection

If no provider is configured, the agent looks for a credential in this order and uses the first one it finds:

  1. Environment keys: ANTHROPIC_API_KEY → OPENAI_API_KEY → OPENROUTER_API_KEY → GEMINI_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY (through Gemini's OpenAI-compatible endpoint https://generativelanguage.googleapis.com/v1beta/openai/) → GROQ_API_KEY → DEEPSEEK_API_KEY → MISTRAL_API_KEY → XAI_API_KEY.
  2. Codex's ~/.codex/auth.json (or $CODEX_HOME/auth.json), only its OPENAI_API_KEY field, which is set when you log in to Codex with an API key. Codex's ChatGPT login tokens are never used.
  3. OpenCode's ~/.local/share/opencode/auth.json (or $XDG_DATA_HOME/opencode/auth.json), only entries with "type": "api". Provider ids map to kinds: anthropic, openai, openrouter, google → gemini, groq, deepseek, mistral, xai, togetherai → together, fireworks-ai → fireworks, deepinfra, moonshotai → moonshot. "oauth" entries (subscription logins) are ignored.
  4. A local Ollama (GET http://127.0.0.1:11434/api/tags, or $OLLAMA_HOST, 300 ms timeout). It picks an installed coding model if there is one (*coder*, then devstral / qwen3 / deepseek …), otherwise the first non-embedding model.
  5. LM Studio (GET http://127.0.0.1:1234/v1/models, or $LMSTUDIO_BASE_URL).

baton doctor / detect() reports which source would be used (auth = ok, with a note naming the source but never the key). If nothing is found, it reports auth = missing with a hint listing these options, and the agent fails to start with the same explanation. Secret values are never logged.

Claude subscriptions are not used

The Baton agent only uses credentials that you created for API use. It never reads the Claude Code keychain item, ~/.claude/.credentials.json, CLAUDE_CODE_OAUTH_TOKEN or any other OAuth or session token. Anthropic's terms do not allow third-party apps to use Claude subscription (Pro/Max) credentials (legal and compliance). To use your Claude subscription, use the claude agent, which runs Claude Code with your own login. For the same reason, Codex ChatGPT tokens and OpenCode OAuth logins are skipped.

Providers

Point the agent at a [providers.<name>] entry:

[agents.local]
adapter = "baton"
provider = "ollama"
model = "qwen2.5-coder:14b"

[providers.ollama]
kind = "ollama"                      # base_url defaults to http://localhost:11434/v1

[agents.kimi]
adapter = "baton"
provider = "openrouter"
model = "moonshotai/kimi-k2"

[providers.openrouter]
kind = "openrouter"                  # base_url defaults to https://openrouter.ai/api/v1
api_key = "env:OPENROUTER_API_KEY"

[providers.work-gateway]
kind = "openai-compatible"
base_url = "https://llm.internal.example.com/v1"
api_key = "keychain:llm-gateway"
headers = { "X-Team" = "platform" }
kindBackendDefault base_urlKey fallback
anthropic@ai-sdk/anthropicAnthropic APIANTHROPIC_API_KEY
openai@ai-sdk/openai (Responses API)OpenAI APIOPENAI_API_KEY
openai-compatible@ai-sdk/openai-compatiblenone (required)none
ollamaopenai-compatiblehttp://localhost:11434/v1none
lmstudioopenai-compatiblehttp://localhost:1234/v1none
openrouteropenai-compatiblehttps://openrouter.ai/api/v1OPENROUTER_API_KEY
gemini (alias google)openai-compatiblehttps://generativelanguage.googleapis.com/v1beta/openaiGEMINI_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY
groq, deepseek, together, fireworks, mistral, xai, deepinfra, moonshotopenai-compatiblethe vendor's API<VENDOR>_API_KEY

base_url, api_key and headers from the provider entry always win. bedrock, vertex and azure are only supported through an OpenAI-compatible gateway: give them a base_url.

Approval modes

approvalWhat asks first
askfile edits and commands
auto-editcommands
autoonly obviously destructive commands (rm -rf, git push --force, git reset --hard, sudo, curl … | sh, …)
yolonothing

web_fetch asks only in ask mode (permission kind fetch, remembered per host).

To ask, the agent sends a permission.requested with the standard options. Baton's policy engine or the user answers it. The daemon emits permission.resolved itself when it answers, so the agent does not. Always allow and Always deny are remembered for the rest of the session, per tool and command (for commands) or per tool and path (for edits). Reads, searches and MCP tools never ask. Changing autonomy while the agent is running (setApproval) applies from the agent's next action.

Tools

ToolKindNotes
read_filereadnumbered lines, offset / limit ranges, binary files are detected; png / jpg / gif / webp images are shown to vision models (see Images)
list_dirsearch
globsearchskips node_modules, .git and build directories
grepsearchuses rg if it is installed (respects .gitignore), otherwise a JS fallback
write_fileeditcreates parent directories; emits a diff
edit_fileeditexact string replacement; must match exactly once unless replace_all; emits a diff
run_commandexecutebash -lc in the workspace root, no stdin, timeout (default 120 s), capped output, live terminal updates; on timeout or interrupt the whole process group is killed; sandboxed (see below)
web_fetchfetchGET an http(s) URL; see below

Every path is confined to the agent's working directory. Paths are resolved through symlinks, including dangling ones, before the check, and anything outside the working directory is refused.

When one model step asks for several read-only tools in a row (read_file, list_dir, glob, grep, web_fetch, and MCP tools whose server marks them readOnlyHint), they run at the same time. Edits, commands and anything that needs approval run one by one, in order. tool.call events and the results the model sees always keep the order of the request.

Sandbox for run_command

options.sandbox = "auto" (the default) runs every command in an OS sandbox:

The effective sandbox is logged when the session starts and whenever the approval mode changes it (run_command sandbox: seatbelt (writes: …; network: allowed)). The usual package-manager caches are writable too (~/.bun/install/cache, ~/.npm, ~/.pnpm-store, ~/Library/Caches, ~/.cargo/registry, ~/go/pkg/mod, ~/.gradle/caches, ~/.m2/repository, …) so installs and builds work; add anything else to sandboxWritable:

[agents.helper.options]
sandboxWritable = ["~/my-tool-cache"]

web_fetch

Fetches an http(s) URL with GET. It follows up to 5 redirects and has a 15 s timeout. It downloads at most 2 MB and returns at most about 20 000 characters, with a note when it truncates. HTML is converted to readable text: scripts, styles and navigation are dropped, and headings, links (as [text](url)), lists and code blocks are kept. Other schemes (file:, ftp: …) are refused. So are loopback, private, link-local and CGNAT addresses, checked again on every redirect, unless allowPrivateFetch = true.

DNS rebinding is covered:

Returns titles, URLs and snippets for a query (6 by default, up to 15). The engine depends on which key is set:

Like web_fetch, it asks only in ask mode.

Images

read_file on a png, jpg, gif or webp file (the extension and the file's magic bytes must agree) sends the image to the model when the model accepts images. Support comes from the models.dev catalogue, or from the provider for known Anthropic and OpenAI models; vision = true / false overrides it. Anthropic and OpenAI get the image inside the tool result. OpenAI-compatible providers get it as a user message right after the tool results, because their tool messages are text only. Images over 5 MB, and any image when the model has no vision, are replaced by a short text note. Prompts themselves (PromptInput) are text only.

MCP and the bus

Every server in the session's mcpServers list is connected over Streamable HTTP with its headers. Baton's own bus server is always in that list. Each server's tools are offered to the model under their plain names (send_message, list_agents, …). If two tools have the same name, the second one gets a server prefix. In the UI they appear as mcp__<server>__<tool>, the same names Baton's policy rules match. Server instructions are added to the system prompt. The clients are closed when the agent exits.

Options ([agents.<name>.options])

OptionDefault
maxSteps40model steps per turn; the turn then ends with stopReason = "max_steps"
contextTokensthe model's context window from models.dev, else a built-in table, else 128000the context window used for compaction
compactThreshold0.75compact when the prompt reaches this share of contextTokens
compactKeepMessages8recent messages kept verbatim when compacting
commandTimeoutMs120000default run_command timeout (the model can pass its own, up to 30 min)
maxOutputChars30000cap on each tool output sent to the model (keeps the head and tail)
temperature, maxOutputTokensprovider default
providerOptions—passed to the AI SDK as is, e.g. { anthropic = { thinking = { type = "enabled", budgetTokens = 8000 } } }
pricingmodels.dev, then a built-in table{ input = 0.5, output = 1.5, cachedInput = 0.05, cacheWrite = 0.6 } USD per million tokens
promptCachingtrueAnthropic prompt caching (see Behaviour)
sandbox"auto""container" runs each command in a docker/podman container; "off" runs commands unsandboxed
sandboxImage"node:22-bookworm"image for sandbox = "container"
sandboxWritable[]extra writable paths for the sandbox (~/ is expanded)
sandboxNetworktruefalse blocks network access from commands
allowPrivateFetchfalselet web_fetch reach loopback and private addresses
visionfrom models.dev / providerforce image support on or off
forkfalsewith resumeId: continue a copy of that conversation under a new session id

Behaviour

Known gaps

Baton 0.3.9 · MIT · Edit this page on GitHub