Reference for .toolbox.yaml: every supported key, the loading order, and the TOOLBOX_* environment overrides. Source of truth: internal/config/config.go (schema + validation) and internal/configexample/render.go (annotated template).
toolbox init # write an annotated .toolbox.yaml in the current directory (--force to overwrite)
toolbox config example # print the same annotated template to stdout
toolbox config show # print the fully-resolved configuration (--origin annotates each key's layer)
toolbox config doctor # validate without modifying
toolbox config ui # interactively view/edit keys across the global & repo layers (needs a TTY)
Prefer not to hand-edit YAML? toolbox config ui is an interactive, provenance-first editor for every key below — it shows both the effective value and what each layer (global / repo) sets, and writes through the same validated, comment-preserving path as config set. The full toolbox config subcommand tree is documented in commands.
Configuration is loaded from (highest priority first):
TOOLBOX_* environment variables — only the env-bound keys (image, registry_mirror, pull, bridge, peer_messaging); for those they override every file layer below. Every other TOOLBOX_* var is ignored (viper's AutomaticEnv binds only these keys).--config flag.toolbox.yaml walking up from the current working directory (search stops at $HOME or the filesystem root) — running toolbox shell from any subdirectory of a workspace still picks up that workspace's project config~/.toolbox.yaml (global)| Key | Type | Default | Purpose |
|---|---|---|---|
mounts |
list | built-in defaults | Patch / replace / append / disable the default bind mounts by name. |
mounts_root |
string | "" |
Retarget every ~/.toolbox/-managed default mount to a custom root; empty resets it. |
inherit_host_auth |
list | [] |
Opt listed CLIs into the host's real credential path instead of the isolated default. |
shells |
map | – | Named shell shortcuts: <name>: {path, env}. |
shell |
string | zsh |
Login shell inside the container (only zsh is supported). |
agent |
string | claude |
Default AI agent auto-launched by toolbox worktree: claude or codex. |
image |
string | "" |
Full image ref override (pull-source concern). |
registry_mirror |
string | "" |
Swap only the registry host of the canonical ref. |
pull |
string | auto |
Registry-sync policy for the shell-start refresh and the background prefetch: auto / always / never. |
sdd |
map | – | Per-workspace Spec-Driven-Development skill packs (gsd, bmad, openspec); the editor writes it to the project layer only. |
bridge |
bool | true |
Mount the host bridge state dir (browser / editor / proximo forwarding). |
browser_bridge |
bool | – | Deprecated alias of bridge. |
proximo |
bool | auto | .test reachability + CA trust; omitted = auto-detect (on iff proximo's CA exists on the host). |
managed_statusline |
bool | true |
Image-owned Claude Code statusline, re-applied every shell start; false keeps your own. |
image_reclaim |
bool | true |
Remove the runtime images this CLI pulled that a later image update left nameless; false keeps every generation. |
peer_messaging |
bool | true |
Let Claude Code sessions in different toolbox containers see and message each other. |
env |
map | – | Arbitrary env vars injected into the in-container shell. |
worktree |
map | – | Tune toolbox worktree sessions; seed adds extra gitignored paths to carry into a new worktree. |
shellLogin shell inside the container. Only zsh is supported (the default); bash is rejected at config load with an explicit migration hint (config.ValidateShell).
agentDefault AI agent auto-launched by toolbox worktree sessions. Accepts claude or codex — the two agents baked into the canonical image (config.ValidateAgent). Resolved with precedence --agent flag > this key > the default claude, so the flag is optional once a default is set. Honouring the standard loading order, it can be set globally (~/.toolbox.yaml) for a per-user default or per-directory (.toolbox.yaml) to pin a project to one agent.
Set it via toolbox config set agent <value> (--where selects global vs local). The key has no default written to disk: when unset it resolves to claude at launch, and config show renders that resolved value (agent: claude). A non-canonical image: lacking the chosen agent fails at launch, not at validation.
managed_statuslineThe runtime image ships a curated Claude Code statusline and applies it to every container by force-setting ~/.claude/settings.json statusLine on each shell start (only that key is rewritten — everything else in your settings is preserved). It is image-owned policy: a local edit to the statusline is overwritten on the next shell, so change it via a PR to this repo, not in the container.
Every segment is conditional except the working directory — the line shows only what applies right now:
…/github/toolbox │ toolbox:main* feat-xyz │ #1234 │ Opus 5 high FAST │ ACCEPT
│ @reviewer │ vim:NORMAL │ ▰▱▱▱▱ 22% │ 1h15m │ 5h 24% 17:00 · 7d 41% 06/02 17:00
Left to right: working directory, repo:branch with dirty/ahead/behind markers and the linked-worktree name, the open PR for the branch (clickable, coloured by review state), model with reasoning effort and fast mode, permission-mode badge, custom agent, vim mode, output style, behavioural-mode badge, context-window bar, session duration, and 5-hour / 7-day rate-limit usage with reset times.

Set managed_statusline: false to opt out — the boot hook then leaves your own statusLine untouched. Default (omitted or true) is managed-on. Mechanics in shell-start internals.
image_reclaimEvery merge to main publishes a runtime image, so a developer who keeps up accumulates a local store of images that lost the latest tag and nothing else. image_reclaim removes them: on every shell start, once the session's own container exists and references the current image, a background sweep deletes each image in the local store that carries a repo digest for the toolbox repo, has no tag left, and is not the digest this session runs.
Three properties are worth knowing before you leave it on, which is the default:
~/.toolbox/Dockerfile overlay — your :local image is built on top of the base, so the base has a child and stays.Set image_reclaim: false to keep every image the CLI ever pulled; a disk-space cleanup is then yours to run (docker image prune, which sweeps the whole machine and not just toolbox). Reasoning and the two consequences in full: ADR 0007.
peer_messagingClaude Code's cross-session messaging (ListAgents / SendMessage) delivers a message to another session on the same machine. Between two toolbox containers it does not work out of the box: sessions already share one registry (the claude mount binds a single ~/.toolbox/.claude everywhere), but each container has its own /tmp — so the inbox sockets are unreachable — and its own PID namespace, so the registry's pids do not resolve.
Toolbox makes both hold by default. Participating containers join one toolbox-owned anchor container's PID namespace and mount one toolbox-owned Docker volume (toolbox-cc-socks) at /tmp/cc-socks, so peers become both discoverable and reachable. The typical use: hand a task to a session already open on another repo, without mounting that repo into this one.
Default on, with one thing to know before leaving it there: containers sharing a PID namespace can see each other's process table. Workspaces that must not see each other — different clients, say — need peer_messaging: false, either globally in ~/.toolbox.yaml or in the project's own .toolbox.yaml, which wins over the global one. Per-session override: toolbox shell --peer=false declines it for one run (--peer asks for it against a config that turns it off).
The setting is part of the container identity: a participating session runs in its own container, distinct from the isolated one for the same workspace (the name gains a .peer suffix — a separator no shell name can produce, so the two can never collide). HostConfig is fixed at ContainerCreate, so changing the setting for an existing container means stopping that one container first: the shell detects the mismatch — in either direction — and names the toolbox stop <container> to run, which is the targeted alternative to toolbox stop --all. The same warning covers a container that carries the right namespace but not the toolbox-cc-socks volume: one created before the socket directory became a volume, or while that volume was unavailable. The socket directory is a Docker volume rather than a host bind on purpose: Claude Code chmods each inbox socket right after binding it, and Docker Desktop for macOS serves host binds over virtiofs, where chmod(2) on a socket inode fails with EINVAL — the listener never starts and no session is reachable, its own included. The volume is created on the first participating shell and initialised once by a throwaway root container, which chowns it to your UID/GID and tightens it to 0700; Claude Code answers a looser directory by silently falling back to a private one. toolbox stop --all leaves the volume in place — it holds nothing but live sockets, and reusing it skips the init. Removing it with docker volume rm toolbox-cc-socks while no participating shell is running is safe: the next shell re-creates and re-initialises it, on the reattach path as well as on a fresh container. Upgrading from a toolbox that predates the volume leaves ~/.toolbox/cc-socks behind on the host — dead state, nothing reads it, delete it.
The anchor (toolbox-peer-anchor) runs the toolbox runtime image — the base image, never the :local overlay a ~/.toolbox/Dockerfile builds, since the anchor is shared by every session on the host — with a bare sleep entrypoint, is created on the first participating shell — with the default on, effectively the first shell you open — and outlives the sessions referencing it. It is swept up by toolbox stop --all and hidden from toolbox list — it is infrastructure, not a shell. If it cannot be created the shell starts anyway, without peer messaging, and warns.
This rests on Claude Code implementation detail (a pid-keyed registry and a liveness check), not on a documented contract: an upgrade can end it without notice. The supported alternative is Remote Control on both containers, which routes through Anthropic servers. For fire-and-forget delegation you need none of this — DooD is a default mount, so docker exec -w <workspace> <other-container> claude -p "…" already runs in the other repo with the shared credentials. Rationale and rejected options: ADR 0003.
inherit_host_auth: [<key>, …] in .toolbox.yaml opts the listed CLIs into reading the host's standard credential path instead of the isolated ~/.toolbox/<key>/ default. Default is [] — fully isolated, matches the pre-#276 behavior.
Eligible CLIs and their host paths (catalog entries with non-nil HostAuthMount):
| Key | Host path | Container path |
|---|---|---|
gh |
~/.config/gh |
/home/toolbox/.config/gh |
glab |
~/.config/glab-cli |
/home/toolbox/.config/glab-cli |
gcloud |
~/.config/gcloud |
/home/toolbox/.config/gcloud |
docker |
~/.docker |
/home/toolbox/.docker |
azure |
~/.azure |
/home/toolbox/.azure |
oci |
~/.oci |
/home/toolbox/.oci |
claude |
~/.claude |
/home/toolbox/.claude |
codex |
~/.codex |
/home/toolbox/.codex |
atuin |
~/.local/share/atuin |
/home/toolbox/.local/share/atuin |
Validation in config.Plan rejects unknown keys and keys whose catalog entry lacks HostAuthMount.
Mount semantics: when a key is listed in inherit_host_auth, the default ~/.toolbox/<key> mount is dropped (not supplemented) — two mounts at the same container target would shadow unpredictably. User mounts: patches keying on the same name: still compose on top of the inherited mount.
mounts_root interaction: if both mounts_root: /custom and inherit_host_auth: [<key>] are set, the mounts_root retargeting is bypassed for that key — host inheritance pulls from the host's canonical path (e.g. ~/.config/gh), not from /custom/gh. mounts_root still applies to every other default mount. If you need the credential dir on an encrypted volume, choose one approach or the other.
Pre-stat check: inherit_host_auth: [<key>] requires the host source path to exist at config-load time. If it does not, toolbox shell fails with a clear error pointing at the missing path — silent soft-skip would have left the container with no credential mount at all (worse than failing loud).
Read-write inheritance: inherited mounts are read-write. Most listed CLIs refresh tokens or update session state during normal use (atuin appends history, claude/codex write session state, gh/docker rotate OAuth refresh tokens) — RO would EROFS those writes. You opt in explicitly: your host credential dir is now writable by container processes.
macOS keychain caveat: gh on macOS stores its OAuth token in the system keychain by default — ~/.config/gh/hosts.yml carries the account but no oauth_token, so inheriting that dir mounts a token-less config and gh auth status inside the container reports the token invalid. Workaround: re-login on the host with gh auth login --insecure-storage (persists the token into hosts.yml), or skip inheritance for gh and log in once inside the container (isolated ~/.toolbox/gh survives recreates). The same class of issue applies to any CLI that delegates secret storage to an OS keychain.
toolbox shell defaults to ghcr.io/filippolmt/toolbox:latest. There is no per-tool opt-out, no local-hash fallback, no auto-build branch. toolbox build is the explicit escape hatch — it overwrites the local cache of the canonical tag so the next toolbox shell picks up the freshly built image. Run docker pull ghcr.io/filippolmt/toolbox:latest to restore the upstream copy.
Source relocation (opt-in). The ref and pull behaviour are configurable — globally (~/.toolbox.yaml), per-repo (.toolbox.yaml), or via TOOLBOX_* env — for users who serve the image from a proxy hub / pull-through cache (Harbor, Artifactory, Nexus, ECR pull-through). internal/imageref.ResolveImage(image, registryMirror) owns the precedence, highest first:
image — full ref override, used verbatim. Highest. Caveat: a local toolbox build tags the canonical ref, so with a full override imageplan.Ensure looks for the override ref and won't find the local build — image is a pull-source concern, not a build target.registry_mirror — swaps only the registry host of the canonical ref, preserving filippolmt/toolbox:latest (host split via imageref.SplitRegistryHost, shared with the pull half's registryOf). The relocated image is byte-identical, so a registry_mirror does satisfy Ensure. The mirror is also authoritative for the update probe: detection goes through the daemon (DistributionInspect), not to canonical GHCR, because the only probe worth making is the one that leads to a pull — announcing an image the mirror cannot serve would be noise. Perceived latency for a new image is therefore the mirror's. Caveat: a pull-through cache that hasn't ingested the image yet fails the first shell with manifest unknown — warm it (or pre-seed locally with pull: never), see troubleshooting.The pull policy (auto default | always | never) governs two acts: the registry refresh at shell start, and the background update prefetch that runs for as long as the shell is attached.
Where the pull cache lives. A successful pull stamps a marker under the resolved toolbox state dir (<state dir>/pull-cache/), so it follows a mounts_root retarget or a --profile the same way every other toolbox-managed marker does — ~/.toolbox/toolbox/state/pull-cache/ only when neither moves it. Deleting that directory forces a fresh pull on the next invocation; it is the only override. A session that disables the state mount entirely has nowhere to keep the marker, and then every invocation pays a registry round-trip instead of one per cache window.
pull |
shell start | background prefetch | banner |
|---|---|---|---|
auto (default) |
asks (see below), then an unconditional pull | on, one shared probe per cadence window (paced by imageprefetch.probeTTL) |
yes |
always |
an unconditional pull, no question | on, same cadence as auto |
yes |
never |
no registry round-trip (air-gapped — Ensure still hard-requires the image locally) |
off | silent |
always therefore differs from auto at shell start and nowhere else: forcing a pull on every background tick would spend real bandwidth for the whole session, and adoption is a fresh container either way. Cross-cutting: under any policy the prefetch abstains while the resolved ref carries no repo digest — the fingerprint of a local toolbox build, so an automatic download never overwrites one you asked for.
Env override requires the keys to be viper-seeded (SetDefault in config.Merge) — AutomaticEnv only resolves TOOLBOX_* for keys it already knows, and TOOLBOX_PULL=never toolbox shell silences refresh and prefetch together for one run with nothing written to disk. Edit via toolbox config set --where global|local [--image|--registry-mirror|--pull] (empty value resets the key by removing it); add --dry-run to see the file the write would produce without touching it (dry runs).
Under auto, when the registry is ahead of your local image store, toolbox shell asks before spending your time on the download, with a visible countdown (promptWindow in internal/imageplan) so a few seconds of silence cannot be mistaken for a hang:
A newer runtime image is available. Download it now? [Y/n] (Ns)
N is the seconds left, redrawn in place until it runs out.
One keypress is the whole answer — the terminal is in raw mode for the length of the question, so nothing waits on a Return behind it, and a Return typed out of habit behind the key is swallowed rather than handed on to the shell that is about to start. ctrl+c stops toolbox shell outright: it is not read as a "no", so no session starts and no postponement is recorded. y, a bare Return, or letting the countdown run out all download it and start the session on the new image. n is "later", not "no": the session starts on the image already in your store while the background prefetch fetches the new one anyway, so toolbox-reload moves you onto it whenever you want it. Nothing extra downloads on the "later" path — the prefetch's own first pass is what advances the store — and the moment you declined is stamped on the state mount, so a postponement is legible to the session it postponed rather than lost.
Five cases never reach the question, because the answer is already settled:
| Case | What happens |
|---|---|
| The image is missing from your store entirely | Pulled synchronously, no question — there is no session to start otherwise. |
pull: always |
Pulls. A policy that already said yes on every shell cannot coherently be asked again. |
pull: never |
Neither probes nor asks — not talking to the registry is that policy's whole promise. |
| No tty (a script, a pipeline, CI) | Neither asks nor probes. The default inverts: start now, fetch behind. The interactive default is justified by the work that follows the wait; a script has no work that follows, so the same wait is pure latency times every invocation. |
| A container for this workspace is already running (you are attaching a second terminal) | Nothing is asked. Docker cannot swap the image under a running container, and replacing it would end whatever else is attached to it — panes, agents, another shell. The prefetch fetches it behind you and the banner then offers toolbox-reload. |
A container that exists but is stopped — after a daemon or host restart, or a hand-typed docker stop — is asked about, and the question is a different one, because there a yes can be honoured: nothing runs inside it and nobody's session is attached, so the download is followed by a rebuild.
A newer runtime image is available. Recreate this container on it? [y/N] (Ns)
Note the default. Letting the countdown run out declines here: a window nobody answered may start a download, it may not discard a container. A y downloads the image, destroys the stopped container and rebuilds it on the new one — anything written inside it outside the ~/.toolbox/ mounts goes with it, which is the same trade toolbox-reload makes. n starts the container as it is. A download that fails (an expired registry token, a dropped connection) rebuilds nothing: the container is left exactly where it was, since a yes the registry could not honour has bought nothing to trade it for. pull: always recreates nothing either, for the same reason it is never asked: a policy about downloads has said nothing about containers. Rationale and the options weighed: ADR 0008.
Knowing whether to ask is itself a registry round-trip, so the question is answered from the prefetch's shared probe cache whenever its stamp is still warm — a sibling session that probed a moment ago has already established the fact. Only a cold stamp probes, and the probe is a DistributionInspect: metadata, not a download. Rationale and the options weighed: ADR 0005.
To layer your own tools onto the standard image without a repo change or a per-shell init.d/ script, drop a Dockerfile at ~/.toolbox/Dockerfile (retargeted with mounts_root / a profile root, same as every ~/.toolbox/-managed path). Its presence is the sole opt-in — global only, no per-repo or config-key activation. When present, toolbox shell builds a derived image tagged ghcr.io/filippolmt/toolbox:local on top of the resolved base and runs from it; when absent, the shell runs from the base image unchanged.
Append-only, RUN-only contract. The overlay is a bare fragment — it MUST NOT contain a FROM line and builds with an empty context (no COPY/ADD from host files, so nothing under ~/.toolbox/ is ever tarred into the build). Toolbox injects FROM <resolved base image ID> ahead of your fragment, so entrypoint, init.d/, and host-UID mapping are inherited unchanged. Use it for RUN sudo apt-get install … / RUN pip install …-style additions:
# ~/.toolbox/Dockerfile — no FROM line; RUN only.
RUN sudo apt-get update && sudo apt-get install -y --no-install-recommends \
httpie \
&& sudo rm -rf /var/lib/apt/lists/*
Rebuild triggers. A marker (base image ID + sha256 of the Dockerfile bytes) is stored under the toolbox state dir (~/.toolbox/toolbox/state/local-overlay.marker by default — the resolved state mount, so a mounts_root or a --profile moves it alongside the image-pull cache and toolbox-managed state stays out of your config dir; with the state mount removed it falls back to the default location for your overlay Dockerfile's own root, because losing the marker would rebuild on every shell). The build is skipped when the marker matches and :local is present locally; otherwise it rebuilds. So a rebuild happens when you edit the Dockerfile, when the shell-start refresh updates the base (its image ID changes), or when the :local image is missing. One marker per state dir, and one :local tag: two profiles that share a state dir but keep different overlay Dockerfiles therefore rebuild on every alternation between them — the alternative is worse, since without a shared marker each would find its own marker matching and start from the other's image. Base freshness stays governed by pull; :local carries pull policy never, so Sync/Ensure never reach a registry for it. The first build streams its output and is unavoidably slower; later shells skip via the marker.
Fail-loud. A failing overlay build (e.g. a broken RUN) aborts the shell and surfaces the build log — Toolbox never silently falls back to the base image.
Next fresh container. A rebuilt :local takes effect for the next freshly-created container under the existing AutoRemove lifecycle. A running/stopped container is reused as-is and adopts the new image only when a fresh container is next created — toolbox stop (or exiting the shell) forces that. Rollback: delete ~/.toolbox/Dockerfile (revert to base) or docker rmi ghcr.io/filippolmt/toolbox:local.
browser_bridge (deprecated)browser_bridge: is the pre-rename spelling of bridge:. When bridge: is absent, the loader folds browser_bridge into it (fillDefaultsBackstop in internal/config/config.go); when both are set, bridge: wins. Migrate to bridge: — the alias survives only for config files written before the rename.
env: passthroughArbitrary env vars injected into the in-container shell via an env: map in .toolbox.yaml. Motivating case: opt-in env-gated CLI features like CLAUDE_CODE_WORKFLOWS=1 (Workflow tool), which previously needed a persisted ~/.zshrc edit.
env: # top-level — applies to every toolbox shell
CLAUDE_CODE_WORKFLOWS: "1"
CLAUDE_CODE_EFFORT_LEVEL: medium
PROMPT_HIDE_KUBE: "1" # hide the starship kubernetes segment
shells:
infra:
path: /tmp/infra
env: # per-shell — overlays the top-level map
AWS_PROFILE: prod
The kubernetes and gcloud prompt segments are opt-out via PROMPT_HIDE_KUBE / PROMPT_HIDE_GCLOUD (set to any value to hide). Details and why terraform/docker_context aren't env-toggleable: prompt module toggles.
GIT_CREDENTIAL_BRIDGE=0 disables the bridge git credential helper without uninstalling the bridge (default on when the bridge is installed). It is un-prefixed by necessity — the TOOLBOX_ prefix is reserved (see below), so this is the supported way to gate it.
Contract:
--config → walked-up .toolbox.yaml → ~/.toolbox.yaml → TOOLBOX_* env → defaults), so the top-level env: is set globally (~/.toolbox.yaml) or per-project (.toolbox.yaml). The per-shell shells.<name>.env overlays it for that named shell only — per-shell keys win on collision (config.Config.EffectiveEnv, resolved inside sessionplan.Plan from the name you typed, matched case-insensitively and space-trimmed).sessionplan emits the curated TOOLBOX_*/PWD/SDD entries first, then the loopback-bridge markers, then the self-identity pair (TOOLBOX_CLI_VERSION, TOOLBOX_IMAGE_DIGEST), then the host platform (TOOLBOX_HOST_OS, TOOLBOX_HOST_ARCH), then the managed-statusline opt-out marker, then the proximo entries, then the reload marker path (TOOLBOX_RELOAD_MARKER, omitted when the session mounts no state directory), and last the user env sorted by key (sessionplan.userEnv, deterministic for tests).config.ValidateEnv rejects empty keys, keys containing =, and any key with the TOOLBOX_ prefix or the literal PWD — those are owned by the curated contract. Same rules apply per-entry under shells.<name>.env (errors namespaced as shells.<name>.env: …). Empty values are allowed (export VAR=). Keys are injected verbatim: environment-variable names are case-sensitive, so FOO and foo are distinct vars (both the top-level and per-shell maps preserve the case you write).tools: block, like sdd: / bridge: — flipping a key never invalidates the image hash. Takes effect on the next container create (toolbox stop first to refresh an existing one).Tunes toolbox worktree sessions. A worktree is a checkout of git-tracked files only, so create/open seed a curated set of gitignored per-repo working state from the main repo into the new worktree (.claude/settings.local.json, .env/.env.*, openspec/, gsd's .planning/). worktree.seed adds extra repo-relative paths to that set:
worktree:
seed:
- .secrets.local
- config/local.yaml
Contract:
seed entries alike — is copied only if git check-ignore reports it ignored in the main repo. A tracked path already arrives with the checkout; a non-ignored untracked path is left alone. So a seed entry that isn't gitignored is a silent no-op, and the built-in defaults self-correct in a repo that tracks one of them.seed is unioned with the built-in defaults (not a replacement); directories are copied recursively. Copies never clobber a worktree-local edit (an existing destination is kept).config.ValidateWorktreeSeed rejects absolute paths, entries containing .., and empty strings — the paths drive filesystem reads under the repo root and writes under the worktree, so traversal must not escape either tree.TOOLBOX_* environment variablesViper is configured with SetEnvPrefix("TOOLBOX") + AutomaticEnv, with the image-selection keys explicitly seeded so the override always resolves:
| Variable | Overrides |
|---|---|
TOOLBOX_IMAGE |
image |
TOOLBOX_REGISTRY_MIRROR |
registry_mirror |
TOOLBOX_PULL |
pull |
TOOLBOX_BRIDGE |
bridge |
For these four keys the TOOLBOX_* env var sits at the top of the loading order: it overrides every file layer (--config, project, global), and only the built-in default is below it.