cd /news/ai-agents/baton-keep-long-coding-goals-moving-… · home › topics › ai-agents › article
[ARTICLE · art-165351] src=github.com ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Baton – Keep long coding goals moving across AI coding agents

A tool called baton keeps a single long-running coding goal alive for days by rotating it among coding agents, ending each agent's turn at USD 50 or 5 hours, whichever comes first, and dropping an agent from a goal once USD 150 has been spent in that goal. baton requires at least one of OpenAI's Codex CLI or Antigravity CLI (agy), treats Claude Code as an optional third paid by use that joins only after a Console API key is saved, and requires the Superpowers plugin for its plan, build and review rounds. A goal finishes only when its verification command exits 0 and every agent approves in consensus; an agent declaring itself done is not accepted.

read45 min views2 publishedNov 6, 2026
Baton – Keep long coding goals moving across AI coding agents
Image: Michielbdejong (auto-discovered)

baton keeps one long coding goal moving for days by passing it between coding agents, stopping each one before it can spend money it should not. It works with Codex and Antigravity alone; Claude is an optional third, paid by use, that only joins the queue once you save an API key for it. Its turn ends at USD 50 or 5 hours, whichever comes first, it is left out of a goal after USD 150 spent in that goal, and the monthly ceiling stays above all of that.

You give it a goal (an objective, a working directory and a verification command). It runs one agent until that agent nears its limit or its turn (5 hours) ends, hands the work to the next, and when every agent is out, sleeps until the earliest reset and carries on. It finishes only when the verification command exits 0; an agent saying "done" means nothing.

Every agent runs through its vendor's own CLI. baton reimplements no agent. Context moves between them through the repository, its commits and a handoff file, and each vendor's own conversation is resumed when its turn comes again.

Inside a goal the work follows a cycle of plan, build and review, with a strong and a working model per provider (see Phases).

flowchart LR
    subgraph queue["The queue: one agent runs at a time"]
        direction LR
        codex["codex"] --> antigravity["antigravity"] --> claude["claude"]
        claude -. "turn ends: back to the first" .-> codex
    end
    queue --> cycle
    subgraph cycle["Every cycle of a goal"]
        direction LR
        plan["plan"] --> build["build"] --> review["review"]
    end
    review --> check{"the check passes?"}
    check -- "no: back to plan" --> plan
    check -- yes --> consensus["consensus: every agent approves"]
    consensus -- "an agent asks for changes" --> build
    consensus -- "all approve" --> done(["done"])

How baton works: Codex, Antigravity and then Claude take turns; every goal runs plan, build and review, then the check, then consensus of every agent, and only then is it done.

baton does not bring its own AI or its own methods. It drives tools you install, and works with two projects it did not write. Everything it depends on or works with:

Project What it does for baton Required? How to install
uv and Python 3.12 or newer installs and runs baton; baton itself has no other dependencies required the installer on astral.sh/uv ; uv fetches Python 3.12 when it is missing
OpenCode the chat window you type in; baton shows up in it as the model baton/auto only for the chat interface: baton run works without it the installer on opencode.ai
Codex CLI runs GPT models, through your plan at least one of Codex and Antigravity, signed in to a plan npm install -g @openai/codex , then sign in
Antigravity CLI (agy ) runs Gemini models, through your plan at least one of Codex and Antigravity, signed in to a plan the installer on antigravity.google, then sign in
Claude Code an optional third in the queue, paid by use: a turn ends at USD 50 or 5 hours, and a goal leaves it out after USD 150 optional; needs a Console API key, not a subscription install Claude Code, then save the key (Quick start, step 3)
Superpowers the working methods (planning, test-driven building, verification, code review) that baton puts in every round required , by design: no round runs without it (a goal waits until it is installed), andbaton doctor fails; seeSuperpowers is required git clone https://github.com/obra/superpowers ~/.config/baton/superpowers ; it is also found automatically from the official Claude Code plugin install, and any other location needs[superpowers] dir
ai-memory long-term memory across goals in the same directory optional download the release binary for your platform from its releases page and put it on PATH ; do not runai-memory setup , which wires global hooks (seeLong-term memory )
launchd keeps baton serve running in the background on macOS macOS only, recommended: baton install-service built in to macOS; on Linux keep baton serve running in a terminal or tmux

baton was tested with Codex CLI 0.166.0, Claude Code 2.1.318 and ai-memory 3.4.1. The stream formats and hook options it relies on are version-specific, so other versions may not behave the same.

How the pieces fit. baton owns the rotation, the phases, the limits and the guards. Each vendor CLI (Codex, Antigravity, Claude Code) does the actual work, one round at a time, inside a model baton chose. Superpowers gives every round its method. ai-memory gives the goals long-term memory. OpenCode is the chat window in front of it all.

Superpowers is not optional, and there is no setting to turn it off. No round runs without it: a goal that cannot find it waits, still running, and looks again every 5 minutes, so installing it is all that is needed. The reason is the design: Superpowers is the working method for every provider. Codex, Antigravity and Claude get the same planning, test-driven building, verification and review discipline, in every fresh conversation, whichever agent happens to hold the baton. Install it as the table above says; baton doctor fails until it is found. baton finds it automatically only from the official Claude Code plugin install or from ~/.config/baton/superpowers; an install anywhere else needs [superpowers] dir in defaults.toml.

This walks you through everything once, with no prior knowledge of baton. It takes about 15 minutes. The commands assume macOS; on Linux everything works except the background service, so use the note in step 4.

Before you start. Get the tools in What you need: at least one of codex or agy, signed in to your plan, plus OpenCode for the chat window. Superpowers is installed in step 1.

