colonizer
install
docs A colony's life

#A colony's life

A colony is one agent in one microVM, working one repository, often on one issue. This guide follows a colony from launch to cleanup. For each feature it says what the feature does, how to switch it on or off and what the default is, and where it stops.

Many of these features already have a full reference elsewhere. In those cases this guide gives a short summary and a link. Settings named here are module settings, changed in the cockpit under Settings → Modules (for example Settings → Modules → Sandbox). Environment variables are read by the mothership when it starts.

#Launching a colony

There are three ways to start a colony. All of them go through POST /api/sessions, so they all get the same checks: the parallel limits, issue claims and the epic guard.

"Autopilot" means the colony publishes on its own. When the agent finishes cleanly and has written its PR description, the mothership pushes the branch and opens the pull request. The default comes from the Publish module's autopilot setting, which is on.

A launch past the parallel limit is not refused. The colony is created queued and starts when a slot frees. The limits are the Sandbox settings max_parallel (default 3) and repo_max_parallel (default 3), plus an org's own limit if it sets one.

Queue priority. Queued colonies start by (priority desc, created_at asc), so with nothing set it is first in, first out. An org's queue_priority (High = 10, Normal = 0, Low = -10 in the cockpit; any integer through the API) puts all its colonies ahead of or behind other orgs'. A single queued colony can be moved with Move to front (above every other queued colony) or Move to back (below), on its page and in the Overview table; the Nest header names the colony that is next up. Priority only picks who is tried first: the global, org and per-repository limits still apply, and a colony whose repository is at its cap is skipped for the next one that fits. An org's optional max_wait_hours is a starvation guard: once a colony has queued that long it counts as High, however low its org or own priority, though never above a colony moved to the front.

Automatic mode. With the stack on Automatic and no number in max_parallel, there is no fixed parallel limit. Each colony is sized from the host: the host keeps the larger of 8 GB or a tenth of its RAM and 2 vCPUs, the colonies share the rest (3 vCPUs and 11 GB on 32 cores and 124 GB; a cpus or memory you set still wins). A queued colony starts when the live free memory (MemAvailable on Linux, free plus inactive pages from vm_stat on macOS) less that reserve holds its memory and the 1-minute load average leaves room for its vCPUs, re-checked on every queue tick. Below the reserve nothing new starts and admission resumes when memory frees; running colonies are never stopped for it. A microVM allocates lazily, so free memory alone would let a burst of colonies in that the host cannot hold once they work. Admission therefore also counts what is committed: the memory sizes of the live colonies times auto_overcommit (default 0.75, kept within 0.5 to 1.0), plus the reserve, must fit in RAM, and their vCPUs must stay within 1.5 times the cores. Over the limit, nothing new starts. auto_max_parallel (default 32) is the cap that holds regardless, and a number in max_parallel switches back to a fixed limit. If the host cannot be measured the fixed max_parallel applies. /api/status reports it as sandbox.mode, size, room_for, waiting_reason, committed_gb and limited_by (cap, memory-commit, cpu-commit, free or load).

See architecture.md, Session lifecycle for every state a colony passes through.

Overrides. The CLI (colonizer launch --allow-duplicate, --queue-behind-holder, --allow-epic) and the MCP launch_colony tool (allow_duplicate, queue_behind_holder, allow_epic) take the same overrides the cockpit's checkboxes and POST /api/sessions do. Without one, a launch on a held issue or an epic is refused with a 409 (CLI exit code 5).

#Tools in the colony image and the setup hook