git clone https://github.com/cahian/baton.git
cd baton
uv tool install .
git clone https://github.com/obra/superpowers ~/.config/baton/superpowers

The last line is required, by design: no round runs without Superpowers (see Superpowers is required).

mkdir -p ~/.config/baton
cp examples/defaults.toml ~/.config/baton/defaults.toml

Claude is optional. Open ~/.config/baton/defaults.toml in any text editor and pick one of these:

  • No Claude (the simplest): change nothing, and skip step 3. Without a saved key, Claude never runs and baton uses Codex and Antigravity only. To switch it off for good, setenabled = false under[agents.claude_api] .
  • With Claude: setmonthly_budget_usd to the credit you actually have, andbudget_reset_day to the day of the month it renews. baton stops 10% before that number. Then do step 3. Claude joins the queue after the other two; its turn ends at USD 50 or 5 hours, whichever comes first, and a goal leaves it out after USD 150. To keep it for last, so that it runs only while the others are out, setreserve = true under[agents.claude_api] .

Change enabled for codex or antigravity the same way if you only have one of them.

Create an API key in the Claude Console and copy it. Then run this, which saves what you copied into a file only you can read:

touch ~/.config/baton/anthropic.key
chmod 600 ~/.config/baton/anthropic.key
pbpaste > ~/.config/baton/anthropic.key

Do not paste the key into a chat or into a project.

Check your billing first. baton cannot see or change it. If the Console has auto-reload on, or holds credits you bought, using the key can spend that money. Turn auto-reload off.

baton install-service
baton doctor

install-service keeps baton running in the background, even after a restart. Every line of doctor should read ok (Superpowers included); with no Claude key it says claude_api: not configured (optional), which is fine. FAIL tells you what to fix; warn is only a heads-up. It spends nothing, so run it as often as you like.

On Linux, skip install-service and leave baton serve running in a terminal.

Optional: long-term memory. Put the ai-memory binary on your PATH and baton does the rest; without it baton works exactly as before. See Long-term memory.

baton opencode-config

This prints a block of settings. Where it goes depends on whether you already use OpenCode:

No ~/.config/opencode/opencode.json yet: save it directly:

mkdir -p ~/.config/opencode
baton opencode-config > ~/.config/opencode/opencode.json

You already have that file: do not run the command above, it would erase your settings. Open the file and copy thebaton entry into your existingprovider section.

Then tell OpenCode to read the change. If you skip this, baton will not show up:

opencode reload

Use a throwaway folder for your first try. The agents have full access to the folder where you open OpenCode, so do not start in a project you care about:

mkdir -p ~/baton-test
cd ~/baton-test
git init
opencode

Type /models . Searching forbaton showsNo results found . That is expected the first time. 2. Press ctrl+a to see all integrations, and choosebaton .3. It asks for an API key. baton does not need one, but the box cannot be empty. Type local and press Enter.4. Type /models again and chooseAuto . If OpenCode asks for a variant, keepDefault . baton ignores this setting; effort is set in yourdefaults.toml .

You are ready when the box at the bottom says Auto baton:

OpenCode remembers the connection, but check that label whenever you open a new session.

Create a hello.py that prints "hi" and run it to check.

The answer streams in the chat like any other model. A small request is answered in one round: the plan finds nothing to build and the cycle ends there. A line tells you each time one agent hands over to another, and one each time the phase changes. While it works:

  • To change direction, just send another message. It reaches the agent after its current tool call (never later than five minutes after), including a message you type while a reply is streaming, which OpenCode would otherwise hold until the reply ends.
  • Closing OpenCode does not stop the work. It keeps running in the background.

Run these in another terminal:

baton jobs                   # everything running; names look like session-<id>
baton status session-<id>    # where it is and what it has spent
baton stop session-<id>      # stop it
baton doctor                 # limits and spending right now
baton restart                # restart the gateway between rounds (see Keeping it running)

By default, the work ends when a cycle ends, which for a small request is one round. For a long goal, give baton a test to pass: create a file named baton.toml in your project with the command that proves the job is finished.

[verify]
command = "./check.sh"
timeout_s = 3600

baton then keeps going, across agents and limit resets, until that command succeeds. An agent saying "done" does not count. Without a good check it may stop too early, and with an impossible one it will keep trying, so choose something you trust.

What you see What to do
baton is not in/models Run opencode reload , reopen OpenCode, then do step 6.
It asks for an API key Type local .
The model still is not listed In your project, run echo '{"model":"baton/auto"}' > opencode.json , then reopen OpenCode.
The answer never starts Run baton doctor andbaton jobs .
It says it is waiting Every agent hit its limit. It resumes alone at the next reset.
You want to go back to another model Choose it in /models . Nothing else changes.

The default order, all at medium effort (the strong tier; Phases adds a working model for building):

  1. GPT-6.1 Sol , through Codex.
  2. Gemini 4 Argon , through Antigravity (agy ).
  3. Claude Opus 5.5 , through a Console API key (never a subscription login).

That is the queue. An agent gives way to the next after 5 hours of work, or sooner when its limit runs out. Only the time its rounds ran counts: a cooldown, a wait or a stopped service does not. Claude's turn also ends at USD 50, whichever comes first, and after USD 150 spent in one goal (a goal is what you pin with baton: goal) Claude is left out of the queue until the goal changes. The others have no budget of their own. Claude is paid by use and has no reset that would ever stop it, so the ledger's monthly ceiling (see The Claude ledger) stays above all of this.

Claude is optional. baton works with Codex and Antigravity alone: until you save a key file (Quick start, step 3), Claude never runs and the rotation is the other two. It joins the queue once the key is there. To turn it off for good, set enabled = false under [agents.claude_api] in defaults.toml, or leave it out of order in a goal file. baton doctor reports an unconfigured Claude as one ok line, not a problem.

The first agent in order starts. The current one keeps working until it is unavailable, its turn is over, or you type baton: next (or baton next); then the next one in order takes over, wrapping around. This happens only between two rounds: a turn that ends while a round runs lets the round finish, and baton: next now ends the round at the next gap between tool calls. Each change is one line in the chat, [baton] rotation: codex -> antigravity (5 hours), with the reason (asked by the person, 5 hours, USD 50), said once the next agent actually starts. When no other agent can run (it is out, or has no key), the agent stays, nothing is said and its turn starts over. A review may go to Claude when it is the only other agent that can take it; that round counts in the goal's spend, not in the turn. An agent that comes back from its limit does not preempt another that is working. When no agent can run, baton waits for the earliest known reset, and takes a next that arrives meanwhile.

Reserve. With reserve = true under [agents.claude_api], Claude leaves the queue and waits for the others, wherever it stands in order: it runs only while every other agent is out. While it does, baton reads the others (without a model call) from their reset time onward, at most every 30 seconds, and as soon as one is available it ends the Claude round and that agent takes over. If more than one is, the first in order goes first. This is not a failure: Claude gets no cooldown. An agent whose round fails within five minutes of such a takeover stops being allowed to end Claude rounds until it completes one. At most six Claude rounds a day are ended this way (baton status says when the cap is active). Without reserve (the default) none of this happens.

The turn of a reserve Claude never ends. The limits are settings (turn_max_s for every agent, in seconds, default 18000; turn_budget_usd, default 50, and goal_budget_usd, default 150, for Claude). They go in defaults.toml or in a goal file. A repository's baton.toml may only lower them (and round_budget_usd), may not turn a reserve off, and may not enable an agent that defaults.toml disables. Claude reports a session's cost as a running total, so a resumed round counts only what it added to the last total baton saved for that session. A round that ends without a cost report (killed, timed out) counts its whole reservation against the turn and the goal, as the ledger does. Change the order, or disable an agent, in the goal file.

Each provider has a strong model for thinking and a working model for building, and baton applies the same discipline to all three:

Provider Plan and review Build
Codex gpt-6.1-sol , medium gpt-6.1-terra , high
Antigravity gemini-4-argon-medium gemini-3.8-flash-high
Claude claude-opus-5-5 , medium claude-sonnet-5-5 , high

This is separate from the rotation: the scheduler still decides who works, and the phases decide what that agent does and which of its two models runs. State lives in .baton/PLAN.md and .baton/REVIEW.md, so any provider can continue a cycle.

  1. Plan (strong). The agent writes.baton/PLAN.md with one- [ ] line per task. A request small enough to do at once gets no tasks, and the cycle ends there.
  2. Build (working). One task per round, marked- [x] when done. Two completed rounds in a row that close no task move on to review; so do 8 rounds in a row of any outcome that close none, and so does the build's round cap (3 per task open when the build began, plus 5; a review that sends the build back renews it).
  3. Review (strong). A fresh conversation, by another provider when one is available. It writes.baton/REVIEW.md , starting withAPPROVED orCHANGES , and adds- [ ] tasks for changes. Changes go back to build at most 2 times; a thirdCHANGES ends the cycle.
  4. End of cycle. The verify command runs here, and a failed run goes back to plan. It also runs after every 3 rounds without a phase change, whatever their outcome (a timeout resumes rather than advances), and a pass finishes the goal as usual (with a goal set, it startsconsensus instead). With no verify command, the end of a cycle finishes the goal.

A note from a person in the middle of a cycle is delivered as before, outranks the current task, and changes no phase; a new message starts a new cycle at plan, and .baton/PLAN.md and REVIEW.md of the cycle before are moved to .baton/archive/ (a failed verify keeps them). A round that does not complete leaves the phase as it was, so the next agent repeats that work. A Claude round cut off by its own budget cap counts as not complete for the phase and does not finish a goal. In a directory that is not a git repository there is nothing to review: baton says so once and ends the cycle after build. phases = false in a goal file or baton.toml keeps the plain relay, with the strong model for every round.

Two guards on the review, enforced by baton and not by a prompt. baton deletes REVIEW.md just before a review round and reads the verdict only after a round that completed, so an old APPROVED can never approve anything. And if a reviewer changes the code (HEAD moves, or any tracked or new file outside .baton/ changes), the review is void: it counts as CHANGES, a phase event says reviewer changed the code, and a task to review those changes is added to the plan. A reviewer editing a gitignored file is not detected.

While a goal is set (baton: goal <text>, baton goal, or oneshot), a passed verify does not finish the job, whatever the goal file says. A goal needs phases and a git repository, because consensus is a phase and an approval is bound to the state of the tree. With phases = false, or outside a repository, baton: goal answers [baton] a goal needs phases, and phases = false here (or a goal needs a git repository), baton goal exits with that error, and oneshot = true with phases = false is an error in the goal file. A run that finds a goal set where it cannot do it (a baton.toml edited to phases = false and a restart, a .git that disappeared) alerts once and holds, as it does for a missing Superpowers: no round, nothing ends, a stop is honoured, until the goal is cleared (baton: goal off) or the setup is fixed and the job restarted.