Every colony image carries a small, language-agnostic toolbox a repository's own build or verification may reach for: python3 with pip and venv, python3-yaml (PyYAML), jq, ripgrep, curl, git, make, unzip and ca-certificates. The node preset's stock node:24-bookworm carries npm, yarn classic and corepack but not bun or pnpm: the colony-node image that adds them is built but not yet pinned (#589), so a bun or pnpm repository's check comes back unverifiable, naming the missing tool, until it is or a .colonizer/setup.sh hook installs it. The image is pinned by digest, so a tool the image does not carry is one a done-claim check reports unverifiable (see Verifying "done"). On the Debian-based presets pip3 install works despite PEP 668 — the images set PIP_BREAK_SYSTEM_PACKAGES=1 for a disposable VM — and an isolated python3 -m venv is the alternative.

For a repository that needs more, add a .colonizer/setup.sh to it. The daemon runs sh on it — it need only be a file, not executable — as root, from the worktree, on every boot (a colony's microVM filesystem does not survive a suspend, so a resume runs it again), once its HTTP listener is up and before the coding agent starts:

#!/bin/sh
# Idempotent: the install is a no-op once the tool is there.
command -v shellcheck >/dev/null 2>&1 || apt-get install -y shellcheck

The hook runs as root with the VM's full capabilities, without the seccomp and capability hardening the agent's runner child gets — that hardening forbids apt-get, the hook's point. Treat .colonizer/setup.sh like a Dockerfile RUN step: repository-controlled code with more privilege than the agent, so add it only to a repository you trust, and write it to be idempotent — it reruns after every resume. It gets 600 s; past that it is killed, its whole process group with it. Its output goes to /tmp/colonizer-setup.log, and the outcome is a log event (elapsed time on success; exit code and log tail on failure); a failure or timeout never fails the boot, only adds a note to the agent's first prompt. A stop or restart while the hook runs kills it and skips the agent. The hook is subject to the colony's egress policy; in allowlist mode the package mirrors every preset's toolbox fetches from are always allowed (see sandbox-network.md).

Cost. With no hook present the check is one file test, effectively free; a hook costs its own runtime, logged per colony. The toolbox adds no boot-time work — the image is larger, paid once on the first image-pull and cached after. Measured with apt-cache against a node:24-bookworm base: the added packages are about 12 MB of downloads and 60 MB installed, almost all the Python 3 interpreter and its pip/venv stack. It is an estimate, not a VM measurement.

What the agent is told. On a fresh session the agent's first prompt gets one line naming the toolbox commands found on PATH (Preinstalled in this VM: …), plus Missing: … when any is absent; it is not repeated when the agent resumes its own session.

Limits. It is one root script with a 600 s cap, and there is no package cache between boots. The colony-<preset> toolbox images are built and published, but the presets still boot the stock images until the published digests are pinned in images.lock — the same step the node preset has left.

#Claims: one colony per issue

Only one colony at a time may hold an issue. A second launch on the same issue is refused with a 409 that names the colony holding it. An issue is held while a colony on it is queued, live, publishing, or has an open pull request. A colony that stopped, failed, found no changes, or whose pull request merged or closed frees the issue.

There are two ways past a held issue. Both are checkboxes in the cockpit's launch form:

On GitHub a launch marks the issue with the colonizer:claimed label, a host label such as colonizer:host:omarchy (the hostname as a slug, one fixed colour per host, created when first needed), and one claim comment. Filter an org's issues by the host label to see what each machine is working on.

The claim comment is edited in place, never posted again. It shows the colony id, the host, the status (queued, running, waiting for an answer, pull request opened, merged, or released with a short reason), the branch, the pull request link once there is one, and when it was last updated. It never shows prompts, costs or errors. A retry on the same issue edits the same comment and names the colony before it. After a restart the mothership finds its comment again by the hidden <!-- colonizer:claim … --> marker, so it never posts a second one.

Status edits are gentle on GitHub: at most one edit per issue every two minutes (a final state is never held back), no request at all when nothing changed, and a pause from 15 minutes up to four hours, doubling, whenever GitHub answers with a rate limit, abuse warning or 429. If the token may not add labels, the claim keeps only the comment and logs a warning.

When the colony releases the issue (failed, stopped or no changes without a pull request, or its pull request closed unmerged), the comment gets its final state and both labels come off. A merged pull request keeps both labels as the record of who did the work. When a mothership starts, it removes marks it left behind for colonies that are gone.

Host labels and status edits are on by default. To turn them off for an org, set "claim_updates": false in that org's settings (orgs.json, or PUT /api/orgs/{org}). The claim label and comment stay, since other motherships read them. COLONIZER_NO_EXTERNAL_EFFECTS stops every claim write.

One rule decides every duplicate, whoever launches: the cockpit, colonizer launch, the API, MCP, the loops, burn-down, the red team or a redo. The same answer comes back every way, and a refused launch in the cockpit shows who holds the work, with a link to that colony or its pull request (or the host, for another mothership's claim) and the Allow duplicate option.

Limits. The duplicate check only looks at this mothership's colonies. Other motherships are seen only through the GitHub labels and comment. If a mothership never comes back, its label stays until a person removes it. The full rules are in protocol.md, Duplicate-colony prevention and issue claims.

#When a merge supersedes a colony

When one colony's pull request merges, other colonies of the same repository whose work it covered are marked superseded. One covers another when the two carry the same supply-chain target (the package and advisory a colony was launched to fix), are on the same issue, or changed largely the same files — at least three files in common (lockfiles don't count, so two dependency bumps sharing a manifest and its lockfile stay two pieces of work), making up at least 80% of the smaller side's changed-file list. A colony waiting for another to release its issue, and one stacked on the merging colony, are left alone: that merge is what releases them.

A superseded colony is not deleted. One that is queued, parked or suspended is held exactly where it is: it does not start — a quota-parked colony stays parked even when its provider recovers — and Resume refuses, until you keep it. A running colony is told in its chat that the changes are now in main, and it rebases and continues — or finishes with no changes if main already covers the task. A superseded colony with an open pull request has that pull request closed with a note, but only for repositories listed in the org's Close superseded PRs workspace setting; otherwise it is left open for you — and the publish module's merge train skips it until you keep it.

In the cockpit a superseded colony wears a "Superseded by #N" badge, and while it is held a banner offers Keep (run it anyway), with Stop beside it for a live or queued colony. Launching a second colony for a supply-chain target one already holds is refused, not queued behind the holder, unless the launch passes allow_duplicate. A target is a package and an advisory; a fix the supply-chain loop started holds every finding it was given, so the Packages tab, colonizer launch --package P --advisory A and the loop refuse the same finding in the same words.

Limits. Only this mothership's colonies are compared, and a pull request's file list is capped at 500 paths. The API shapes are in protocol.md, Duplicate-colony prevention and issue claims.

#The merge steward: getting a colony's pull request merged

Off by default, per org. In Settings → Workspaces → (org) → Pull requests, Auto-merge is off, green or green+rebase, next to the Merge method (squash unless you pick otherwise) and Delete branch (off; a branch another colony's pull request is stacked on is always kept). The steward only looks at pull requests this mothership's colonies opened, and never calls GitHub for an org that has not opted in.

Every five minutes it reads an org's pull requests with one GraphQL query and decides each one from what GitHub says. The decision is a pure function, merge_steward::decide:

What it findsWhat it does
Green, GitHub says CLEAN, not a draft, no hold / do-not-merge / needs-human label, no open question on the colonyMerges, pinned to the head it read (--match-head-commit). A merge queue is asked for GitHub's auto-merge instead. At most one per repository per cycle.
Behind or conflicting, in green+rebaseGitHub's update-branch first. If that conflicts, and the watcher's own rebase has flagged the colony (needs_rebase), the colony is resumed with a rebase task.
A real failing checkThe colony is resumed with the failing job's name and the tail of its log. Two rounds at most, and never twice for the same head; then the pull request is marked needs attention.
Every failed job ended in under 10 s with no steps, or GitHub's annotation names billing or a spending limitMarked ci blocked. No colony is spent on it, and one banner per org says GitHub Actions is blocked there.
It adds a changelog.d/ fragment while a release: vX.Y.Z pull request is open in the repositoryWaits: the release train's changelog check would fail on a fragment that lands after the release assembled its own.
Anything else (running or missing checks, BLOCKED, a requested change, a fork)Waits, and says why.

Branch protection is never second-guessed: only mergeStateStatus: CLEAN merges, so a pending or failing required check, a missing review or a merge queue is GitHub's to settle. The steward waits out an open GitHub circuit breaker (GitHub failures), and leaves a repository the publish module's merge train or the merge-train loop drives to them.

The cockpit lists each pull request with its state (waiting, merging, rebasing, fixing, ci blocked, needs attention) and a Merge now button, which asks GitHub again and merges only a pull request GitHub itself calls mergeable. GET /api/merge-steward serves the list; /api/status carries the blocked orgs as merge_steward.ci_blocked. A pull request needs at least one check to report before the steward merges it.

#Epics are refused

An epic is a planning issue whose work lives in its sub-issues. A colony on the epic itself would repeat the work of the colonies on those sub-issues. So a launch on an epic is refused with a 409. The message says why the issue counts as an epic and lists up to ten of its open sub-issues to launch instead.

An issue counts as an epic when any of these is true:

How to override. In the launch form, check "Start on the epic anyway". Over the API, send allow_epic: true. The Colonize pane greys out epics and leaves them out of bulk hand-offs.

Limits. If the GitHub lookup fails, the launch goes ahead. The guard helps you avoid a mistake. It is not an access control. The CLI (--allow-epic) and MCP (allow_epic) take the same override (see above). Details are in protocol.md, under "Epics".

#A repo can opt out

A maintainer can say their repository does not want colonies. A launch on such a repo is refused with a 409, and the message names which opt-out was found. Any one of these signals is enough:

How to override. The repo's own owner (the owner of owner/repo, in any case) can always launch there: the opt-out is checked against the signed-in GitHub user, and the owner's launches are let through. Nobody else can, and there is no request flag for it.

Limits. If the GitHub lookup fails, the launch goes ahead. The opt-out is a guard that respects a maintainer's wishes. It is not an access control. Like the epic guard, it is best effort: a missing file or label is simply no signal, and only a config file that exists but cannot be read is logged and ignored.

#A repo's daily PR cap

Colonies open pull requests, and some repositories would rather be handed a few a day than be flooded. You can cap how many pull requests colonies open in a repo per UTC day. The cap is off unless you set one: nothing is parked by default.

How to set it. Install-wide, set Pull requests per repository per day (max_prs_per_day) in the publish settings; it applies to every repo. A repo can set its own with max_prs_per_day under the [colonizer] table in .colonizer/config.toml at its root, next to the enabled key above, and that wins over the install-wide setting:

[colonizer]
max_prs_per_day = 12   # this repo's own cap

0 means uncapped, in either place: it is the default, and a repo can set it to opt out of an install-wide cap. Like the opt-out above, the repo lookup is best effort: if the config file cannot be read, the install-wide setting applies.

What a cap does. The cap is checked when a pull request is about to be opened, not at launch, so a colony that runs out of room is the only thing delayed. The Create PR press and autopilot's verdict both go through the same check, so neither can outrun it, and a publish already in flight counts toward the cap — so two presses at once cannot both slip one past it. A colony that hits the cap is parked with the reason repo_pr_rate_limit: it keeps its worktree, its branch and its work, the cockpit shows the park and when it resumes, and the queue requeues it on its own once the UTC day rolls over — nothing to press. It resumes sooner if the cap stops holding it back: set the cap to 0, or raise it past what the repo has opened today, and the queue requeues as many parked colonies as the new room allows within a few minutes. A park releases the colony's slot and tears its microVM down, as every park does, unless your org sets discard_vm = false (or its worktree cannot be verified, in which case the machine is kept for the work in it).

#Exec policy: install, org and repo

The exec policy is rules about the shell commands a colony's agent runs: deny, ask you, or allow (the format and the built-in rules are in modules/agents/claude-code/README.md). A colony layers up to three policies over the built-in one, in this order:

  1. install — the agent module's exec_policy setting in Settings → Modules;
  2. org — the Exec policy box in the org's workspace settings (issue #924), stored as exec_policy in orgs.json and handed to the org's colonies as COLONIZER_EXEC_POLICY_ORG;
  3. repo — the repository's own .colonizer/exec-policy.json.

Across layers the strictest decision wins, so each layer can only narrow the ones before it: an org deny beats an install allow of the same command, and an org allow cannot undo an install deny.

Only the owner can change an org's policy. The save is refused, with the reason under the box, unless the runner would keep every rule of it: valid JSON of at most 64 KiB, an object with a rules array, and every rule a deny, ask or allow decision with at least one usable command, script, touches or writes_outside. Leave the box empty for no org layer.

Limits. Claude Code and ACP apply the policy; Codex, Grok Build, Hermes, OpenCode and Pi do not. While any layer is set, a colony on one of those refuses to launch and names where the policy came from — the org by name — rather than run without it. The org's policy reaches colonies launched after the save; a running colony keeps the policy it booted with. A pattern's regex syntax is checked only by the runner, which drops a rule it cannot compile.

#Questions, and who answers them

An agent asks you something with a multiple-choice question. The colony's status becomes waiting_for_answer. You can answer in the cockpit, from your phone, with colonizer answer <id> <number|label|text>, or with the MCP tool answer_colony.

Every question has a risk class. From lowest to highest the classes are read_only, workspace_write, publish_affecting and credential_adjacent. The Claude Code runner assigns the class by scanning the question and its options for words. Words about keys, tokens, passwords or .env make it credential_adjacent. Words about publishing, pushing, merging, deploying or pull requests make it publish_affecting. Anything else is workspace_write. The scan only ever rounds up. The ACP runner assigns the class from the tool kind instead.

The judge. By default nobody answers for you. The Autonomy module is off, so questions wait for a person. Choose "Judge model" to let a model answer questions you leave open:

SettingDefaultWhat it does
modelnoneThe judge model, from a provider you added under Model providers. Your Claude login is never used.
after_minutes10How long a question waits for you first.
max_answers5Judged answers per colony. After that, questions wait for you.
free_textoffWhether the judge may answer a question that has no options.
risk_ceilingworkspace_writeThe highest risk class the judge may answer.

The judge only picks among the options the agent offered. A question above the ceiling is never answered by the judge. It waits for you however long it takes, and the colony log says once why it was left. The cockpit marks judged answers as the judge's.

Limits. The judge does not answer suspended colonies (see the next section). A question with an unknown risk class counts as above every ceiling. The judge also answers to the rate limits that notifications use. The full contract is in protocol.md, §6.2b Autonomous mode.

Full autonomy (YOLO). The second autonomy choice is the judge with no answer limit. It takes the same settings — model, fallback_models, after_minutes (default 1), free_text and risk_ceiling — but has no max_answers, so every ask question at or below the ceiling is answered for as long as the colony runs, however many that is. Everything that bounds the judge still bounds this: it answers only ask questions, only among the options the agent offered, never overrides a deny, and leaves anything above the ceiling for you. The sandbox, egress and path policies hold unchanged, and every answer is logged as the judge's. It needs a model from a provider under Model providers — the judge cannot use your Claude login — and saving a ceiling above workspace_write asks you to confirm once in the cockpit. If three judged calls in a row cannot reach the model, the colony still falls back to waiting for a person.

#Suspending a colony that waits for you

A colony waiting for your answer keeps a microVM and a parallel slot while it does nothing. After a grace period, the mothership suspends it.

Three Sandbox settings control this. All are mothership-wide, with no per-org override:

SettingDefault
suspend_waitingonSuspend colonies that wait for an answer.
suspend_after_minutes10The grace period, from 1 to 1440 minutes.
prewarm_timeout_minutes5How long a warmed colony waits for your answer before it is suspended again.

Limits.

writes-outside-repo now asks only for a write to a host-backed path outside the repository: a write to /root, /usr, a CARGO_TARGET_DIR such as /root/colonizer-target, or the rest of the microVM's own root filesystem — discarded with the VM — no longer asks, while a write to a host mount outside the repository, such as /harness/out or the agent's transcript directory, still does. A write onto a read-only host mount (/colonizer, /opt/colonizer) or into the checkout's own .git asks too, and the card says why. An org can restore the older, stricter behaviour — any absolute write outside the repository asks, the microVM's root filesystem included — with a policy rule whose predicate is "writes_outside": "strict". The secret-paths and script-egress denies are unchanged.

A separate Sandbox setting, hold_timeout_minutes (default 30), parks a colony that autopilot held. It removes the microVM and keeps the worktree. The full design, including why this is not a VM snapshot, is in architecture.md, Suspending colonies that wait for an answer.

A Watchdog setting, idle_park_minutes (default 15, 1 to 1440), parks a colony sooner when it is doing nothing. A colony that is idle, held by autopilot or flagged by the watchdog, with no open question and no publish or verification in flight, is parked once it has sat that long: its microVM stops and its slot is freed, the worktree and branch are kept, and Resume brings it back. A colony with an open question keeps the behaviour above. When the colony is idle because its last turn did not write or update /harness/out/pr.md (for example after a redacted description was held), it first gets one automatic message asking it to rewrite the description; if that does not help, it parks like any other.

#Stacked colonies that wait

A colony stacked on another (after) builds on that colony's branch, and one failure used to take the whole chain with it. Now a dependent whose parent is stopped or parked goes to blocked instead: it holds no slot and no microVM and is not failed, and its card says what it waits on ("waiting on #5 (c8a6d23c, stopped)"). It is queued again when the parent resumes or finishes, and waits for the parent's branch as before. When the parent is gone for good (failed with no parent of its own, cleaned up before it published, a closed pull request, no changes, or deleted) the dependent re-bases on the default branch and queues like any other colony; a conflict is then handled by the normal rebase path. A cascade never produces failed. A blocked colony can be stopped (Leave the queue) or deleted like a queued one.

#Stop, resume and delete

The mothership also stops colonies itself: when a budget or the host-disk quota is passed, and when a microVM dies on its own. In every case the worktree is kept, so Resume continues.

#Recovering on its own

Some failures have no work behind them: a provider that blipped, a status that fell out of step, a parent that paused, a sign-in that expired. The mothership handles these itself rather than handing each one to you as a colony that needs you.

The marks for a broken account are kept in memory. After a mothership restart, waiting colonies resume, and if the account is still broken it is marked and announced again, through the same notification rate limits as every other alert. A keychain-held credential has no file timestamp, so a re-sign-in there is not noticed until the mothership restarts.

#Services that come back after a resume

A resume boots a fresh microVM: every process inside the old one is gone. A colony declares the long-lived processes it wants back in .colonizer/services.toml at the worktree root, and anything the agent starts during the run through colonizer-svc is recorded too; on a resume the mothership hands both to the guest, which relaunches them before the agent sees your answer or brief, waits on each one's readiness, and opens the resumed turn saying what came back. A fresh boot starts nothing; services come back on a resume only.

Restored from suspension. Restarted: web on :5173 (ready in 3.2 s). Lost: background cargo test, rerun if needed.

The manifest lists services, one [[service]] table each:

[[service]]
name = "web"
cmd = "npm run dev -- --port 5173"
cwd = "web"             # optional
ready = 5173            # optional: integer port or "http://localhost:5173/" URL string
env = ["VITE_API_URL"]  # optional: names only
timeout_secs = 30       # optional
FieldTypeRequiredMeaning
namestringyes1-64 characters of letters, digits, dots, underscores or dashes. Also the record's file name.
cmdstringyesShell command, run with sh -c from the worktree root (or cwd).
cwdstringnoDirectory to run in, relative to the worktree root. Absolute paths and .. are refused.
readyinteger or stringnoTCP port or http(s) URL the resume waits on for readiness.
envarray of stringsnoEnvironment variable names passed through from the colony env. A table like env = { TOKEN = "abc" } is refused — values are never stored on this road; pass them through the colony env.
timeout_secsintegernoHow long to wait for ready before the service counts as failed. Default 60.

Services started during the run are recorded with colonizer-svc — the agentd binary under another name, linked onto the guest's PATH at boot:

colonizer-svc start web --ready 5173 --cwd web --env VITE_API_URL -- npm run dev -- --port 5173
colonizer-svc stop web

Claude Code background Bash tasks are recorded the same way, as background tasks. They are never relaunched — the mothership cannot judge whether a finished test run should run again — so a resume reports them lost, exactly once, and leaves rerunning them to the agent.

The records live in the colony's session directory on the host, mounted into the VM at /colonizer/services, so they survive the suspension like the worktree and transcript do. A resume waits each service out to readiness or its timeout_secs, and reports one that never answers as not ready, naming the log it wrote to.

#GitHub failures

Every call the mothership makes to GitHub — gh and the network half of git (fetch, push, ls-remote, clone) — goes through one circuit breaker per GitHub identity (the saved token, the GH_TOKEN/GITHUB_TOKEN environment, or the gh CLI login). A failed call is classified as one of:

ClassWhat GitHub saidEffect
Suspendeda 403 whose body says the account is suspendedopens the breaker
Token revokeda 401, "Bad credentials", git's refused tokenopens the breaker
Missing scopethe refusal names a scope the token lacksthat action fails, with what to add
Secondary rate limita 403/429 naming a secondary limit, or carrying Retry-Afteropens the breaker at the third within 10 minutes
Transienta 5xx, a 429, the primary rate limit, a network failureretried where the caller retries
Othera 404, a validation error, a permission on one repositorythat action fails

While the breaker is open:

A slow probe makes the only call: one GET /user every 30 minutes for a suspension or a revoked token, and for secondary limits after 5 minutes, doubling up to 30. When it succeeds the breaker closes, the queue moves on its next tick, and the held publishes go out one at a time, each verified first. Reconnecting GitHub with another token is a new identity, so the breaker closes at once. The breaker lives in memory: after a restart, the first refused call opens it again.

#Budgets and plan balance

Two Sandbox settings cap what one colony may spend. Both default to 0, which means unlimited.

When a colony passes either budget, its next routed request gets a 403 and the colony is stopped with its worktree kept. Raise the budget and press Resume to continue.

Plan balance. A provider can say where to read what is left in its plan. In Settings → Model providers, open a provider and fill in "Plan balance": a URL and a JSON pointer into the answer (the quota field, {url, pointer}). The URL must be on the same scheme, host and port as the provider's base URL, because the provider's own credential is sent to it. The health card then shows "N left in plan". A failing balance check never marks the provider unhealthy.

Limits.

The reference is in configuration.md and architecture.md, Per-colony limits.

#What a colony cost

Every colony-scoped row of the spend journal (<data>/spend.jsonl) names the colony (session) and the agent module that ran it (agent, for example claude-code or codex). To read it back per colony:

node scripts/colony-report.mjs --costs                             # the last 30 days, like the cockpit's spend history
node scripts/colony-report.mjs --costs --since 2026-09-01 --days 90
node scripts/colony-report.mjs --costs --json

The report ranks colonies by spend. It splits each one into estimated dollars (the agent's own figure at the end of each turn) and metered dollars (what the gateway priced for a routed provider). A colony that used only unpriced providers shows unpriced — tokens only, never $0. Rows without a colony, such as older rows or cockpit chat, are grouped under unattributed, so the total covers the whole window. A harness × model table follows.

Limits. A turn that used several models adds its cost to the totals but not to any single model, because the split is not known. Dollars routed through the gateway are never split by model either. The per-org and per-day views are in protocol.md, §6.8 Spend.

#Verifying "done"

When a colony finishes a turn cleanly and has written its PR description, it is claiming it is done. The mothership checks that claim before autopilot publishes:

  1. It snapshots the colony's work without touching the worktree.
  2. It reads the git state itself: commits ahead of the base branch, changed files, and whether the files the PR description names are on the branch.
  3. It runs the checks the diff calls for in fresh, one-shot microVMs, on a git archive export of the snapshot. Nothing runs on the host, and no result comes from the agent's own logs. Each check has a 20-minute limit.

The verdict is confirmed, contradicted, inconclusive or unverifiable. Autopilot publishes on confirmed, inconclusive and unverifiable, and holds the colony on contradicted. A claim is contradicted only if a check fails in a way the base branch does not, or if none of the files the description names are on the branch: when a check fails, the mothership runs the same check once more on the merge-base, in a fresh checkout of its own. A check that fails there too is inconclusive — the colony did not break it — and autopilot publishes anyway, with a note in the pull request; only a failure new against the base holds the colony. If some named files are missing but others are there, that is only an advisory. Advisories are shown with the verdict and added to the pull request as "Verification notes". They never change the verdict.

A failing check leaves its last 200 lines of output in the colony's out/ directory — out/verify-cargo-test.log, out/verify-web-npm-test.log, one log per check — and the failing test names (up to five, parsed from cargo, vitest or jest output) are quoted in the hold message and the colony's attention detail.

Which checks. The Publish module's verify setting decides. You can override it for one colony at launch.

Which check first. When the diff owes more than one check, the Publish module's verify_focus setting decides the order. shadow (the default) runs them as above and records, in the data dir's jev_focus.jsonl and the colony's log, which check would have gone first — the one owning most of the changed files — and whether it would have caught the failure sooner. act runs that check first and stops at its failure. off records nothing. A confirmed verdict always needs every check to pass.

Limits. If the colony image lacks the tool a check needs, that check is unverifiable — every image carries a small toolbox and a repository can add its own with a .colonizer/setup.sh hook (see Tools in the colony image and the setup hook). A branch that rewrites the file a check's command is read from (scripts.test, the Makefile) cannot grade its own homework: that check comes back unverifiable and nothing runs. A check whose directory the branch deleted is skipped rather than run to a meaningless exit 1. A base result is remembered per repository, image, base commit and check, so a re-verification does not pay for it twice. The full description is in architecture.md, Session lifecycle, step 5.

#Self-healing: the watchdog playbook

The watchdog used to send a generic "no progress" nudge, and a person (or an outside agent polling harness.jsonl) did the rest: recognise a known stall, send the colony the exact fix, publish, switch model. The playbook is that step done by the mothership. It is a table of signature, action, tries; each fix is logged as auto-fixed: <signature> in the colony's harness.jsonl, listed on the session as auto_fixes, and shown in the colony header ("auto-fixed: pr_md_write").

SignatureMatchesActionTries
placeholder_dotfilesa secret-paths denial whose target is a harness placeholder (.env, .netrc, ...) in the worktree and names no real credential pathmessage: the placeholders are harness mounts, leave them, continue the issue1
pr_md_writea writes-outside-repo denial on /harness/out/pr.mdmessage: write pr.md with the file tool1
toolchain_installera script-egress denial on a toolchain installer (rustup, swift, ghcup, ...)message: no toolchains, say in pr.md what was not compiled1
provider_unavailablea turn-error hold on unrecognized_model, or a provider quota flag, where the provider's fallback_model is a <provider>/<model> on a configured provider that is not itself out of quotaswitch_fallback_and_resume2
idle_verifiedidle, autopilot on, pr.md written, a confirmed verification of the tree as it stands now, nothing flagged, quiet for 10 minutespublish1

The same denial coming back after its tries are spent (the colony is looping) runs stop_looping: the colony is stopped, its slot is freed, and it carries the attention reason looping naming the signature. Between a fix and the next action on the same signature the playbook waits settle_secs (120 by default) for the agent to read the message. A person's resume gives a stopped colony fresh tries.

Rows handled by their own mechanism are not repeated here: a contradicted verification is sent back to the agent in fix rounds, a pr.md that only redaction changed is published redacted, and an open question is closed by the judge.

The playbook never releases a security hold. A colony carrying a control-defeat flag, or any attention reason naming a secret, a redaction or a defeat, is left alone, and a denial that completes a control-defeat signature is flagged as before and not answered with a message. The publish action refuses when the tree is not the one that was verified, when the kill-switch is up, or when GitHub's breaker is open.

#Adding a pattern: playbook.toml

The table is data. The compiled-in rows are the defaults; <config dir>/playbook.toml (next to updates.json) adds rows, replaces a default by naming the same signature, or turns one off. It is read on each use, so a new pattern needs no release and no restart. A file that does not parse is ignored with a line in the mothership's output.

# replace = true            # start from an empty table instead of the defaults

[[entry]]
signature = "npm_registry_denied"       # what the cockpit shows as "auto-fixed: ..."
trigger = "denial"                      # denial (default) | provider_failure | idle_verified
action = "send_message"                 # send_message | publish | switch_fallback_and_resume | stop_looping
message = "The npm registry is not reachable from here; use the vendored packages."
max_tries = 2                           # fixes per colony before the signature counts as looping
settle_secs = 120                       # wait this long after a fix before acting again
stop_when_exhausted = true              # stop the colony (attention `looping`) when it comes back
# enabled = false                       # switch a default off by repeating its signature

[entry.when]
kind = "egress_denied"                  # the boundary kind; exec_policy_deny when absent
control = "egress"                      # substring of the control
text_any = ["registry.npmjs.org"]       # one of these in the denial's detail or target
text_none = []                          # none of these
target_in = []                          # target must be a relative path with one of these file names
# error_any = ["unrecognized_model"]    # provider_failure: words in the hold's detail
# quota = true                          # provider_failure: a quota flag matches too

A stall that no row matches goes to playbook::on_unmatched_stall, which does nothing today; it is the hook for an operator agent (#1192).

#Conditional instructions

CLAUDE.md and AGENTS.md load once when a colony starts, and a compaction can summarise them away. Conditional instructions are loaded only when they apply, and loaded again after a compaction.

``toml [[rule]] file = "docs/STYLE.md" paths = ["web/**", "*.css"] # gitignore-style globs labels = ["frontend"] # the issue's labels, any one of them ``

There is nothing to switch on. Add the files to the repository. The Claude Code module watches the paths the agent touches, and each time a fragment loads, the colony log says so.

Limits. This is in the Claude Code module only. A fragment is capped at 16 KiB. A file outside the worktree, including one reached through .. or a symlink, is refused. A path condition holds only while one of the last 20 distinct paths touched matches it. Arrays in instructions.toml must fit on one line. See the Claude Code module README.

#Shared memory

Shared memory is notes per repository, per org and global, which colonies can read. Inside a colony, memory is read-only.

Nothing becomes memory until it is approved. The Memory module's require_review setting is on by default. Turning it off only lets repository notes through. Org and global notes are always reviewed.

Limits. Nothing extracts memories from a conversation automatically. With the mem0 provider, the mem0 key never enters a colony. The access table is in architecture.md, Shared memory access.

#Recall from earlier colonies (deja)

What it is. After a colony finishes, the mothership can index its Claude Code transcripts into a per-org deja index on the host. Later colonies of the same org get a recall tool that searches it, read-only, through the colony gateway with the colony's own token. deja is a local binary: no model call, and nothing leaves the machine.

Turning it on. Off by default, at two levels. Switch on "Transcript recall (deja)" under Settings → Memory for the install, then in each org's settings dialog (Memory group). An org can also opt an enabled install back out. Memory itself must be on for the org. The installer fetches the deja binary; without it, recall stays empty and the mothership prints one line saying so. GET /api/deja shows whether the binary is installed and, per org, whether recall is on, the index size and when it last indexed. GET /api/deja/search?org=<org>&q=<query> runs the same search a colony would.

Limits. Only Claude Code transcripts are indexed, and only the Claude Code runner has the recall tool. A colony without an org is never indexed and gets no recall, and one org's index never answers another org's query. Before anything is written, every secret value the mothership knows is scrubbed, raw and JSON-escaped, for values of six characters and up, and deja's own pattern redaction runs on top. Base64- or percent-encoded forms of a secret are not caught.

#Operator vault

Stage a filtered, secret-scrubbed snapshot of a local Markdown vault (an Obsidian vault, say) read-only into each colony at /colonizer/vault/, with an INDEX.md. Off by default; the mothership only ever reads the vault. Point it at the vault in colonizer.toml and allowlist the folders to stage, each scoped like colony secrets — all, one org or one repo:

[vault]
path = "/home/me/Obsidian/Work"
[[vault.folders]]
path = "Projects/web"
scope = { kind = "repo", repo = "acme/web" }
[[vault.folders]]
path = "Decisions"
scope = { kind = "all" }

A folder path is relative to the vault; "" or "." is the whole vault. An absolute path, one with a .. component, or one reached through a symlink is skipped with a warning. Only *.md files are staged: dot-named files and directories (.obsidian/, .trash/, .git/) and attachments stay out, a note whose frontmatter says colonizer: false is left out, and symlinks are never followed. One note over 256 KiB is skipped and the snapshot stops adding notes at 8 MiB. Every note — and its path — is scrubbed of the secret values the mothership knows, the same deja scrub as the transcript index, before anything is written, and the INDEX.md (titles, paths, tags, status, [[links]] and backlinks) is built from the scrubbed text. The snapshot is taken at each boot and resume, so a later edit needs a new boot to reach a colony; a folder out of scope stages nothing, so /colonizer/vault/ is absent. Base64- or percent-encoded forms of a secret are not caught.

Searching it. A colony with a staged vault gets a vault_search(query, limit?) tool beside the memory tools (Claude Code, Codex, Grok Build, OpenCode, Pi and ACP; Hermes serves neither). It is a plain-text search of the snapshot like memory_search: every term must appear in a note, case aside, and matches are ranked by how often the terms appear, a hit in the title or a heading counting more. Each match names its path under /colonizer/vault/, the line and the heading it sits under, and a short excerpt, wrapped in an <operator-vault> frame that calls it data to verify, not instructions. The snapshot holds only the folders in scope for the colony, so the search never reaches anything else; INDEX.md, dot-named entries and symlinks are left out of it.

Proposing a note. vault_propose(path, title, body, reason) (Claude Code, Codex, Grok Build and OpenCode; Pi and ACP have no channel back to the mothership) never writes inside the colony or the vault: it sends a vault_proposal event, and the mothership queues it for review with its provenance — the colony, its repository and the commit its worktree was at. Only the orchestrator proposes: a subagent's call is refused in the colony and again on the mothership. A proposal from a colony no vault folder reaches is ignored, the secret values the mothership knows are scrubbed from it, and the path must be relative (at most four parts and 200 characters, no .. or dot-named part; .md is added); the title is capped at 200 characters, the reason at 2,000 and the body at 64 KiB, and at most 200 proposals wait at once. The cockpit lists them under Proposed for your vault on the Memory page, as plain escaped text. Accept writes the note as a new file under the vault's inbox folder — Inbox/colonizer/ unless colonizer.toml says otherwise — with the provenance in its frontmatter; it never overwrites a file, never follows a symlink and never writes outside the vault root, and a proposal it cannot write stays in the queue. Reject drops it. Notes you accept reach colonies at their next boot only if the inbox sits in an allowlisted folder.

[vault]
path = "/home/me/Obsidian/Work"
inbox = "Inbox/colonizer"   # relative to the vault; this is the default

#Search earlier colonies' conversations

What it is. Where deja recalls a finished colony's transcript, this searches every colony's conversation — your prompts and the agents' replies — at once, to find how an earlier colony met a problem before. A colony has the same reach, read-only, through the colony_history_search tool (Claude Code only so far, issue #739): it asks its own gateway, which scopes the answer server-side to same-org colonies — or, when it has no org, to org-less colonies of its own repository — and never to itself. Every query word must appear in a message, any case; a search returns at most 50 hits, 20 by default, and no one colony may contribute more than three, newest colony first.

Turning it on. Always on with memory: the mothership hands the colony the gateway URL and its token whenever memory is enabled for it. Nothing is indexed and no model is called — a search just reads conversation logs, so it costs nothing to leave on.

In the cockpit. The panel at the top of History takes a query and filters by repo, org, agent, status and a date range. Each hit names its colony, role and turn; clicking one opens that colony and scrolls to and highlights the turn. It is backed by GET /api/history/search?q=&repo=&org=&agent=&status=&since=&until=&limit=, owner-only, which answers {hits: [...]} newest colony first. An empty q, or a since/until that is not a date, is a 400.

Limits. This is a plain scan, not an index (a normalised transcript index is issue #736): each colony's events.jsonl is read in turn, from its tail — at most the last 8 MiB of each log and 64 MiB across one request, skipping any line over 256 KiB — so it is slower on a large data directory and finds only messages that carry their text inline. Every snippet is redacted again on the way out. Over the gateway a colony whose sensitivity is restricted, missing or unrecognised is visible only to a restricted caller, and one org's log never answers another org's query. It searches conversations, not code, issues or notes.

#The repo-explorer subagent

Every Claude Code colony has a read-only subagent called repo-explorer. It answers questions about the code's structure, such as "where is Y defined" or "who calls Z". Before it searches with find or grep, it checks for a retrieval skill in the colony, such as graft, and uses that first.

It is always on, and there is no setting. It has the same write restrictions as the built-in Explore subagent.

Limits. It only prefers graft when graft is installed. graft is a downloadable skillset, not part of the default install. Without it, repo-explorer searches the same way Explore does.

#Who caused each event

Every line the mothership writes to a colony's event log carries an origin field. It names what caused the line: user, agent, subagent, watchdog, autonomy (the judge), burn_down, redteam, notify or system.

It is always on. To read one colony's transcript filtered by origin:

node scripts/colony-report.mjs --transcript <id> --origin autonomy,watchdog

Limits. Lines written before this field existed have no origin. The vocabulary is in protocol.md, Origins.

#Which commits a colony wrote

When a publish pushes, the mothership records each commit the branch carries past its base in <data>/sessions/<id>/commits.json: the full sha, its git patch-id --stable, the colony and the agent session that wrote it. The patch-id names the change rather than the commit, so the link survives a rebase, a cherry-pick or a message-only amend.

After the branch is rewritten, a reconcile re-points each link whose commit is no longer on the branch to the one commit there with the same patch-id, keeping the old shas in previous. It runs:

Each stamps the tip it reconciled against (tip in commits.json), so a head seen again, or a branch that did not move, costs one comparison and no fetch.

The links show in the cockpit's colony pane under Commits, and in GET /api/sessions/{id}/commits (protocol.md). An orphaned link carries an orphaned badge whose tooltip says why: a squash or rewrite made the match ambiguous, so the link was kept rather than guessed.

Limits. The reconcile never guesses. A squash, an amend that changed the content, or more than one matching commit leaves the link in place, flagged orphaned. A failing git call or fetch changes nothing, and while a rebase is in progress the reconcile does not run. Merged and closed colonies are not reconciled after a sync. A redo colony taking over a branch is not a trigger yet.

#The log archive

When a colony ends, the mothership saves its session directory (event logs, the PR description, and so on) as a compressed bundle:

<data>/archive/<org>/<repo>/<yyyy>/<mm>/<colony-id>.tar.zst

Each bundle has a JSON record beside it with the colony's repository, issue, status, pull request, cost and tokens. Bundles are never overwritten. If a colony is resumed and ends again, a new revision is written (<id>.r2.tar.zst). An unchanged colony is not archived twice.

It is always on, with no setting.

Delete keeps the logs. Deleting a colony archives it first. If that archive fails, the delete is refused and nothing is removed. In the cockpit, a second prompt asks whether to delete the archived logs too. Cancel keeps them. Over the API, DELETE /api/sessions/{id}?purge_logs=true removes the bundles too.

Cleaning up the archive. No bundles are removed until something asks for it. Automatic retention is the Disk cleanup loop's Session archives category (loops.md): off by default, and, when switched on, it removes bundles older than 30 days (settable) and — past an optional size cap — the oldest first.

The Storage panel on the Overview page shows the archive's size and a Clean up now form: a one-off pass run on request. Set "keep N days", "cap at X GB" or both, press Preview to word the plan, then Apply to run exactly what it listed. Every bundle is the only copy of that colony's logs, so nothing is removed unless you also check "Allow deleting the only copy". If the archive changed since the preview, you are asked to preview again. Over the API, this is POST /api/archive/retention with {keep_days, max_gb, allow_single_copy, dry_run, expect}, and GET /api/archive lists the bundles.

Limits. The archive is local only. GET /api/storage reports the archive as totals.archive_bytes alongside the other categories, and its free-disk floor measures free space on the data disk, so the archive counts against it. You cannot open an archived colony in the cockpit. Read the bundle with tar.

#Automatic cleanup of finished worktrees

Finished colonies leave worktrees behind. Every five minutes the mothership removes the worktree and local branch of a finished colony whose work is safe on the remote. That means a colony with a pull request that is open, merged or closed, or one that found no changes. It is removed once it is older than the retention window since its last update.

ControlDefault
COLONIZER_RECLAIMon0, false, off or no switches automatic cleanup off. Manual cleanup still works.
COLONIZER_RECLAIM_RETENTION_HOURS12Hours after the last update before a finished colony is cleaned up.
Sandbox min_free_disk5GBelow this much free disk, queued colonies wait, and finished colonies are cleaned up without waiting for the retention window. COLONIZER_RECLAIM_MIN_FREE sets it when no value is saved in Settings.
Sandbox warn_free_disk10GThe cockpit warns below this much free disk.
"Keep worktree" in the colony viewoffExempts one colony (POST /api/sessions/{id}/retain with {"keep": true}).

Limits. A cleaned-up colony is normally unresumable, because resume needs its worktree — but when the colony's pull request is still open, resume re-creates the worktree from the branch on the remote instead of refusing (issue #623). Only a colony whose pull request is no longer open, or whose branch was itself deleted, stays unresumable once cleaned up. Stopped and failed colonies are never cleaned up automatically, because they can still be resumed. Work that was never pushed is never deleted: GET /api/storage lists it under unpushed for you to publish or clean up by hand. See protocol.md, Automatic reclamation.

#Measuring Jev compaction

Jev compaction is an optional Claude Code setting (jev_compaction, off by default). When the context fills, it deletes stale tool calls by score instead of summarising the conversation. The mothership also measures how good those decisions were. This measurement only records. It never changes what compaction keeps or drops.

When a compaction pass is applied, the mothership writes one decision row per chunk to <data>/jev_ladder.jsonl. If the agent later repeats a tool call that a decision was about (the same tool with the same input), it writes a reread row: evidence that the chunk was needed. The colony log shows the running precision and recall.

There is no separate switch. It runs whenever jev_compaction is on. Turning on Jev compaction sends the colony's conversation to TypeSafe (api.typesafe.ai) at each compaction, and needs a JEV_API_KEY. Read protocol.md, Token savings before you turn it on.

Limits. A pass that was computed but not applied is not measured. A reread only counts when the tool and its input are exactly the same. The bench-wide report that grades colonies against each other is bench.mjs jev (bench.md, Grading Jev compaction). This is separate from the Jev routing second opinion (jev_shadow_mode, protocol.md §6.1c), which is shadow-only unless jev_routing_act lets it pick the tier.

#Rate limits on notifications and the judge

Notifications (desktop and webhook) and the autonomy judge both check one shared record before they act. The record is <data>/ledger.json. It stops a busy afternoon of colonies from flooding you:

NotificationsJudge
Per rolling hour12, then held for the digest30, then held
Per rolling day60, then dropped100, then dropped
Same topic again within10 minutes (held)no cooldown
Same fact, told once within1 hour24 hours
Per topic per day1020 per colony

What is held is summarised in one digest line at most once an hour, sent down the same channels. A question that is blocking a colony skips the hourly limit and the cooldown, but not the daily limits or the duplicate check. Every delivered, held and dropped message is counted by class. GET /api/status reports the counts under ledger, with no colony ids and no question text.

It is always on. Limits: the numbers above are fixed in code and cannot be changed yet. There are no quiet hours by default. The cockpit does not show the counts, only GET /api/status does. The watchdog's nudges do not use this record yet. If ledger.json is corrupt at startup, it is moved aside and the record starts empty.