The check is verify.command when the goal file has one. Otherwise it is .baton/VERIFY.sh, which the plan writes: it must exit 0 only when the goal is met as the person would experience it, in the real setup, with no rounding, a check that measures a stand-in fails the check, and it writes only under .baton/ or outside the working directory (anything else it leaves in the tree counts as a change to the work). With no check the gate fails with no check, and the plan stays in plan, with tasks or without, until .baton/VERIFY.sh exists. The gate before round 1 and the periodic one run only with a check that exists.

  • The check is locked. At the end of each completed plan round baton saves the script's hash, and keeps its first version in the state directory (verify.first.sh ). A gate that finds another hash fails withthe check changed outside plan and alerts, so a build round cannot weaken it silently. A plan round may change it; consensus then shows every approver the diff against the first version, and tells them to reject a weaker check. When a goal ends or changes, the script moves to.baton/archive/ with the lock and the first version: the next goal writes its own.
  • Consensus. A pass starts it: every agent inorder that can run (not left out for its goal budget, and not one that can never run: disabled, no usable key, or around_budget_usd below the minimum) takes a fresh, strong round, one at a time, without moving the queue or its turns, and with no round tail and no preemption watch. Every approver is shown the check itself (the command or the script) and told to reject one that cannot fail or measures a stand-in. It may not change the tree or the check, reruns the check itself, inspects real evidence, and writesREVIEW.md withAPPROVED orCHANGES first (APPROVED only if ... is not an approval). An agent out of quota is waited for (waiting for <agent> to approve , said once per reason); a round that did not complete is retried, and three in a row count as one failed cycle; an agent that crosses its goal budget in the meantime leaves the list, and the chat says so.
  • An approval belongs to a state. When the gate passes baton records the tree (HEAD and every changed file outside.baton/ ) and the check's hash. After every consensus round, completed or not, on a restart, and just before the goal ends, they are compared: a change rejects, consensus ends and the work goes back to build, with a note naming the changed paths fromgit status . A clean round's own snapshot commit moves the record along. Before it ends, baton also looks again at the goal (changed or cleared ends the approval) and asks anyone who can run now and has not approved. Everyone approving ends the goal:[baton] goal achieved: check passed, approved by codex, antigravity, claude_api , and the goal is archived.
  • The goal's terms are saved. When a goal is first seen baton saves its terms instate.json (goal_terms : the verify command,order , which agents are enabled, and every agent'sround_budget_usd ,turn_budget_usd andgoal_budget_usd , which a repository'sbaton.toml may lower and so take Claude out of consensus). A later difference, from abaton.toml an agent edited or anything else, fails the gate withthe goal's terms changed: <what> and an alert, and voids a consensus under way, until the person sets the goal again withbaton: goal orbaton goal (the same words will do), which saves the terms as they are. Consensus shows the saved verify command.
  • The check runs once more at the end. Just before the goal ends baton runs the check again: the tree comparison cannot see what git ignores (adist/ , a build). A failure rejects, back to build, and a goal that changes while any check runs does not get that run's result: the check runs again for the new goal.
  • A no goes back to build.CHANGES , a missing verdict or a changed tree ends consensus: the review becomes a note, a task goes to the plan, and the chat says[baton] <agent> rejected: back to build . A message from the person during consensus ends it the same way, andnext is taken and dropped with a[baton] line.
  • No giving up. Every 3 cycles that end with a failed gate or a rejection, baton alertsno progress in N cycles: still working , and carries on.

Hardening, and the trust boundary. Every git call baton makes runs with core.fsmonitor, core.hooksPath and core.pager neutralised, so those cannot run a program inside baton. Other config still can: a repository's filter.* (clean and smudge) programs run inside git, and baton does not disable filters. What stops them forging an approval is the order: an approver's REVIEW.md is read before baton's next git call. The tree record also covers tracked files hidden with skip-worktree or assume-unchanged. Baton sets BATON_ROUND=1 in every agent's environment, and the commands that steer a goal (baton goal, next, restart and stop) refuse to run under it, so an agent's shell cannot accept its own changed terms or clear the goal. This only raises the bar: agents run as the same user with full access to the machine, so a determined agent can still write to baton's own state directory (the goal file, state.json, verify.first.sh) or unset the variable. The protection is the check and the consensus, which guard the work and the check, not baton from the agents it runs. Helper files a check calls beside it are not locked either.

Subagents are allowed on the provider's two models. A subagent may use only the strong or the working model of its provider, exactly, and what enforces that differs by provider.

  • Claude: every subagent runs on the working model (Sonnet), because baton setsCLAUDE_CODE_SUBAGENT_MODEL withCLAUDE_CODE_SUBAGENT_MODEL_FORCE=1 , which makes Claude Code drop themodel parameter: a named model is replaced, not denied. APreToolUse hook stays as a second check, and in practice only denies unreadable input or an empty allowed list.
  • Codex: aPreToolUse hook, given per run with-c (nothing in~/.codex is touched, andfeatures.hooks=true keeps a config file from turning hooks off), denies a spawn naming a model outside the pair before the subagent exists, with a message naming the two allowed ones; the agent can retry with an allowed one. A subagent that names no model inherits, unless a role pins one. A Codex spawn may not appear in the stream, since the block is in the hook.--dangerously-bypass-hook-trust also runs any other enabled Codex hooks without review, which adds no new capability, sinceverify.command already runs commands.
  • Antigravity: no hook. baton ends the round (asubagent outcome, followed by a ; three in a row rest that agent) when a forbidden model is visible in the stream, and otherwise only logs, since its model is rarely shown.

For Claude and Codex the stream only logs subagents as subagent events, shown in baton status; it never ends a round. Subagent spend is included in a Claude round's reported cost, which Claude checks between steps, so --max-budget-usd is a soft limit: a round can pass it slightly, and the 10% reserve covers that. Claude runs with a config directory of baton's own (<state base>/.claude-config, mode 700), so a ~/.claude.json env map cannot replace the key or the base URL.

Superpowers is applied in every round (it is required). baton puts the methods that fit each phase in the prompt of a fresh conversation, under Skill: <name> headings: writing-plans for plan, test-driven-development and verification-before-completion for build, the code-reviewer prompt for review, and systematic-debugging for the plan after a failed verify, even in a resumed conversation. The phase's own instructions come last. It leaves out the skills that conflict with baton's own workflow, and says baton's files outrank the methods'. To use another install, set [superpowers] dir in defaults.toml (a directory named there is final: if it is incomplete, Superpowers counts as missing); a repository's baton.toml cannot set it. Files over 64 KB, or 128 KB of text in all, count as missing.

.baton/HANDOFF.md is short-term: it dies with the goal. ai-memory is the long-term layer: what past rounds did and learned, searchable, across goals in the same directory. It adds to the handoff and never replaces it. It is optional: without the binary, or with memory off, nothing changes, and a memory failure can never stop a goal.

baton drives it; the agents do not. baton calls the ai-memory command itself, between rounds, so the same memory reaches Codex, Antigravity and Claude alike. It uses no hooks and no MCP, and never runs ai-memory setup, install-hooks, install-mcp or run.

What is stored, and where.

  • Before every round, baton searches the project's memory with words from the last verify output (at most 16 of them) and the objective (the rest, up to 24 in all), and puts the best 5 hits (6000 characters at most) in the prompt, labelled as context that may be out of date, not as instructions. Words are taken from the end of the verify output and of the objective, where the errors and the newest message are. Each hit is the page itself, not search's short snippet: baton reads it and puts in the prompt the part around where the search matched (the head when it cannot tell), sharing the 6000 characters among the hits, 1200 at most for each. Pages that hold lessons come first, and the pages of this goal's own rounds are left out, since the handoff already carries them. The hits sit between --- BEGIN BATON MEMORY --- and--- END BATON MEMORY --- , one line each, starting with the page's date and goal.
  • After a round that started a process, baton stores one page: the date, the agent, how it ended, how long it took, the gate result, the commits it made, and the tails of the verify output, the handoff and the round. It is written when the round ends and again, over itself, once the verify has run, so a baton killed during the verify still leaves its page. A round that fails, times out or has a failing verify is kept as a "gotcha"; a stop, an interrupt, a takeover by another agent or a limit is normal rotation.
  • Agents are told to keep durable findings under a ## Lessons heading in the handoff. baton also takes## Lessons ... and### Lessons ... headings and list items that start with**Lessons , with their indented lines (code fences are skipped). When that changes, baton stores it as a pinned page that outlives the goal. Each round's page is announced inbaton status as amemory eventrecorded .
  • ai-memory redacts secrets before it stores anything. Memory is per directory: a project is the directory's name plus a short hash of its full path, and a search never leaves it.
  • The data is in ~/.local/state/baton/.ai-memory (mode 700), with its own config and its own port,127.0.0.1:18778 , so it never meets another ai-memory on the machine. The leading dot keeps it clear of every goal's state directory, since no goal can be named that.BATON_STATE_DIR moves it. baton starts the server when it first needs one and leaves it running.
  • ai-memory gets only PATH ,HOME ,TMPDIR ,NO_COLOR andRUST_LOG in its environment, so no provider key can reach it, and baton configures no model for it: it uses none.

Install it as in What you need: the release binary from the ai-memory releases on your PATH, or bin below set to its path. Do not run ai-memory setup: it wires hooks into every session on the machine, which is what baton avoids. Then run baton doctor: the memory line shows the binary found and whether the server is up. If you run baton as a service, re-run baton install-service after installing ai-memory, so the service can find it.

Turn it off. memory = false in a goal file or baton.toml turns it off for that project. BATON_MEMORY=off in the environment turns it off everywhere. In defaults.toml:

[memory]
enabled = true
bin = "ai-memory"      # a name on PATH, or a path
port = 18778           # 1024-65535
recall_chars = 6000    # 0-20000; 0 stops recall, recording goes on

A baton.toml that comes with a repository can only turn memory off or on; it cannot set bin or port. A [memory] table that cannot be read turns memory off, with one line in baton status and a warning in baton doctor.

Limits.

  • One memory call, starting the server included, is given 8 seconds in all. After any failure (server dead, garbage output) baton skips memory for 300 seconds, so a broken memory costs a round one short wait at most. A page that cannot be read does not stop memory: for 300 seconds the hits are search's snippets instead of pages. A missing binary costs nothing and says nothing; baton doctor warns. Failures show inbaton status asmemory events, never in the chat.
  • If you change port while the old server is still running, baton starts no second one: it says so, inbaton status andbaton doctor , and you stop the old server (or set the old port back). baton never stops it for you.
  • A round that baton had to kill is still recorded, as interrupted .
  • Recall is keyword search, not understanding: it finds earlier pages that share words with the failing output or the objective. It can miss, and it can return something stale.
  • Lessons longer than 10000 characters are cut to their last 10000.
  • Goals in one directory share one memory; the pages are told apart by goal name.

The failure this exists to prevent: at 100% of its included usage, the Codex CLI keeps working and silently debits account credits. No error is raised. A design that waits for a limit error has already paid, so baton reads the limits and acts before they are reached.

Agent What is read Stop condition
Codex codex app-server rate limits and credit balance. No model call, nothing spent. 90% used in any window, or the credit balance drops
Antigravity the /usage and/credits slash commands. No model call, nothing spent. 90% used in any Gemini row, or the credit balance drops. Both Gemini Models rows, Five Hour and Weekly, must be readable
Claude baton's own spend ledger (there is no API that returns the balance) a spend ceiling of 180 USD by default, and a per-round cap

Rules that apply to all of them:

  • Limits are read before a round and every few seconds during it (15 s for Codex, 30 s for Antigravity by default). Reaching the threshold stops the round.
  • A balance that moves between the read before the round and any read during it kills the round at once (credit_guard ). Balances are compared as exact decimals.
  • Unknown means stop. A read that fails or cannot be parsed never lets an agent run, and two failed reads in a row during a round stop it.
  • Every agent process has the vendor API-key variables removed from its environment, so a CLI cannot fall back to paid API access. The Claude adapter adds its own key back for its process only. Before the key is added, every ANTHROPIC_* ,CLAUDE_CODE_USE_* ,CLAUDE_CODE_OAUTH_* ,CLAUDE_CODE_*_FILE_DESCRIPTOR andAWS_BEARER_TOKEN_BEDROCK variable is dropped from the Claude round, and a round whoseinit event shows any other credential (a subscription login) is refused at once, before any model call.

Claude is billed to the organization's promotional credit, and no API reports what is left, so the budget is baton's own:

  • monthly_budget_usd (default 200) minusreserve_pct (default 10) is the spend ceiling, 180 USD. The reserve also absorbs the error of the cost estimate.

  • Spend is summed from total_cost_usd in each round's result, in one ledger shared by every goal (~/.local/state/baton/claude-spend.json ), under a lock. A period starts onbudget_reset_day at 00:00 UTC.

  • Before a round, baton reserves round_budget_usd (default 5), passed to Claude as--max-budget-usd : a soft limit, since the cost (a subagent's included) is checked between steps, so a round can pass it slightly and the reserve covers that. If the ceiling would be passed, the round shrinks to what is left, and below 5 USD the agent is unavailable until the next period.

  • A round that ends without a result keeps its whole reservation counted. Crashes overcount and never undercount. So does a round ended because another agent came back (reserve = true ): it has no result, and whatever it spent before is counted as the whole 5 USD. The ledger drifts high, never low, and the small default keeps that drift small. A corrupt ledger makes the agent unavailable; it is never recreated silently.

  • Optionally, admin_key_file points to an Admin API key. baton then also reads the Console cost report and uses the larger of that and the ledger. It never lowers the ledger, and a failed read makes the agent unavailable.

  • With no key file, Claude is unavailable with a clear reason and the other agents carry on.

  • There is a poll gap. A round can cross from the threshold to 100% between two polls. The 10% margin exists for this, and it cannot be made mathematically zero. The account's own setting for credits, where it exists, remains the only hard stop.

  • Claude spend is an estimate. It is computed locally from what the CLI reports. The hard stops are in the Console, not in baton.

  • Some Console settings must be right, and baton cannot check them with the key. Turn auto-reload off and hold no purchased credits, since otherwise spending continues after the promotional credit ends. Put the key in a dedicated workspace with a monthly spend limit at the ceiling.baton doctor prints this reminder.

  • Interactive sessions outside baton are not protected. Codex and Antigravity share one set of limits and credits between baton and anything else you run. baton cannot stop your own session, but a balance drop it causes still stops baton's round.

  • Antigravity's output when its quota actually runs out has not been observed. The limit reads are expected to stop it first.

  • Masking is by pattern. Before it is shown, kept in the round tail or put in the log of events, the agent's output has the value ofBearer ... and oftoken ,api_key ,secret ,password andpasswd assignments (also in a longer name such asDB_PASSWORD ) and the well-known token shapes (sk- ,ghp_ ,AKIA ,xoxb- ) replaced by[masked] . A quoted value is masked from 8 characters, spaces included. An unquoted value is masked only when it holds a digit or a symbol other than. ,_ ,- , so code such aspassword = self.password is left alone:an unquoted value of letters only is not caught. A placeholder ($TOKEN ,${TOKEN} ,<token> ) is never masked. A secret in another shape, or split across two streamed fragments, is not caught either, and the raw round log keeps what the agent printed. The next agent is told what[masked] is.

  • A response held open for days through OpenCode is untested beyond seconds. The job does not depend on it:baton jobs andbaton status session-<id> show where it is, and a new message in the session reattaches the stream.

uv tool install .
baton doctor

Python 3.12 or newer. There are no runtime dependencies. You also need the tools in What you need: Superpowers (required), and the CLIs you want to use on your PATH and logged in: codex, agy and claude. Binary paths can be overridden per agent with bin, in a goal file or in defaults.toml, since tmux and launchd may not load your shell aliases. A leading ~ is expanded.

baton doctor [goal] checks binaries, logins, stray API-key variables, live limits, Claude's spend against its ceiling and Superpowers (a blocking failure when it is missing). It spends nothing and exits non-zero on a blocking problem.

Settings applied under every goal. The Claude budget (monthly_budget_usd, reserve_pct, budget_reset_day) can only be set here, because the ledger it describes is shared; a goal file that sets them is refused. A full example, also in examples/defaults.toml:

[agents.claude_api]
enabled = true
model = "claude-opus-5-5"
effort = "medium"
build_model = "claude-sonnet-5-5"   # the phases' working tier
build_effort = "high"
monthly_budget_usd = 200   # what the organization holds
reserve_pct = 10           # kept back; the spend ceiling is 200 - 10% = 180
budget_reset_day = 1       # day of the month the period starts, 00:00 UTC
round_budget_usd = 5       # most one round may spend (a killed round counts all of it)
reserve = false            # true: Claude waits for the others, and runs only while they are out
turn_max_s = 18000         # a turn ends after this long (every agent has it): 5 hours
turn_budget_usd = 50       # or after this much spent in the turn
goal_budget_usd = 150      # after this much in one goal, Claude is left out until the goal changes
key_file = "~/.config/baton/anthropic.key"
admin_key_file = ""        # optional Admin API key file, to cross-check spend

[agents.antigravity]
enabled = true
model = "gemini-4-argon-medium"   # the effort is part of the model id
build_model = "gemini-3.8-flash-high"   # must start with gemini-
threshold_pct = 90
poll_s = 30

[agents.codex]
enabled = true
model = "gpt-6.1-sol"
effort = "medium"
build_model = "gpt-6.1-terra"
build_effort = "high"
threshold_pct = 90
poll_s = 15

[superpowers]
dir = ""

Create the key in the Console, in a dedicated workspace with a monthly spend limit, and store it where only you can read it:

mkdir -p ~/.config/baton
( umask 077; cat > ~/.config/baton/anthropic.key )   # paste the key, then Ctrl-D
chmod 600 ~/.config/baton/anthropic.key

baton refuses a key file that is not a regular file with mode 600. It reads the key when it starts the process and passes it only in that process's environment; it is never logged, stored in state or given to another agent.

A goal is a TOML file. Only name, workdir and objective are required; everything else has the default shown in examples/goal.toml:

name = "example"
workdir = "/path/to/project"
objective = "Make the build pass on all platforms."
phases = true   # false keeps the plain relay, with the strong model for every round
oneshot = false # true pins the objective as the goal when the job first starts

[verify]
command = "./check.sh"
timeout_s = 3600

The verification command runs through the shell in the working directory. Exit 0 finishes the goal; anything else, including a timeout, means not done, and its output goes to the next round. Without verify.command, the goal is finished when a cycle ends (a round, with phases = false).

A goal the person fixes (goal.md in the state directory, set from OpenCode or with baton goal) opens every prompt, fresh or resumed, with Goal, fixed by the person. It does not change until the person changes it: and its text. It is on while that file is not empty. A goal over 8000 characters goes to a file under .baton/notes/ once, and only its first 1000 characters and the file's name are pinned. oneshot = true pins the objective as the goal once, at a job's first start (state.json remembers it), so a restart never brings back one that was cleared. Only a goal file you pass to baton run may set it: a repository's baton.toml is refused, as it would pin text an agent wrote.

Between rounds each agent gets the same context: the repository, its commits, .baton/HANDOFF.md (kept current by the agents), the last gate output and the tail of the previous round. .baton/ lives in the working directory and is added to .git/info/exclude. A handoff over 16000 characters is cut in the middle: its first 4000 and its last 12000 are kept, with a line saying how much was left out. From OpenCode, an over-long conversation keeps the person's messages first, newest first, each cut to its first 8000 characters, and gives assistant messages the room that is left, each gap marked with .... With ai-memory installed, what earlier goals learned in the directory is added too (see Long-term memory; memory = false turns it off). After each round baton commits uncommitted work locally as baton: snapshot round <n> (<agent>) so a hard stop loses nothing. It never pushes (snapshot = false turns the commits off). .baton/, __pycache__/ and *.pyc are kept out of it through .git/info/exclude.

When you use baton from OpenCode (below), a baton.toml in the project directory plays the role of the goal file: its [verify], timings and [agents.*] settings apply. name, workdir and objective in it are ignored, since the session decides them. Without one there is no gate, and the job finishes when a cycle ends (a round, with phases = false). An invalid baton.toml is an error, never a silent fall-back to no gate.

A baton.toml comes with the repository, so it may not set bin, key_file, admin_key_file, full_access or poll_s, and no [superpowers] table: a file in a repository must not choose executables or credentials, which files become part of a prompt, or slow the guard's limit reads. It may set phases and the build_model and build_effort of an agent: they name models, not executables. A goal file you pass yourself and the defaults file may set poll_s. Everywhere, threshold_pct may not go above 90, and an antigravity.model must start with gemini-.

OpenCode sees one OpenAI-compatible provider with one model, baton/auto, and keeps the history and context on its side.

Print the provider block and merge it into your opencode.json :

baton opencode-config

Start the gateway. It binds 127.0.0.1 only:

baton serve            # --port 18777 by default

Select baton/auto in OpenCode and write your request.

Each OpenCode session becomes a job that runs in its own thread inside baton serve, independent of the HTTP connection. A dropped connection never stops it, and a new message in the session becomes a note that ends the running round once its current tool call is done (after five minutes at the latest). A message typed while the reply streams, which OpenCode queues in its database and posts only when the reply ends, is read from there every 2 seconds (read-only, and any failure is silent), delivered as a note at once, and not delivered again when OpenCode later posts it. Every conversation of the job reads every note, not only the first one to take it, and a note over 8000 characters goes to a file under .baton/notes/ in the directory, with its first 1000 characters and the file's name in the prompt. A message gets at least one round, even if the gate already passes. A request that repeats what was already accepted, as a client's retry does, attaches to the stream and interrupts nothing, so it costs no round. baton jobs lists every job, and baton status, baton say, baton goal, baton next and baton stop accept its name (session-<id>) in place of a goal file. The stream shows the agents' text, one line per rotation, a sentence when a returning agent ends a reserve Claude round, waits and gate results, and when no agent can run it says why, for each one. Title requests are answered locally and spend nothing. The working directory is read from OpenCode's own database (~/.local/share/opencode/opencode.db, or the path in BATON_OPENCODE_DB); baton never guesses one.

One runner works in a directory at a time, across baton run and baton serve. A directory inside or around a running one counts as busy, since the two would commit each other's work.

A message that starts with baton: (any case) is a command, not work. They never start with /, which belongs to OpenCode. Text that only contains these words is an ordinary message.

Message What it does
baton: goal <text> Fix the goal (the rest of the message, over any number of lines). With no job running it starts one with <text> as the objective; with one running it is also a note, so the next round reads it. Chat:[baton] goal set .
baton: goal off Clear the goal, from the next round's prompt, and leave the note The person cleared the goal. for the agent. Chat:[baton] goal cleared .
baton: next Ask for the next agent in the queue after this round. It is stored for the rotation to take. With no job running baton answers in one line and stores nothing.
baton: next now The same, and the round ends after its current tool call.

A command typed while the reply streams goes through the same parsing when baton reads it from OpenCode's queue, and neither a retry of one nor OpenCode's later post of the same words runs it twice. Old commands are left out of the conversation a new job is briefed with.

Jobs live inside baton serve, so keep it alive. If it dies, even by kill -9, the next baton serve first ends every agent left behind that it can prove is baton's own, then resumes every job that had not finished. A job a person stopped stays stopped. A group that cannot be verified is left alive and its job waits, because two agents at once could spend twice.

On macOS, install it as a launchd service:

baton install-service            # --port 18777 by default

launchd loads no shell profile, so this resolves everything from the shell you run it in. It finds each enabled agent's binary and node, which codex needs, builds PATH from their directories, writes ~/Library/LaunchAgents/local.baton.serve.plist with absolute paths, and loads it. Running it again replaces the service. The log is serve.log in the state directory. Run it again when an agent or node moves. Your verification commands get that same PATH, so a tool they need from elsewhere must be called by its full path. A verification command that exits 126 or 127 can never pass, so baton says so once in the chat and in baton status, and the rounds go on.

Restarting the gateway. baton restart writes a request that the running baton serve sees. Each job then holds at the next point between rounds (also inside a wait), says so once, and the gateway sends itself SIGTERM when every running job is held, so no round is cut off. A job in the middle of a round is never held, and a baton stop still ends a hold. The jobs are resumed after the restart, exactly as after any restart. Under launchd (baton install-service) KeepAlive brings the service back; without a supervisor the gateway simply exits and you start baton serve again. It works whether or not jobs run. baton restart --now does not wait for the jobs: it is the same as stopping the service, and the jobs resume. A request left over when the service starts is dropped. To cancel a request that is still waiting, delete the file .restart in the state directory (~/.local/state/baton/.restart unless BATON_STATE_DIR moves it): the held jobs go on at once.

examples/baton-serve.plist shows the same file with placeholders, for writing it by hand.

Command What it does
baton doctor [goal] Check binaries, logins, stray API keys, live limits, Claude spend and Superpowers.
baton run <goal.toml> Run one goal in the foreground, without OpenCode.
baton serve [--port 18777] Start the OpenCode gateway.
baton jobs List every job and goal that has state: whether its runner is alive, round, current agent, last event.
baton status <goal.toml or job> One-shot status: rounds, cooldowns, recent events.
baton say <goal.toml or job> [--now] "note" Leave a note for the next round. --now also ends the current one, after its tool call.
baton goal <goal.toml or job> "text" /--off Fix the goal every prompt opens with, or clear it.
baton next <goal.toml or job> [--now] Ask for the next agent in the queue; --now also ends the round after its tool call.
baton stop <goal.toml or job> [--now] Stop after the current round, or kill the round with --now .
baton restart [--now] Restart the gateway once every running job is between rounds; --now does not wait. Jobs resume.
baton opencode-config [--port 18777] Print the provider JSON for opencode.json .
baton install-service [--port 18777] Write the launchd plist and load it (macOS).

State lives in ~/.local/state/baton/<goal name>/ (events, state, raw round logs, inbox), one runner per goal. BATON_STATE_DIR and BATON_CONFIG move the goals' state and the defaults file. BATON_STATE_DIR does not move the Claude ledger: it stays at ~/.local/state/baton/claude-spend.json under the real home directory, so that no goal can end up with a ledger, and a budget, of its own. On macOS the runner holds caffeinate so the machine does not sleep mid-goal.

baton never exits because of a limit, a transient error, a crash in an agent or bad network. Only a passing gate, a stop request or a termination signal ends it.

uv sync
uv run pytest -q
uv run ruff check .
uv run ruff format --check .
uv run python scripts/check_private.py

Tests never call a real vendor and never read real credential files: fake CLIs and fake limit readers stand in. scripts/check_private.py is part of CI. It fails on any e-mail address, home path, private or Tailscale address, .ts.net hostname, UUID or credential-looking token in a tracked file or a commit message, because this repository must hold no personal data. Working notes (.superpowers/, .oneshot-state.md) stay untracked, and a test enforces it.

The design is in docs/specs/2026-11-04-baton-design.md.

MIT. See LICENSE.

baton uses two projects it does not bundle and did not write:

  • Superpowers , by Jesse Vincent (MIT): the working methods every round is given. baton reads your own install of it and copies none of its files.
  • ai-memory (MIT): the long-term memory. baton calls its command-line tool and bundles none of it.
── more in #ai-agents 4 stories · sorted by recency
── more on @baton 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/baton-keep-long-codi…] indexed:0 read:45min 2026-11-06 · —