Documentation

Everything Cleat can do, from first install to the security internals. Current as of v1.5.4.

Why Cleat #

AI coding agents are at their best with full permissions. They install, build, test and iterate without stopping to ask. That is exactly what --dangerously-skip-permissions unlocks in Claude Code. On your actual machine it is a disaster waiting to happen: one wrong command can overwrite system files, read your credentials, or reach into every other project you have. Cleat gives the agent that same unrestricted flag, safely, inside a container that reaches your project and nothing else on your machine unless you grant it.

Without isolation

  • modifies /usr/local, overwrites /etc/hosts
  • reads ~/.ssh, tokens, every secret you have
  • reaches into your other projects

Secrets leaked. Home folder wiped. No undo.

Inside Cleat

  • install, build, delete, run anything
  • cannot touch your system files or your secrets
  • cannot reach your other projects

8 hours later · 47 files changed · nothing outside the repo.

Why not just write a Dockerfile?#

You could. But then you handle UID/GID mapping, clipboard bridging, auth sharing, session persistence, per-project container naming and a dozen edge cases. Cleat does all of it in one command and keeps your files owned by you, not root. The blog post on every safe way to run --dangerously-skip-permissions sets that path against the four alternatives, with the cost of each.

Install #

Requirements

  • Docker. Cleat starts it for you when it's down and offers to install it when it's missing. Prefer to set it up yourself? Get Docker, then confirm with docker run hello-world.
  • macOS or Linux (Windows via WSL2). On macOS, Docker Desktop is the easy way to get Docker. Cleat also runs on plain Docker Engine, so it doesn't require Desktop.
  • An Anthropic account (Pro, Max, Team, or Enterprise). Already signed into Claude Code on this machine? Cleat reuses that login automatically.
  • git, used by the install script, by cleat update on a script install and by the on-start release check on every install shape. A Homebrew keg updates through brew but still asks for the tag list with git ls-remote.
$ curl -fsSL https://cleat.sh/install | bash

This clones Cleat to ~/.cleat, checks out the latest stable tag and symlinks cleat onto your PATH.

Or with Homebrew:

$ brew install cleatdev/tap/cleat

Prefer to read it first? curl -fsSL https://cleat.sh/install -o install.sh, inspect it, then run bash install.sh, or clone the repo and run ./install.sh yourself. If you cloned or moved the script, cleat install symlinks it onto your PATH and cleat uninstall removes it. One caveat for a clone you actually work in: the on-start upgrade offer is skipped while the tree has uncommitted changes, because the automatic checkout it runs would move you off your own work. Use git pull there. On macOS your Claude login lives in the login Keychain rather than in a file, so Cleat copies the current token into ~/.claude/.credentials.json on the way into the box. macOS may ask once for Keychain access. Then drop into any project and go:

$ cd ~/code/my-app
$ cleat

The first run downloads a prebuilt image once (later runs reuse it instantly), starts the container, shares your Claude auth and launches Claude, locked down by default:

✔ Image ready (pulled v1.5.4)

✔ Container started

✔ Auth shared

✔ Claude launched

  Container:  cleat-my-app-a1b2c3d4

  Project:    ~/code/my-app → /workspace

No git identity, no SSH keys, no env files, no Docker socket: just your repo at /workspace and Claude's auth. You add access deliberately with cleat config (next sections).

That → /workspace is the locked-down default. Grant the docker cap and the Project line instead reads (same path, sandboxed). Your repo mounts at its real host path so in-container docker commands resolve on the host daemon. Two things move it: that cap plus --fork, which points /workspace at the box's own copy and adds a Fork: line to the summary. See what a configured start shows.

Signing in#

If you already use Claude Code on this machine, you're set. Cleat mounts your existing ~/.claude login, so nothing else is needed. If Claude asks you to log in, run cleat login once the container is up: the OAuth page opens in your browser and the callback is proxied straight back into the container. You only log in once. Every project and box that is not pinned to a named account shares that login.

If the first run fails#

  • Docker isn't running: Cleat starts it for you (Docker Desktop, OrbStack, or Colima on macOS. Desktop or a rootless engine on Linux. The Windows-side Desktop from WSL2), waits, then continues your command. No docker CLI at all? Cleat offers to install one. Where it can't start Docker (root-owned engine, script, CLEAT_NO_AUTOSTART=1), it prints the exact command to run instead.
  • "Docker is running, but you can't reach its socket" (Linux): your user isn't in the docker group. Cleat prints the exact usermod command with your username already in it. Run it, then log out and back in.
  • Claude asks you to log in: run cleat login (see above).
  • Something else: Troubleshooting lists the messages Cleat prints and what each one wants.

Removing Cleat#

A tool that asks for this much trust should say exactly what it leaves on your disk. cleat uninstall removes one symlink. It touches no Docker state and no configuration, so getting all the way out is three steps in this order:

$ cleat fork prune
# delete fork copies whose box is gone
$ cleat nuke
# every Cleat container, image and the shared build cache
$ cleat uninstall
# the symlink on your PATH (brew uninstall on a keg)

cleat fork prune comes first on purpose. A fork copy can hold the only version of work an agent never committed. cleat nuke deliberately keeps those copies rather than deleting them behind you. It says how many it kept.

Two directories survive all three, so remove them by hand when you want the machine clean. ~/.cleat is the clone the install script made. ~/.config/cleat holds your global config, your env files, your recorded trust approvals and any fork workspaces still on disk. It also holds every named Claude login cleat account stored, removed and held copies included, under accounts/. Those are live credentials. Inside ~/.claude Cleat leaves the empty directories and inert placeholder files it created as mount points. Your shared Claude login is not Cleat's and is never removed.

Quickstart #

The daily loop is six commands. Five of them act on the current directory. cleat ps is the exception: it lists every Cleat box on the machine.

$ cleat
# start the sandbox + launch Claude (the default command)
$ cleat resume
# continue the last session, with full context (alias: r)
$ cleat stop
# stop the container, keep it for next time
$ cleat shell
# open a bash shell inside the container (alias: sh)
$ cleat ps
# list every Cleat container across all projects
$ cleat rm
# remove the container; your session survives on the host

Sessions live on your host, not in the container, so cleat rm never loses a conversation. cleat resume restarts the box you stopped, or builds a fresh one when the box is gone, then picks up exactly where you left off. Once a container is running, cleat login signs you in: the OAuth page opens in your browser and the callback is proxied straight back into the container.

Capabilities #

By default the sandbox is sealed. Capabilities are opt-in grants that extend what it can reach on your host. Each one is off until you turn it on. Manage them with cleat config:

$ cleat config
# editor: space toggles caps, arrows set memory/cpus, enter saves
$ cleat config --enable git
# turn on a capability, globally
$ cleat config --memory 4g
# set the box memory ceiling (--cpus 2 too)
$ cleat config --project --enable env
# write it to this project's .cleat instead
$ cleat config --list
# show enabled caps and resource limits
$ cleat --cap ssh
# enable for one run only, no config change

The editor's global scope has one more row: generate a project .cleat. It stamps your current caps and resource limits into ./.cleat so a per-project cage is one keystroke, not an INI you write from memory. It preserves an existing [setup] section and does not auto-trust: the generated file goes through the normal trust prompt on the next run, exactly like a repo-shipped one.

git mount
Mounts your ~/.gitconfig read-only when you have one. Commits carry your name and email. Identity only: it grants no repo access.
ssh mount
Mounts ~/.ssh read-only when the directory exists. Forwards your SSH agent when $SSH_AUTH_SOCK points at a live socket. push, pull and clone private repos work. Reaches every host your keys can. A real privilege grant, which is why it is separate from git.
env mount
Loads variables from ~/.config/cleat/env and .cleat.env. With it off, those files are ignored entirely.
hooks mount
Runs your Claude Code hooks on the host through a bridge, uncontained, as you. The agent generates the events that trigger them. The command comes from ~/.claude/settings.json alone and never from a project settings file. The event is checked and its paths translated before your hook sees it. A hook command naming a path in the repo is still a file the agent can edit. With it off, hooks are stripped from the container so no host command runs through this bridge. Needs jq on the host.
gh mount
Mounts ~/.config/gh read-write so gh auth login persists. The token lives on the host and survives rm, nuke and rebuild.
docker sandbox
Binds your engine’s real daemon socket so the agent can run docker compose and docker run: the in-VM socket for Docker Desktop, OrbStack, Colima, Rancher Desktop and Lima, the real host socket for rootful and rootless Linux. On a host-local daemon it checks the socket is live before binding, so a missing socket gets a warning instead of a phantom directory. Breaks isolation: socket access is root-equivalent on the daemon. Off by default, loud warning on every Claude launch.
unsafe-rm guard
Answers Claude Code’s one prompt that survives --dangerously-skip-permissions: a destructive rm. That prompt is an intended upstream safety check with no off switch, so Cleat installs a PermissionRequest hook in the box that answers it. The hook answers whenever the command actually invokes rm or rmdir at the top level, so the chained cleanups an agent writes run unattended. A command that removes nothing is never answered. A command that does remove something is approved whole, so any other safety check the same command trips is approved with it. This disarms a real guard. Your project and your ~/.claude are read-write mounts, so an unset-variable rm reaches the host. Off by default, global config or --cap only (never a project .cleat), red warning on every launch. Needs jq on the host. Without it Cleat says so and the prompt stays on.

mount capabilities give the container something from your host (files for git, ssh and gh, an event channel out to the host for hooks, plus environment variables for env). sandbox capabilities change the isolation boundary itself. There is exactly one, docker. guard capabilities disable a safety check inside the box rather than reach out to the host. There is exactly one, unsafe-rm. It is global or --cap only: a project .cleat can never grant it, because the caged agent can write that file. Cleat warns loudly every time it launches Claude under either of the last two: in amber for docker, in red for unsafe-rm.

What a configured start shows#

Turn caps on and the summary names them. They split into a labelled block when more than one kind is on, or whenever the guard cap unsafe-rm is on, so its note is never dropped. Anything else collapses to one line. The docker cap also mounts your workspace at its real host path, so the Project line reads (same path, sandboxed) instead of → /workspace. A loud warning also prints whenever Claude is launched:

✔ Image ready (cached)

✔ Container started

✔ Auth shared

  ! Docker socket mounted. Container can create host-level processes

✔ Claude launched

  Container:  cleat-my-app-a1b2c3d4

  Project:    ~/code/my-app (same path, sandboxed)

  Caps:

    mount:    git, ssh

    sandbox:  docker (breaks isolation)

Changing a capability recreates the container. Docker mounts are fixed at create time, so after a config change the next cleat notices the drift and asks to recreate. Your session survives the recreate.

Upgrading Cleat does not. A new CLI version only prompts to refresh the image when the image's contents actually change (the entrypoint, the bridges, the base). A version-only release leaves your container and everything you installed in it untouched.

Overnight runs and the unsafe-rm guard#

Cleat launches Claude with permission prompts off, which is what makes an unattended run possible at all. One prompt survives that by design. In practice it is the most common reason a long run is found stopped in the morning: a destructive rm. Claude Code keeps a safety check for a removal whose target might resolve somewhere catastrophic, the classic case being a glob under a shell variable that turns out to be empty. That check is deliberately immune to bypass mode. No setting turns it off. The agent cannot answer it on its own, so the session sits and waits for a human who is asleep.

unsafe-rm answers that one prompt for you. Cleat writes a PermissionRequest hook into the box, the mechanism Claude Code documents for exactly this, which replies allow whenever the command actually invokes rm or rmdir at the top level. The chained cleanups an agent really writes go through untouched. A command that removes nothing is never answered. A command that does remove something is approved whole, so any other safety check the same command trips (a Read or Edit deny rule Claude cannot judge, for one) is approved with it.

$ cleat config --enable unsafe-rm
# global, persists
$ cleat --cap unsafe-rm
# this session only
 
# with the cap on, this runs unattended instead of stopping
$ export S=/workspace/.scratch && mkdir -p $S && rm -f $S/*_all.log

The launch summary names it in red, every time:

✔ Container started

✔ Auth shared

✔ Claude launched

  Container:  cleat-my-app-a1b2c3d4

  Project:    ~/code/my-app → /workspace

  Caps:

    mount:    git, ssh

    guard:    unsafe-rm (disables the delete-safety prompt)

What you are switching off. The check is not theatre. Your project is mounted read-write at /workspace and your ~/.claude is mounted read-write too, so an rm whose variable came back empty reaches your machine rather than stopping at the container. Everything else a box does still stops at that boundary. Those two paths do not. Turn this on for runs you genuinely leave alone. If you are sitting with the session, leave it off and answer the prompt, because the check is doing real work.

A project cannot switch it on. It is off by default and Cleat accepts it only from your global config or a --cap flag. A project .cleat is refused with a warning, because the agent inside the box can write that file. A capability that disarms a delete guard must never be reachable by the thing it guards against.

Boxes #

new in v0.14.0

You can skip this until you need it. A single default sandbox is all most people ever use. Boxes are here for when one project needs more than one posture at once.

A box is a named sandbox for the current project. By default every box mounts the same live /workspace (the same uncommitted files), but each gets its own capabilities, trust, writable layer and Claude session. A fork box is the exception: it mounts its own copy instead. The grammar is cleat <verb> [box]. The token after the verb is always a box name, never a path. A box name is lowercase letters, digits, - or _. It starts with a letter or a digit and stops at 31 characters. Anything else is refused rather than corrected, so a branch name like feature/JIRA-42 will not work.

This buys you least privilege within a single repo. Run a locked-down editing box beside a cloud-capable one over the same code. Nothing the project grants the second box reaches the first:

# A locked-down everyday box: identity only
$ cleat config dev --enable git
$ cleat start dev
 
# A separate box with the Docker socket, over the same files
$ cleat config az --enable docker
$ cleat start az --desc "cloud box: az login lives here"

--enable only ever adds. It writes the box's inherited set out first, so enabling something the box already had leaves it exactly where it was and hands it back to inheriting. A box that has to hold less than the project default needs --disable, or a [box.dev.caps] section listing what it may have.

What a box may reach on top of your global config is declared in the project's .cleat, as a section named after the box (below). A box can hold fewer capabilities than the project default, which is the whole point. Trust, env files (.cleat.<box>.env) and Claude sessions are per-box too: approving the az box never grants dev those caps, while cleat resume dev never crosses into az's conversation.

$ cleat shell dev
# bash into the dev box
$ cleat status
# list this project's boxes, caps, descriptions
$ cleat ps
# every box across every project
$ cleat describe az "prod only"
# update a description, never recreates the box
$ cleat rm scratch
# remove just the scratch box

A box is not an auth boundary for your Claude account. Your shared login (~/.claude) is mounted read-write into every box so you log in once. Sign in from any box that is not pinned to an account and every other unpinned box is signed in too. A box pinned with cleat account runs on its own named login. A sign-in there is saved to that account. Boxes isolate host-capability blast radius, the writable layer and per-project Claude state, not your login. cleat, cleat start and cleat start main all mean the same default container.

Per-box configuration#

One project, one config file. A box scopes any section by name, so the posture of every box in the repo reads top to bottom in a single diff:

.cleat
[caps]
git
ssh
 
[setup]
make bootstrap
 
# only memory is declared, cpus keeps inheriting
[box.heavy.resources]
memory = 8g
 
# declared, so it replaces: review gets git only
[box.review.caps]
git
 
# declared and empty: no caps from this file
[box.locked.caps]

The syntax is [box.<name>.<kind>], where <kind> is caps, resources, setup or fork. A declared section replaces the bare one. An absent section inherits it. Declared but empty is a real value rather than absence, which is what preserves least privilege inside the file: [box.locked.caps] with nothing under it takes nothing from the project, rather than inheriting [caps]. It bounds what the repo can grant, not what you granted yourself. Capabilities resolve as a union, so your global config caps plus any --cap flag still apply to every box. A box that has to be genuinely bare needs a global config with no [caps] too. caps, setup and fork are lists that replace wholesale. resources is key by key, so declaring memory for a box leaves its cpus inherited. [box.main.<kind>] is honoured like any other box. The editor is the one exception: cleat config main writes the bare [caps] until a [box.main.caps] already exists.

Two things stay outside those sections. Env vars keep their own sidecar file (.cleat.<box>.env, falling back to .cleat.env), so an az box's credentials are never visible to a dev box. And per-box sections are project-only: box names mean nothing outside a project, so a [box.*] section in your global config is ignored with a warning. Resource values from a repo file are still clamped (8 GB memory, the core count your Docker daemon reports for cpus), per-box sections included, so a .cleat asking for 16g still runs at 8g.

Trust follows the section. The capability hash is per box, so editing one box re-prompts that box alone and the prompt names the subject it is approving. cleat config <box> edits those sections for you. Enabling a cap on a box that is still inheriting writes the inherited set out first, so cleat config review --enable gh against a project [caps] of git ssh leaves that box on git ssh gh. Edit a box back to exactly what it would inherit and the section is removed, so it inherits again. Empty it and the bare header stays, because dropping it would hand a locked-down box the project's full set the next time the project gains a cap. cleat config <box> --list marks every value declared or inherited and names the box it is showing.

On an older Cleat, a per-box reduction fails open. A CLI older than v1.4.0 cannot see [box.*] sections at all. It falls back to [caps], so a teammate on an older version runs your locked-down box with the permissive project set. The first time a per-box section appears, Cleat writes a two-line comment into the file saying that only newer versions read those sections and that older ones apply [caps] to every box. It names no version number, because the release writing it is the last one that could not read such a section. If a lockdown has to hold across a mixed-version team, put the restrictive set in [caps] and widen the boxes that need more.

Forked workspaces #

new in v1.4.0

A box normally shares your live project directory. --fork gives it its own copy, so an agent can work without touching your tree.

$ cleat start feat-a --fork

Run it a few times and you have several agents on one project, each in its own container working on its own files. They still share what every box shares: your Claude login and the host ~/.claude/plugins.

It is a copy rather than a git clone, so submodules, untracked sibling repos, uncommitted work and node_modules all come along. A project with no git at all works the same way. Symlinks are copied as symlinks and never followed, so a project holding sub/keys -> ~/.ssh does not put real key bytes in the cage. On macOS the copy is copy-on-write, so it is close to instant and costs almost no disk until something changes.

While the copy runs, Cleat pauses any running box that can write the same project so nothing shifts underneath it, then resumes them, naming what it paused in one dim line. On an engine that cannot pause a box the fork is refused rather than risk a half-copied tree.

The launch summary names the copy and how old it is, so a stale fork is never silent:

  Container:  cleat-myproj-2f96c884-feat-a

  Project:    ~/code/myproj (not mounted, this box works on a copy)

  Fork:       ~/.config/cleat/forks/cleat-myproj-2f96c884-feat-a → /workspace (copied 3h ago)

A box's workspace is fixed when the container is created, so the flag only does something at create time. Passing --fork to a box that already exists as a plain box is refused rather than quietly ignored, with cleat rm as the remedy. Forking a fork, or putting the fork root inside the project so the copy would contain itself, is refused too. cleat rm frees the container and keeps the copy, because it may hold the only version of the work. Because it is kept, starting the box again with --fork reuses that copy rather than taking a fresh one, so a change to [fork] exclude does not apply until you refresh it.

The copies outlive their boxes on purpose, so they get their own verb. Note that fork is a verb here while --fork stays a flag on start and run. cleat fork start feat-a and cleat start feat-a --fork are the same command, because fork is the noun people reach for. Both start and run are offered because the difference matters: run creates the box then returns you to your shell while start creates it and launches Claude in it.

cleat fork

  Fork workspaces in ~/.config/cleat/forks

 

    cleat-demo-ab8ed4e5-feat-a    412 MB  3h ago    box exists

    cleat-demo-ab8ed4e5-feat-b     12 MB  2d ago    no box

 

    2 copies, 424 MB apparent.

cleat fork path feat-a prints one copy's path and nothing else, so cd "$(cleat fork path feat-a)" works. cleat fork rm deletes one copy. cleat fork prune deletes copies whose container is gone, plus any stale fork marker. cleat fork refresh replaces a copy with a fresh one from the live project. Size is apparent, not reclaimable: du is not copy-on-write aware, so a fresh copy reports its full size while sharing nearly every block with the project. rm and refresh refuse while the box exists, because its container has the copy mounted at /workspace. Both confirm, defaulting to no. Both say plainly that uncommitted agent work in the copy will be lost.

Exclude what you do not want with [fork] exclude = node_modules in .cleat. One path per line, repeating the key as often as you need. A nested path like web/dist works. There is no list syntax, so exclude = node_modules dist is read as a single directory name and matches nothing. An exclude that is an absolute path, contains .., names the workspace root, or resolves outside the copy through a symlink is refused with a warning instead of being deleted. [fork] dir moves the fork root if your projects live on another volume, since copy-on-write only works within one. It is read from your global config only and must be an absolute path, or a ~/ path Cleat expands to one: a project .cleat cannot move it, because that file arrives with a cloned repo and the value is a path Cleat creates and deletes under.

A fork is a snapshot. Landing it is yours. The copy is taken once, so a fork from an hour ago does not have work you did in the live tree since. Without copy-on-write (Linux without reflink support, or a fork root on another volume) it is real duplicated disk. cleat storage will not show it either: that command measures the Docker store, while the copies live on your filesystem. Cleat copies out. It does not merge back.

Kits #

new in v1.2.0

A kit is a curated Claude Code pre-configuration (a CLAUDE.md policy plus custom subagents) enabled for one box with one command: the setup power users hand-build and the first feature that makes a Cleat box a better place to run Claude, not just a safer one. The flagship, plan-big-execute-small, adapts the coordinator pattern from Anthropic's cookbook (big models for planning, small models for execution): your session's model (pick Fable 5.1 once with /model) plans and reviews every result while worker and scout subagents (Sonnet 5 by default) carry the mechanical bulk at the worker model's rate, each in its own context window, so the session stays lean and heavy work burns your rate limit far slower. It merges over your own config, never touches your host and cleat kit show prints every line before you enable it. To verify it is routing, check /usage after a heavy session: the bulk of tokens should sit on the worker model.

$ cleat kit
# interactive picker: kit, then models
$ cleat kit plan-big-execute-small
# or enable directly, for the default box
$ cleat kit plan-big-execute-small dev
# or for the dev box only
$ cleat kit show plan-big-execute-small
# read every line it injects, first
$ cleat kit off
# back to your own config next session
~/.config/cleat/config
[kits]
worker_model = haiku
scout_model = inherit

The picker writes those two keys into your global config. Hand-editing them is the same thing. Its ring cycles sonnet (the default), haiku, opus then inherit, which runs that subagent on your session's own model. A value left at the default is omitted on save, so an untouched setup carries no [kits] section at all. The planner has no knob. It is your session's model by definition.

A kit merges on top of your own setup inside the box. Your global CLAUDE.md content comes first, byte for byte, then a short marked Cleat box notes section every box carries (the clipboard-bridge rules: copying to your host clipboard works, reading it back from the shell does not), with the kit's section clearly marked below it. Your own agents sit beside the kit's (a same-named agent of yours wins). Your commands and skills still read exactly as they do on your host, through a read-only copy. Settings are the exception. The box gets a generated per-box copy of your ~/.claude/settings.json: with the hooks capability off the whole hooks block is deleted, with it on every hook command is swapped for a line that appends the event to a log your host bridge reads. That event queue is capped at 64 MiB. A box that overruns it has its queue dropped (nothing in an overflowing queue would ever run) and the session end reports how many bytes went. Everything else in the file passes through. With unsafe-rm on, that copy also carries the one PermissionRequest hook the cap installs. Without jq on your host that copy falls back to an empty . And it is per box: main can stay vanilla while a refactor box runs the planner kit on the same repo.

Your host is never written. The kit exists only as read-only files masked over ~/.claude/CLAUDE.md, ~/.claude/agents, ~/.claude/commands, ~/.claude/skills and ~/.claude/plugins inside the container. Native claude on your machine never sees it and the caged agent cannot rewrite its own instructions. Other projects are invisible too: a box gets a generated ~/.claude/projects holding only its own sessions, so it cannot read another repo's transcripts, while its own session stays writable so --continue keeps working. A read-only mask would not be enough there: measured, :ro blocks writes while leaving a sibling project's transcript perfectly readable, so these are generated replacements and the host content is simply never mounted. The same treatment covers file-history, paste-cache, uploads, backups, shell-snapshots, session-env, daemon, seed-admin, sessions, tasks, jobs and hooks. hooks is the sharpest of them, because the hooks capability runs your hook commands on the host and the usual way to write one names a script under ~/.claude/hooks/, so a box able to write there could rewrite what your host runs. session-env, daemon and seed-admin are private for the same reason: your host's own Claude Code runs what sits in them. What a box can still reach is the project you mounted plus your Claude login, which it needs in order to authenticate. Fifteen more paths at the root of ~/.claude are masked read-only as well, the ones Claude Code's own sandbox denies: workflows, routines, rules, output-styles, themes, cowork_plugins, project-settings, local-settings, scheduled_tasks.json, launch.json, loop.md, keybindings.json, settings.local.json and daemon.json, plus local, the npm install your host claude alias can run from. Those are the paths a later session, or your own Claude Code outside a box, would read as configuration or as hooks. In a box, rules, output-styles, themes, workflows, keybindings.json and loop.md are read-only copies of yours, refreshed on create, start and resume. The rest are empty. One path cannot be masked. Claude Code loads ~/.claude/.config.json in place of your global config whenever that file exists. It starts every MCP server the file lists. A box can write it and no mount can close it. Cleat never creates it and warns at every start while it is there, so read it before you run claude on your host. The directory itself stays read-write, because token refresh creates files there and replaces your credential by rename, which a read-only mount turns into a login that breaks silently hours later. So treat anything else you keep there as reachable. Capabilities you turn on mount their own host paths on top. Kits contain instructions and subagents only. No hooks, no settings, nothing that executes as a program and cleat kit show prints every line before you enable one.

Kit changes take effect at the next session (Claude reads its config at session start) and enabling, switching, or disabling one never recreates the box. One exception: a box created before kits existed lacks the mount points, so the first enable there offers a one-time rebuild. Inside any box created on v1.4.0 or later, kitted or not, your user-level ~/.claude memory, agents, slash commands, skills and plugins are read-only views (a hardening side effect: the caged agent still reads them but can no longer rewrite any of the five instruction surfaces your host claude obeys). A box created between v1.2.0 and v1.4.0 got the first three only, so its ~/.claude/skills and ~/.claude/plugins stay writable until you recreate it. Every session start on such a box says so. Author agents, commands and skills you make in a box at project level (.claude/agents/, .claude/commands/, .claude/skills/) instead. Plugins are the one exception to "read it, just don't write it": installing a new one from inside a box fails, so install plugins on the host and every box sees them.

Sessions #

new in v1.5.0

Claude Code keeps every conversation on disk. /resume lists them. The terminal CLI can rename one but it cannot delete one. Its purge takes a whole project at once. claude rm removes a background job and keeps the transcript. An automatic sweep goes by age, thirty days by default, with no off switch. So the finished conversation you will never open again sits there until it is a month old, while the one you keep resuming never ages out at all.

Cleat already creates and owns the directory those conversations live in, the same per-project isolation that makes cleat resume show only this project's history. So it can work on them one at a time.

That isolation has a consequence worth knowing. Claude Code's own purge resolves a project by its path. A Cleat box's conversations are not filed under a path it can derive. Its own --dry-run confirms it: pointed at a Cleat project, the purge plan lists the directory native claude made and not the one Cleat made. Wiping every project at once still reaches them. Nothing narrower does.

$ cleat session
# list this project's conversations
$ cleat session rm 1f204d6c
# move one to the trash
$ cleat session trash
# see what is in the trash
$ cleat session restore 1f204d6c
# change your mind, within 30 days
$ cleat session rename 1f204d6c --title "site redesign"

  Claude sessions (my-app)

  /Users/you/.claude/projects/my-app-a1b2c3d4

  ▸ 20h ago     1007 MB 1f204d6c  site-redesign

    3d ago        91 MB aa9375b7  egress-audit

    4d ago       248 MB 68975fe4  cli-work

    27d ago       11 MB 49d8c600  growth-fable

    4 sessions    → trash (2)

  ↑/↓ move  ⏎ rename or delete  q close

The list fills the window. It shows as many conversations as the terminal has room for, or as many as you have, whichever is fewer. Resize the window and press any key and it reflows. It will not reflow while it sits idle, because the key reader has no timeout and giving it one would make an untouched terminal act as though you had pressed Enter. On a pane too narrow to lay a row out, Cleat prints the plain list rather than a picker whose rows wrap.

An action does not close the list. Rename one conversation and the list comes back with the new name on the row you were on. Delete one and it comes back with that row gone and the cursor on whatever took its place, which is what makes clearing out a long history a few keystrokes rather than a few runs. The receipt for each action stays above the list, so what you did is still on screen.

The trash is the second view of the same list. Press → to open it and ← to come back. It shows what you deleted, how long ago and how much it is holding. ⏎ puts one back. The live list only advertises the trash once there is something in it. Delete the last conversation in a project and Cleat shows you the trash instead of dropping you at a prompt, because that is the one place the thing you just deleted can be got back from. cleat session trash prints the same list as plain text.

The size column is the reason the list is worth having. A conversation is not one file. It is the transcript plus a directory holding its subagent transcripts, its workflow state and its tool results. That second part is usually several times larger than the transcript itself. The sizes above add both. So does a delete. They are apparent sizes, the same caveat cleat fork carries, because du is not clone-aware on APFS.

Identify a conversation by its id, or by enough of the front of it to be unambiguous, at least eight characters. Row numbers are not accepted: the list is ordered by recency, so the conversation a number points at changes.

Listing needs no Docker and works with the daemon down, which is usually the moment you want it. Renaming and deleting do need it, because they have to ask whether the box is running.

Delete is a trash, not an unlink. The conversation moves to a host-only trash under ~/.config/cleat/session-trash, outside every folder a box can reach. On one volume that is a rename, so it costs nothing to move even at a gigabyte. Cleat prints the command that brings it back. The trash is swept after thirty days, which is also when the disk space actually returns. Deleting refuses while the box has a live Claude session. It also refuses when Docker cannot say whether the box is running at all, because a transcript removed while Claude is still writing to it is lost with no error. With no terminal it shows you what would go and deletes nothing unless you pass --yes. That flag skips the question, never a refusal.

Only a real conversation is ever touched. That directory also holds this project's Claude memory. A box writes into it while it runs. Every subcommand works off an exact session id and nothing else: no wildcards, no "everything except", no way to name the directory itself.

Renaming is a convenience here, not a missing feature. Claude Code renames conversations perfectly well on its own, with /rename inside a session or Ctrl+R in its own /resume picker. Cleat offers it because you are already looking at the list. Titles here are up to 64 plain characters with no quotes or backslashes. For anything outside that, accents and emoji included, use /rename inside the session.

Accounts #

new in v1.5.0

A Claude Max account has a five-hour window. If you hold two of them, the only way to move between them is /login, in a browser, in both directions, every time one runs out. cleat account gives each login a name and pins a box to one of them.

$ cleat account work2
# pin this box to a login called work2
# start the box and run /login once. That login is remembered under the name
 
$ cleat account
# picker: switch, rename or remove, → for the trash
$ cleat account work1
# back to the first one, no browser

Only the login moves. Conversations, project history and settings are identical on both accounts, by construction rather than by copying: a switch relocates Claude Code's credential store for that box and nothing else. So you can hit a limit in the middle of a conversation, switch, then carry on in the same conversation.

  Claude accounts (box main)

  /Users/you/.config/cleat/accounts

     default       shared     your own ~/.claude login

  ▸• work1         ok         you@example.com

     work2         ok         you+two@example.com

    → trash (1)

    max · 5h 16% until 01:20 · 7d 40%

    last used 2h ago

  ↑/↓ move  ⏎ switch or edit  q close

The pin is per box, because the limit is per account and you probably have several boxes open. One box can move to the fresh account while the others keep draining the first. A box created before this existed has no credential mount. Cleat records the pin, since the pin has to outlive the cleat rm that fixes it. It says so. Until you recreate it, cleat status and the launch summary both report the pin as not in effect rather than naming an account the box is not using. Starting that box refuses the override independently, so it is never pointed at a store it does not have.

Claude Code refreshes its own token roughly every eight hours, inside the box. Every attach stages the stored login in and every detach takes the refreshed one back out, newest wins. cleat rm, every recreate and cleat nuke do that before they touch a run directory, so a refresh is never the thing that sends you back to a browser.

The list will not invent a number. Usage is live while that account's own access token is still alive. It is a timestamped snapshot when it is not, which is usually the case for the account you parked. Once a reset time has passed the row says the window reset rather than showing a stale percentage. Cleat never refreshes a parked login to draw a bar, because the refresh can rotate the token and revoke a credential something else still holds.

Logins stay out of the cage. They live in ~/.config/cleat/accounts, not in ~/.claude, which every box mounts read-write. Only the pinned account's credential is staged into that box. No other stored account ever crosses in. Your shared ~/.claude login is still mounted there, as it is in every box, so a pinned box can read that one too.

One thing a mid-conversation switch costs. Claude Code ties a conversation to the account that started it. Resuming one on the other account keeps the local transcript and --resume exactly as they were. What stops is that conversation's server-side history being backfilled from the switch point. Cleat says so every time you switch.

The first row is always your shared ~/.claude login, so the picker is never a one-way door. cleat account default is the same thing from the command line. Usage figures need jq and curl on the host. So does saving a new login into an account, because Cleat checks with Anthropic whose login it is before it stores it. Without jq, cleat account list says the figures are not shown.

Removing a login is a trash, not a delete: cleat account restore brings it back for thirty days. Removing refuses while a box on that account has a live Claude session, because a running Claude only takes a login change in full when it starts. Exit that session first. A switch is different. On a running box Cleat hands the live session over: it restarts on the new account and reopens the same conversation, after showing what the restart costs and asking first. It never interrupts a turn on its own. A session in the middle of a turn or waiting for an answer is refused until it finishes. Add --now to restart it anyway, which loses the reply so far. Background shell commands refuse the switch too, unless they belong to a mid-turn session you restart with --now. A session started by hand in cleat shell, by cleat login, by an older Cleat or without a terminal is never restarted for you. The first reply on the new account can reread the whole conversation without a prompt cache, which may use a large share of that account's usage. Cleat then says what the reopened terminal needs, usually that you type continue there. The handover has been driven end to end on Claude Code 2.1.270, 2.1.274 and 2.1.280. A box on another version is told so before you answer. A box that runs the amd64 image under Rosetta on Apple Silicon is judged exactly like a native one.

The trash is the second view of the same picker. → crosses to it and ← comes back, the same two keys the session list uses. Each row still says whose login it was and when it went, because the metadata travels with the store. ⏎ puts it back in one step. Remove your last account and the picker opens the trash rather than dropping you at a prompt, since that is the only place it can be recovered from. cleat account trash prints the same list without a terminal.

  Claude accounts (box main)

  /Users/you/.config/cleat/accounts

  ▸  old-side      removed    side@example.com

    Trash: 1 account, kept 30 days

    max · Example Ltd

    removed 27h ago, kept 30 days

  ↑/↓ move  ⏎ restore  ← back  q close

Clipboard #

new in v1.4.0

Copy a screenshot on your host, click into a box's Claude prompt and press ctrl+v. About a second later the prompt shows [Image #1], exactly as it would outside Docker. Pasting a picture into a caged agent just works.

# screenshot copied on your host, then ctrl+v in the box

what's wrong in this trace? [Image #1]

It is ctrl+v, not cmd+v. That is fixed. Claude Code inside the box reports platform linux, so it binds image paste to ctrl+v. On a Mac, cmd+v is the terminal's own text paste and never reaches the image handler. The feature is on by default. It needs no config and no recreate: every existing box picks it up on its next session.

Images only, enforced on the host. A box with a session attached can ask your host for the clipboard image at any time, so this is a real, bounded grant. The gate that keeps it to pictures lives outside the cage where the agent cannot reach it: the host reader names only image formats (PNG, JPEG, GIF, WebP), checks the bytes and caps the size, so clipboard text (where passwords and tokens live) never crosses into the box. Turn the whole thing off with CLEAT_NO_CLIPBOARD_IMAGE=1, which removes the shim and restores the pre-v1.4.0 behavior.

Copying out of a box#

Text goes the other way with the commands you already reach for. Inside a box, clip, pbcopy, xclip and xsel all write to your host clipboard while a session is attached. Reading back is not supported, so xclip -o and xsel -o find nothing. A copy landed if the command exited 0, so there is nothing to check afterwards.

Provisioning #

new in v1.3.0

Every box starts from the same base image. Anything the agent installs by hand survives stop and start, then disappears the first time the box is recreated. A [setup] section in your project's .cleat is the durable form: real shell commands that Cleat runs once inside a fresh box, before the session starts, so a recreated box comes back with your toolchain already on it.

.cleat
[caps]
git
 
[setup]
sudo apt-get update
sudo apt-get install -y build-essential postgresql-client
npm ci

A line is either a shell command, run verbatim, or script <path>, which inlines a project-relative file at that position. Mix them freely, in any order: they become one payload that runs top to bottom. A script target has to be a real file inside the project, so absolute paths, .. segments, symlinks, a file over 1 MiB and anything resolving outside the project root are all refused before a single command runs. Keeping the steps in a file means your teammates and CI run the identical bytes you approved.

What actually runs#

The payload is staged into the box and executed as coder (passwordless sudo, full network access) with /workspace as the working directory, under bash -e, with stdin closed. Three consequences worth planning for. The first failing command stops the rest. Nothing can sit waiting for input. An export reaches later lines of the same payload but nothing afterwards. Output streams live, so you watch it install.

  ▸ Project .cleat [setup] wants to run 3 command(s) in the box as coder (full network access)

      sudo apt-get update

      sudo apt-get install -y build-essential postgresql-client

      npm ci

    Run this project's setup commands? [y/N] y

✔ Container started

  ▸ Running project setup (3 command(s), [setup] in .cleat)

✔ Setup applied (3 command(s))

✔ Claude launched

Approving it#

A .cleat lives in the repo, so it is untrusted input. Running arbitrary shell as a sudo-capable user is a materially bigger grant than mounting a file. [setup] gets its own consent class because of that. Approving a project's capabilities at the start-time prompt never trusts its [setup]. The reverse holds too. Only an explicit cleat trust records both. CLEAT_TRUST_PROJECT deliberately does not cover it: only CLEAT_TRUST_SETUP or --trust-setup does.

The first time a project declares [setup], or after any edit to it, you get a default-no prompt showing the payload before anything runs, truncated to its first 15 lines. Run cleat setup --show for the whole thing. Approval covers every inlined script file's contents, so any change to a command re-prompts. Comments, blank lines and indentation inside [setup] are normalised away before hashing, so those edits do not. Cleat rebuilds and re-hashes the payload immediately before executing it, which means an agent that rewrites /workspace/.cleat mid-session cannot ride your earlier approval. Declining never blocks the box: it starts without provisioning and tells you how to approve later.

$ cleat trust
# approve this project's caps and [setup]
$ cleat setup --show
# source, count, hash, trust and marker state, then the payload
$ cleat setup
# run it now, or retry it after a failure (box must be running)
$ CLEAT_TRUST_SETUP=1 cleat
# approve + record non-interactively (CI)

If the commands themselves fail, the box still opens. You get an amber warning naming the exit code, nothing is marked applied and the next start tries again from the top, so write the steps to be safe to re-run.

Editing [setup] never recreates the box. Run-once is tracked by a marker file inside the container holding the payload's hash, so a recreate loses the marker and re-provisions automatically while an edit reapplies in place. What forces a recreate is a changed capability, a changed environment key or an explicitly configured [resources] limit. Accepting an image refresh rebuilds the box too.

Three traps worth knowing. The base image ships no C toolchain and no apt package lists, so start with sudo apt-get update and install build-essential if you need to compile. The agent's session runs with ~/.local/bin first on PATH and never sources ~/.bashrc, so install into ~/.local/bin or symlink into it rather than editing a shell rc file. And .cleat is INI, so any line shaped like [...] starts a new section even inside [setup]. Write test -f x, not [ -f x ]. Leading whitespace is stripped from every line. The payload does not get the session's PATH either, so a tool you just linked into ~/.local/bin has to be called by full path, or export PATH before you use it.

[setup] is project-only and box-aware: a [box.<name>.setup] section replaces the plain [setup] for that box rather than merging with it, with trust recorded per box. Starters for .NET, Python and Rust live in examples/, one showing inline commands, one a single script file and one two scripts plus a trailing inline command.

Security model #

The boundary is the container itself. The agent writes your project at /workspace, the container's throwaway layer and the Claude state Cleat shares on purpose: your ~/.claude login plus this box's own session transcript. The instruction surfaces inside ~/.claude (memory, agents, commands, skills, plugins, rules, hooks and the rest Claude Code reads as configuration) are masked read-only or kept private to each box. One file cannot be masked. Cleat warns at every start while ~/.claude/.config.json exists (see Kits). Nothing else on your machine is mounted unless you grant it.

Protects against

  • Host filesystem damage: the only writable host paths are your repo, Cleat's own state and the shared ~/.claude login directory. The rest of your home is never mounted.
  • Credential theft: SSH keys and gitconfig are absent by default, read-only when shared.
  • Malicious repo config: a project's .cleat cannot silently grant capabilities (see trust, below).
  • Cross-project bleed: sessions and per-project .claude.json are isolated, so trust and MCP approvals never leak between repos.

Deliberately allows

  • Inside the box, the agent has full power: install, build, delete, run anything.
  • Claude runs with permissions auto-approved (--dangerously-skip-permissions), safe because the container, not a prompt, is the boundary.
  • ssh reaches every host your keys can. A real grant: opt in knowingly.
  • docker is root-equivalent on the host. It explicitly breaks the sandbox.
  • Image paste pulls a picture off your host clipboard on ctrl+v. Images only, checked and capped on the host.
  • Shared by design, not a boundary: your Anthropic auth and host Claude settings.
  • Full outbound network. There is no egress allowlist today and no capability gates it, so whatever the agent reads in /workspace it can also send. Naming the hosts a box may reach is the next thing being built.
  • No published ports. Cleat passes no -p, so a dev server the agent starts inside a box is not reachable from your host browser. The one path that ever bound a host port is the OAuth callback forward. It now runs only for an allowlisted origin and only when that port is free.
  • A box can ask your host to open a URL. That is how a login inside a box reaches your browser. The destination is checked outside the cage.

Browser origins#

A box can ask your host to open a URL. That is how a login inside a box reaches your browser. Cleat opens it only when the origin is on this list, because otherwise the box chooses where your logged-in browser goes. Every origin here is on by default and there is nothing to configure. By default a listed origin is opened for you only when the URL is a login request, the OAuth authorize page a tool sends you to. A device-flow page or a plain link is never opened for you. You click the link the tool prints. A login at an origin not on the list is refused and printed for you to open by hand when the session, cleat shell or cleat login ends. The line Cleat prints includes the one command that adds it for next time.

$ cleat browser origins
# what your sandbox is allowed to open
$ cleat browser allow auth.example.com
# add one, for every box
claude.com
claude /login with a Claude account
platform.claude.com
claude /login with a Console account, plus the code-paste flow
claude.ai
claude /login in older Claude Code, plus links such as claude.ai/code
console.anthropic.com
claude /login, the code-paste flow · older Claude Code only
github.com
gh auth login · device flow, click the printed link
www.npmjs.com
npm login, the web flow
login.docker.com
docker login · device flow, click the printed link
accounts.google.com
gcloud auth login, firebase login, Gemini CLI
app.netlify.com
netlify login
app.planetscale.com
pscale auth login · device flow, click the printed link
app.pulumi.com
pulumi login · paste an access token at the prompt. The browser flow cannot finish in a box
app.terraform.io
terraform login · a token-creation page
auth.openai.com
codex login · fixed callback on :1455
cli-auth.heroku.com
heroku login
dash.cloudflare.com
wrangler login · fixed callback on :8976
gitlab.com
glab auth login · web or device flow
login.microsoftonline.com
az login · dynamic loopback port
microsoft.com
az login --use-device-code, plus the Microsoft device pattern · device flow, click the printed link. The widest entry here
sentry.io
sentry-cli login · a token-creation page

Two things this list does not do, both worth knowing.

An origin on the list can still redirect your browser somewhere else, because following a redirect is what browsers do. The list bounds the first hop, not the last one. It is a smaller boundary than "the box cannot reach the web through your browser". It is also the honest one.

Some logins can never be on a shipped list. Cleat cannot know those in advance, so you add them once with cleat browser allow or CLEAT_BROWSER_ORIGINS and they work from then on. The same goes for a login Cleat has simply not catalogued yet. There are some. Not every tool documents which page it opens. A few ask their own server for the address at login time, so it can move without the tool changing.

AWS SSO
Not shipped. aws sso login opens oidc.<region>.amazonaws.com, one host per AWS region, so allow the region you use.cleat browser allow oidc.eu-west-1.amazonaws.com
Atlassian Cloud
Your site is your own. Which host acli auth login --web opens is not measured yet. If Cleat refuses that login, the notice names the host to allow.cleat browser allow <the host the notice names>
MCP servers
Each server names its own authorization host, one per server, discovered at connect time.cleat browser allow auth.mcp.example.com
Self-hosted anything
GitHub Enterprise, a self-managed GitLab, Terraform Enterprise, a private npm registry, self-hosted Sentry.cleat browser allow github.example.com

For one shell rather than every box, CLEAT_BROWSER_ORIGINS takes the same hosts.

$ CLEAT_BROWSER_ORIGINS="oidc.eu-west-1.amazonaws.com,github.example.com" cleat

Missing something worth shipping? If a tool you use opens a login page that is not on this list, the origin is worth adding for everyone. Cleat prints the exact origin when it refuses one, so paste that line into an issue and it can ship as a default.

Open an issue on cleatdev/cleat

CLEAT_BROWSER_BRIDGE=always opens every URL the box picks, at any origin. It is the documented full bypass for the tab and it lives in your shell rather than in the box. It does not finish every login. The callback that brings a hands-free login back into the box still needs the origin on the list, so that kind of login at an unlisted origin opens its tab and then waits. Fix that one with cleat browser allow.

Whatever the mode, a box can make your host open at most 6 URLs a minute and 30 a session. Past that the URL is not opened. When the session ends Cleat says how many it held back and prints the first few, so you can open one by hand.

Workspace trust#

A .cleat file lives in the repo, so it is untrusted input. Cleat never applies its capabilities silently. The first time a project requests them, you get a prompt that defaults to no. In non-interactive contexts (CI), project caps are dropped unless you opt in. Your global config and --cap flags are always trusted, because you wrote them.

$ cleat trust
# approve this project's .cleat caps and [setup]
$ cleat trust web
# approve the "web" box ([box.web.caps]), separately
$ cleat trust --list
# review approvals (green ● ok, amber ● = .cleat or its env file changed)
$ CLEAT_TRUST_PROJECT=1 cleat
# approve + record non-interactively (CI)
$ CLEAT_TRUST_SETUP=1 cleat
# separately approve + record [setup] provisioning (CI)

Trust is per (project, box). A named box hashes the capabilities it actually resolves to, its own [box.<name>.caps] when it declares one, so editing one box re-prompts only that box and cleat trust <box> approves exactly that box, recording the same row its start-time prompt would. The first prompt names what it is approving, down to .cleat [box.web.caps], because with one file per project the box name is the only thing telling two consent decisions apart. A lone box name is always that box. A token containing a slash is a path and selects the main box, so cleat trust and cleat untrust stay symmetric.

Two kinds of input never reach the prompt as a grant. A capability name Cleat does not recognise (a typo, a removed cap, a line like docker,git) is dropped with a warning and counts toward nothing. unsafe-rm in a project .cleat is ignored the same way, since that guard is global-config-only. What the prompt does now list, beside the caps, is any bare host variable a .cleat.env asks for (see the recipe). The names are part of the one approval, so adding or removing one re-prompts. cleat trust records and prints exactly what it grants. A project whose .cleat holds only env-file names is trustable on its own. A .cleat.env that is a symlink is refused and never read.

A project's .cleat can also declare a [setup] section: real shell commands that run in the box as coder before the session starts. That is a materially bigger grant than a mount, so it is approved separately, byte-exactly, with a default-no prompt that shows the payload first. Approving caps at the start-time prompt never trusts [setup]. CLEAT_TRUST_PROJECT deliberately does not cover it either. The explicit cleat trust verb is the one place both are approved together. It names each grant in its output. The full story is in Provisioning.

Resource limits & honesty#

Each box runs with --init (a real PID 1 that reaps orphaned processes, so long sessions can't wedge on zombie buildup and cleat stop is instant), a memory ceiling sized to your Docker VM (a quarter of VM memory, clamped to 4–8 GB or a conservative 2 GB when the VM's size cannot be read, with swap off so a runaway process OOMs inside its own box instead of freezing every session) and --pids-limit 4096 (a fork-bomb guard) on Docker's default bridge network. Your host is reachable at host.docker.internal. Files the agent creates are owned by you, via UID/GID remapping. Need more headroom for a heavy build, or want a box pinned to fewer cores? Set it in the cleat config editor (the Resources group, arrow keys to cycle) or with a flag, no INI required. Every session already runs with NODE_OPTIONS=--max-old-space-size pinned to about 60% of the box's ceiling, so node's heap tracks the real budget rather than the host's RAM. Your own NODE_OPTIONS lands later on the command line and wins, so that is a default rather than a straitjacket.

$ cleat config --memory 4g
# global default for every box
$ cleat config --cpus 2
$ cleat config --project --memory 8g
# just this project's .cleat
$ cleat config --memory default
# clear it, back to the derived ceiling
$ cleat config --memory 24g
# the whole VM: allowed, with a warning that says why

The editor's choices come from your actual machine. The cpus ring is built from the core count Docker reports, so it never offers more cores than you have and never stops at 8 on a 24-core box. The memory ring climbs in real stops to the size of your Docker VM, so a 24 GB VM offers the full 24 GB. Someone who runs one box at a time is entitled to it.

Nothing above 8 GB is blocked, only annotated. The note gets blunter as the number climbs: first a reminder that a ceiling is not a reservation, then a warning once you pass half the VM, then an amber warning at the whole VM with the full reason on save. A box that really grows into the entire VM leaves nothing for the daemon or the VM's own kernel, so the Linux OOM killer inside the VM starts firing. That can take out a working agent mid-write or wedge Docker until you restart it. You are told, then you choose.

Both write the same [resources] section, so hand-editing stays a valid fallback and the reference format. Repo-supplied values are capped (8g memory, the core count your Docker daemon reports for cpus) so an untrusted .cleat can't overcommit your machine. The project ring stops at 8g for the same reason, rather than offer a ceiling the box would silently not get:

# ~/.config/cleat/config, or <project>/.cleat
[resources]
memory = 4g
# cpus is optional. Decimals are fine. Unset means all cores.
cpus = 2
 
# one box only, in <project>/.cleat. cpus keeps inheriting the 2 above
[box.heavy.resources]
memory = 8g

If a session is ever killed by that memory ceiling, Cleat says so (and what to do) instead of leaving an unexplained crash. The usual culprit is a test runner: jest and vitest start one worker per CPU and a box sees every host core, so a small box can spawn a dozen Node workers and blow its ceiling. Cap the workers (jest --maxWorkers=2), give the box more memory, or set cpus so the runner self-limits.

Images and cleanup#

Prebuilt images are multi-arch (Intel and Apple Silicon both run natively, never under emulation). cleat prune clears cleat's own stale images (superseded builds and pulls, never your boxes or other projects). Cleat watches for this itself: when stale images pile up it offers the prune on start.

When Cleat stops other boxes#

Closing a terminal ends the session but leaves the box running, still reserving its memory ceiling, so on every interactive start Cleat first stops other idle boxes that are safe to stop (detached, no agent running, idle past a grace window) and tells you what it freed. A box working unattended (terminal left open) is never touched. If running sessions still reserve more memory than the Docker VM has, Cleat names how many are running and your real RAM and the VM, before the freeze does.

When Cleat holds the launch#

A box and a worktree are the same thing (one session, one memory ceiling), so Cleat sizes the VM with comfortable headroom for parallel work (a ≈16 GB default, capped at half your RAM) and, when yours is smaller, tells you the exact value to set, the click-path and your machine's safe max, naming the Docker Desktop knobs the CLI can't touch (memory, swap, VirtioFS) and keeps reminding you until you fix it. And because a warning that scrolls past protects no one, an undersized VM (or default swap) actually holds the launch: Cleat stops on a prominent banner, shows the fix and waits for you to press Enter, so you can't miss it. That hold is one of two places Cleat blocks the start (a nearly-full Docker disk is the other, below). It fires only on a real interactive terminal, so cron, pipes and CI sail straight through. WSL2 is skipped too: its VM hands memory back to Windows, so an undersized reading there is usually a false alarm. CLEAT_NO_DOCKER_GATE=1 skips it for a machine that can't spare the RAM.

A ceiling is not a reservation#

The sizing advice above is a default, not a cap on sessions: a ceiling is a limit, not a reservation, so you can run many sessions at once and the VM only swaps if a lot of them go genuinely heavy on memory at the same time (rare and no different from Claude Code outside Docker). When the VM is already as large as your RAM can safely back, it says so and steers you to running fewer sessions instead of asking for memory that isn't there. It checks swap too: memory and swap are separate Docker Desktop sliders, so if you bump memory (often on Cleat's advice) but leave swap at the default, Cleat flags just the swap, so a brief spike pages out instead of freezing a session. And when your VM is sized right, Cleat says so on every start with a one-line nod (✔ Docker tuned for Cleat) right above Image ready, so a good config gets confirmed, not just a bad one flagged. The wording is engine-aware: on a native Linux engine there is no VM and no slider, so the nod reads ✔ Docker ready for Cleat (16 GB RAM, ...) instead. A small host (under 8 GB) states its size without the parallel-headroom claim.

Disk is the other pressure Cleat watches. On Docker Desktop every box shares one VM disk, so a box that reads 100% full is really the whole store filling up, not that one box. cleat storage shows the breakdown: your images and boxes, the shared build cache, plus everything else labeled other projects, with a fill bar and the reclaim levers. When the store crosses about 85% full with little free space, Cleat drops a one-line advisory. When it crosses 95% with under 10 GB free, it holds the launch the same way the memory gate does (CLEAT_NO_DISK_GATE=1 skips it). Each check pairs the fill percentage with a free-space floor, so a small disk trips when it is genuinely tight while a 1.8 TB store at 95% with 90 GB still free never does. cleat prune --cache is the lever for the build cache. That cache is shared by every Docker project on the machine, so the flag has to be typed and the confirm defaults to No. The fix guidance is written for your engine: the Docker Desktop slider, OrbStack auto-reclaim, Colima resize, a native-Linux prune or a WSL compaction.

To be candid: these are plain Docker containers, sharing your host kernel, not microVMs with a separate kernel. Cleat trades that stronger boundary for a true one-command experience, session persistence and the clipboard bridge. Cleat also drops no Linux capabilities and sets no seccomp or no-new-privileges profile beyond Docker's own defaults, so the boundary is the image and the mount set rather than a hardened profile. For most local agent work, that is the right trade. For genuinely hostile code, a separate VM is the stronger boundary.

Recipes #

Copy-paste starting points for the things people actually do.

Explore an untrusted repo#

Let the agent loose in code you don't trust. A malicious .cleat can't grant itself the Docker socket. You'll be asked first and the default is no.

$ git clone https://github.com/some/repo && cd repo
$ cleat

Commit as you, without push access#

git is identity. ssh is reach. Enable only the first and commits carry your name while the agent still can't push anywhere.

$ cleat config --enable git

Pass secrets without committing them#

Enabling env scaffolds a .cleat.env in your current directory (gitignore it). Even a global enable drops the file where you run it. Then declare keys. A bare key like GH_TOKEN takes the value from your shell. KEY=value sets it explicitly in the file with no host value.

Because a bare key reaches into your shell, it is untrusted repo input. The first launch that sees one lists those names in the trust prompt (Project .cleat.env asks for host variables: GH_TOKEN ...) and defaults to no. Approve once with y, cleat trust or --trust-project and the values flow from then on. Until you do, the KEY=value lines still pass and Cleat names the host variables it is holding back. Adding or removing a name re-prompts. A .cleat.env that is a symlink is refused and never read.

Inline comments are not supported. A # after a bare key makes the line invalid so the variable is dropped in silence. After KEY=value it becomes part of the value. Put every comment on its own line.

$ cleat config --enable env
.cleat.env
# .cleat.env: project environment variables for Cleat
# A bare key inherits from your shell. KEY=value sets it explicitly.
GH_TOKEN
DATABASE_URL=postgres://localhost:5432/app

Run Docker from inside the sandbox#

For Docker-first apps. Containers you launch run as siblings on the host daemon. Use $(pwd) or . for mounts. The daemon sees host paths. Remember: this breaks isolation. The bare enable writes your global config, so every box on the machine gets the socket. Scope it to one repo with cleat config --project --enable docker, then approve the .cleat on the next run.

$ cleat config --enable docker
$ cleat
# existing box? accept the recreate prompt
# then, inside the container:
$ docker compose up -d
$ docker compose exec app npm test

Two postures, one repo#

A least-privilege editing box beside a cloud box, over the same files. The dev box cannot reach the socket the az box holds. Both edits land in one file you can read and commit.

$ cleat config dev --enable git
$ cleat config az --enable docker
$ cleat start dev
# locked down
$ cleat start az
# cloud-capable, separate session
.cleat, after both commands
# Per-box config: [box.<name>.<kind>] sections, read by newer Cleat.
# Older versions ignore them and apply [caps] to every box.
[box.dev.caps]
git
[box.az.caps]
docker

Authenticate gh inside the container#

gh prints a device link with a one-time code. Click the link and it opens in your host browser. Cleat does not open device-flow or plain links for you. The token writes back to ~/.config/gh, so it survives rm, nuke and rebuild.

$ cleat config --enable gh
$ cleat
# then, inside the container:
$ gh auth login

Upgrade the bundled Claude Code#

Cleat owns Claude's version. Upgrade in place (fast, no full rebuild), then it offers to recreate this project's container.

$ cleat upgrade-claude
# latest
$ cleat upgrade-claude stable
$ cleat upgrade-claude 2.1.156
# install an exact version

An exact version lasts until the next rebuild, update or nuke, each of which resets the image to the release build.

Troubleshooting #

Every row below is a message Cleat prints, quoted from the source. Search the page for the line in your terminal. Cleat prints its own remedy under most of these, so this table is the second place to look, not the first.

Docker isn't running.
Autopilot starts Docker Desktop, OrbStack or Colima for you and waits. This message means it could not: a root-owned engine, a script, or CLEAT_NO_AUTOSTART=1. The exact start command is printed under it.
Docker did not come up in 90s. Open it manually, then retry.
Autopilot gave up waiting. A cold Docker Desktop boot on a slow disk can exceed the default. Raise it with CLEAT_AUTOSTART_TIMEOUT_SECS, or start Docker yourself first.
Docker is running, but you can't reach its socket
Linux only. Your user is not in the docker group. Run sudo usermod -aG docker $USER, then log out and back in. Cleat prints your username in the command.
Container failed to start.
Docker's own error is printed under it. Most often a stale mount or a resource limit the daemon refuses. cleat rm then cleat recreates the box from current config.
Container <name> is not running.
You reached for shell, logs or claude on a stopped box. cleat start brings it up, or cleat resume brings it up and continues the last session.
Out of memory. The box hit its memory ceiling
The kernel killed a process inside the box. Test runners are the usual cause: they start one worker per CPU and a box sees every host core. Raise memory under [resources], cap cpus so runners self-limit, or pass --maxWorkers.
Box <name> already exists and is not a fork.
A box's workspace is fixed when the container is created, so --fork cannot be added later. cleat rm <box>, then cleat start <box> --fork.
This box is a fork but its workspace copy is missing.
The copy under the fork root was deleted or moved. Cleat prints the path it expected. Recreate it with cleat start <box> --fork, or drop the marker with cleat fork rm <box>.
Invalid box name: <name>
Box names are lowercase letters, digits, '-' or '_', max 31 characters. A branch name with a slash is refused rather than corrected. Pass a directory instead to inspect another project.
Project .cleat ignored, not trusted
A warning, not an error. The box started with your global config only. Approve with cleat trust, or CLEAT_TRUST_PROJECT=1 for CI. [setup] needs its own approval.
Ignoring unknown capability in the project .cleat: <names>
A cap name Cleat does not know (a typo, a removed cap, or a line like docker,git) is dropped. It grants nothing, never reaches the trust prompt and never counts toward trust. Fix the name in .cleat.
Not passing the host variables <file> asks for: <names> (the project is not trusted for them)
A bare KEY in .cleat.env pulls a value from your shell, so it needs approval. KEY=VALUE lines still pass. Approve the names with cleat trust or --trust-project.
<file> is a symlink and was not read (the box can point it at any file you can read)
A project .cleat.env that is a symlink is refused, because the box could aim it at any file. Make it a regular file, or load it with --env-file instead.
Refusing to edit <path>: it is a link, a non-regular file or over 256 KB.
cleat config builds the new .cleat outside your project and renames it in, so it will not write through a symlink, a directory or an oversized file. Edit the file a shared link points at.
Project .claude/<name> is a link or unreadable, so Cleat did not read it.
A project settings file that is a link, sits inside a linked .claude, or cannot be read gets no overlay. The box sees the raw entry. Cleat reads project settings only from a regular file inside the project.
Project .claude/<name> is larger than 1 MB, so Cleat did not read it.
A project settings file over 1 MB gets no overlay and no mount. Trim it to a regular file under the cap and start again.
cleat is already installed elsewhere on this machine.
Two installs mean PATH order decides which one runs. cleat status lists every install it found. cleat install --force replaces a conflicting symlink, though never a real file or a Homebrew keg.

Still stuck? cleat status prints this project's boxes, image, auth and capabilities. cleat logs follows the container's own output. Anything neither answers is worth an issue on GitHub.

Notes & gotchas #

Docker and the engine#

  • The docker cap binds the socket where your engine actually runs. Since v1.4.3 the bind is engine-aware. A VM-backed engine (Docker Desktop, OrbStack, Colima, Rancher Desktop, Lima) gets the in-VM /var/run/docker.sock. A host-local daemon gets its real socket, rootless included. A loopback tcp:// DOCKER_HOST counts as local. A socket that is not there is never bound, so a stopped daemon can no longer leave a phantom directory on the host. A remote daemon has no socket to bind, so the box gets your DOCKER_HOST instead. Expect rough edges: a box mounts your project by host path, which the remote daemon cannot resolve. A TLS remote gets a plain warning that the client certificates stay on the host.
  • Docker down? Cleat starts it. Missing? Cleat offers to install it. A session verb that finds the daemon unreachable launches your own Docker (Docker Desktop, OrbStack, or Colima on macOS, Docker Desktop or a rootless engine on Linux, the Windows-side Desktop from WSL2), waits with a bounded spinner, then continues your command. A root-owned Linux engine gives you the exact one-line command instead. A remote tcp:// endpoint is named so you start it where it runs. No docker CLI at all? An interactive, consent-first install offer (macOS picks Desktop / OrbStack / Colima via Homebrew's official packages, Linux runs Docker's official script under sudo only after you say yes, WSL2 uses winget), with the exact command always shown and No as the default. Interactive terminals only, so scripts and CI never pop a GUI or an installer. CLEAT_NO_AUTOSTART=1 turns all of it off.
  • Root-only hosts work. On a stock VPS image (or under sudo) the box user maps to uid 0. Claude Code itself refuses --dangerously-skip-permissions as root, which used to end every session with "Claude exited with code 1". Cleat sets IS_SANDBOX=1 (Claude Code's own escape hatch for sandboxed containers) on each session whenever the box will run as uid 0, so sessions launch normally and existing boxes heal on their next attach.
  • Cleat measures which uid your engine maps you to (v1.5.1). Your files come back owned by you because the box runs as your uid. A user-namespaced engine breaks that: rootless Docker maps you to container uid 0 and your subordinate uids to container 1, 2, 3. Docker Desktop for Linux remaps the same way without calling itself rootless. Your own number then picks a subuid that owns nothing, which left the agent unable to edit files already in your project and took your real ~/.claude with it. On a Linux host Cleat now measures the mapping once per engine. It mounts a directory you own into a throwaway container, reads back the uid that container sees and caches it in ~/.config/cleat/state/uidmap. On rootful Linux and WSL2 the measurement is your own uid and nothing changes. macOS never measures: every macOS engine runs the daemon in a VM whose file sharing already presents your files as yours, so Docker Desktop, OrbStack and Colima on a Mac behave exactly as before. A box created before v1.5.1 on a namespaced engine keeps the old number frozen at create, so it now says so at session start and names the fix: cleat rm, then start it again.
  • The VM size Cleat shows matches your slider. docker info reports the guest kernel's memory, which sits under the Docker Desktop slider by a reserve that grows with VM size (a 24 GB VM reads ~23.4 GiB), so Cleat reads the slider value straight from the Docker Desktop settings (the same way it reads swap), falling back to rounding the kernel's memory when that can't be read. The display and the undersized check share that one value everywhere (the advisory, the tuned nod, cleat status), so a correctly sized VM is never falsely flagged as too small and the number you see is the slider you set.
  • Use host.docker.internal, not localhost, to reach a service on your host (an MCP server, a local proxy). Inside the container, localhost is the container. On every engine except Docker Desktop, which provides it natively, Cleat wires up host.docker.internal for you automatically.
  • With the docker cap on long sessions, a Docker Desktop restart can renumber the socket group. Cleat re-heals access on every attach. If the socket itself was replaced, it tells you to run cleat stop && cleat resume rather than killing your live session.

Recreates and what survives them#

  • Enabling a cap recreates the container. Mounts are fixed at create time, so the next cleat after a config change prompts to recreate. Your conversation survives it. The transcript lives on the host, so cleat resume reopens it. A plain cleat starts a fresh one.
  • Only a real config change prompts a recreate. Resizing your Docker VM or upgrading the CLI never does, the one-time v1.5.4 move below aside. The recreate prompt fires only when a capability, an environment key, or a configured [resources] limit actually differs from how the box was created, never because a value Cleat derives from the VM moved underneath you.
  • One silent recreate on the first v1.5.4 start. A box created before v1.5.4 is recreated once, with no prompt and no opt-out, the next time cleat starts or resumes it, printing Recreating container (host paths changed). It moves that box's arrow-up input history to a host-only folder the box cannot reach. A running box is left alone until it stops. Conversations, logins and trust survive. [setup] re-runs. Anything installed by hand outside [setup] is lost. The one box this cannot reach on its own is one you first start from the Docker Desktop or OrbStack dashboard, or with a plain docker start, before any cleat verb touches it: run cleat on it once.
  • A box from before the current ~/.claude masks says so. Bind mounts are baked at create, so a box made by an older Cleat keeps some ~/.claude paths writable until recreated. Every box created before v1.5.0 prints this after the upgrade. Every start notes it with the command for that box. Move anything installed inside the box into [setup] first.
  • Ad-hoc installs survive stop and reboot. Per-container state lives under ~/.config/cleat/run, not /tmp, so macOS file rotation can't trigger a surprise recreate that wipes what you installed inside the box.
  • An ad-hoc install still dies on recreate. sudo apt-get install survives stop and start just fine, but config drift, an image refresh or a stale-mount auto-recreate all start the box from the base image, wiping it. A [setup] section is the durable path: approve it once and it re-runs automatically after every recreate.
  • Gaining cleat account recreates nothing. Upgrading to a build that has it touches no image and no container. A box picks up its credential mount the next time it is recreated for its own reasons. Pin a box that predates the feature and Cleat prints the one-off cleat rm it needs, with what that costs: conversations, trust and env live on the host so they come back. Anything you installed inside the box does not, so move it into [setup] first.
  • An ssh box survives a reboot. macOS regenerates your SSH agent socket path on every restart, so the path baked into the box goes stale. Cleat spots the vanished mount before starting and recreates the box transparently. No cryptic mount … not a directory error, no lost session.

Boxes, trust and config#

  • Per-box .cleat.<box> files are no longer read. Per-box config moved into the project's own .cleat as [box.<name>.<kind>] sections, so a week of boxes stops leaving a pile of committed dotfiles in your repo root. A leftover file changes nothing silently: it warns whenever that box starts, naming the sections to move it to. Delete it once you have. Env is the exception that stays a file, as .cleat.<box>.env.
  • Trust and MCP approvals are per box. Each box keeps its own isolated .claude.json, which fixed the old corruption crash, but approvals no longer carry across repos or between two boxes on the same repo. Approve once per box. Your login is unaffected.
  • Cloud CLI caps were removed. az, aws and gcloud are gone. Install the CLI on your host and pass credentials through the env cap instead. Old aliases like --cap aws now fail with "Unknown capability" rather than silently doing nothing.
  • A symlinked project .cleat is never edited through the link. cleat config builds the new file outside your project and renames it into place, so it refuses a project .cleat that is a symlink, a directory or over 256 KB with Refusing to edit. If you point a link at a shared .cleat, edit the file the link resolves to. Reads for display still follow the link, but a write never republishes it.

Install and updates#

  • Switching install method loses nothing. Config, trust, boxes, fork workspaces and Claude history all live outside the install itself (~/.config/cleat, ~/.claude, your project's own .cleat). Container names are derived from the project path rather than from where Cleat is installed. A box you made under one install is the same box under the other. Remove the one you have, add the other, carry on.
  • One cleat per machine. Cleat's own installers, the curl script and cleat install, refuse to create a second one. Homebrew installs its keg without that check, so a brew install can sit next to a script install, which cleat status flags when it finds more than one. Two cleats means a bare cleat resolves to whichever comes first on your PATH. The loser stays invisible until it bites: a stale version that "fixes itself" after a shell restart, an update that appears to do nothing. Re-installing over the path you already have is fine, that is just an upgrade. A different path is refused, though --force replaces it. A Homebrew keg is never replaced even with --force, because that would leave brew tracking an install it no longer owns. The scan looks at every PATH entry plus the usual install directories even when they are off PATH, since a Homebrew prefix is invisible to a shell that never ran brew shellenv. cleat status names them all when it finds more than one. Every interactive start also warns about the split, silenced with CLEAT_NO_INSTALL_CHECK=1.
  • A Homebrew install is brew's to manage. If cleat is running from a Homebrew keg, cleat update runs brew upgrade cleatdev/tap/cleat for you and cleat uninstall offers brew uninstall, asking first because that removes the whole keg where the command normally just drops a symlink. cleat install refuses outright, since brew already linked it. The on-start upgrade offer works the same as on a script install: same throttle, same "don't ask me again", same session continuing afterwards, just applied with brew upgrade instead of a checkout. Otherwise cleat update would fall through to "re-install with curl", which symlinks over /usr/local/bin/cleat. That path is Homebrew's own bin on an Intel Mac, so you would end up on a forked copy while brew still tracked an orphaned keg. Cleat decides by where the resolved binary physically lives, never by the directory you invoked it through. Detection never shells out to brew, so it still holds where brew is off your PATH. There the command is printed for you to run.

Terminal and clipboard#

  • A box never replays an old copy onto your clipboard. A copy made inside a box lands on your host clipboard while a session is attached, exactly then. Anything left over from an earlier session is discarded at the next start instead of overwriting what you currently have (before v1.2.5 it could).
  • Prompts treat redirected stdin as a decline. Every [Y/n] read from /dev/null or a wrapper means no, never a silent yes. Update checks are throttled and never downgrade.

Reference #

Commands#

Daily loop

cleat
Start the sandbox and launch Claude for the default box. The default command.
cleat start [box]
Build, start and launch Claude, the all-in-one. Same as bare cleat, for a named box. Add --fork to give it its own copy of the project.
cleat run [box]
Bring the container up without attaching Claude, for scripting, CI, or pre-warming a box.
cleat resume [box]
Reopen the latest conversation in this box by its id. One already open in another terminal is skipped, so two Claude processes never write one transcript. A conversation that ran on a 1M context window reopens on one unless your settings or env name a model. Alias: r.
cleat stop [box]
Stop the container and keep it for resume.
cleat rm [box]
Remove the container. Host sessions are preserved. Resume recreates it. A fork box keeps its workspace copy, which may hold the only version of the work.
cleat fork start [box]
Create a fork box and launch Claude in it. The same command as cleat start [box] --fork.
cleat fork run [box]
Create a fork box without launching Claude.
cleat fork [sub]
Manage workspace copies. Bare (or list, for scripts) shows them with size and age. Then path, rm, prune and refresh.
cleat shell [box]
Open a bash shell in the container. Alias: sh.
cleat claude [box]
Launch Claude in a running container. Fresh session, recreates on config drift.
cleat login [box]
Run claude auth login in the box with the browser and OAuth callback bridge. A box pinned with cleat account saves the login to that account. Any other box saves it to your shared ~/.claude.

Inspect

cleat ps
List every Cleat container across all projects.
cleat status [box|dir]
Show this project's container, image, auth, capabilities and boxes, plus a warning when more than one cleat is installed on the machine. It also flags an image running under emulation and a memory overcommit when running boxes reserve more ceiling than the VM has. A box name narrows it to that box. A directory inspects another project. The Auth line names the account a box is pinned to. Alias: st.
cleat logs [box]
Follow container logs (docker logs -f).
cleat describe [box] [text]
Show or set a box's one-line description. Never recreates the container.
cleat session [box]
List this box's Claude conversations, newest first, with the disk each one really costs. A picker on a terminal, plain text when piped. Aliases: sessions, ses.
cleat session rename <id> [box]
Give a conversation a name. Add --title <text> to skip the prompt.
cleat session rm <id> [box]
Move a conversation and its subagent data to the trash. Add --yes to skip the prompt.
cleat session trash [box]
List what has been deleted and not yet swept, with the id each one goes back by.
cleat session restore <id> [box]
Bring a trashed conversation back. The trash is swept after 30 days.
cleat account
Picker for the named Claude logins this machine holds, marking the one this box uses. Acts on the project default box. Alias: accounts.
cleat account <name> [box]
Pin a box to a named login. Creates a clean one if the name is new.
cleat account <name> [box] --yes
Switch a box whose Claude session is live without the question. The session restarts on the new login and reopens its conversation. Anything typed there but not sent is lost.
cleat account <name> [box] --now
Restart the live session even mid-turn or while it waits for an answer. Its reply so far and any running tool are lost.
cleat account default [box]
Unpin a box, so it goes back to your shared ~/.claude login.
cleat account list [box]
Plain list, marking the pinned one.
cleat account rename <old> <new>
Rename a stored login. Every box pinned to it follows.
cleat account rm <name>
Move a stored login to the trash. Add --yes to skip the prompt. Names are the box charset: lowercase letters, digits, dash and underscore, up to 31 characters. list, rename, rm, delete, restore, trash, held, adopt, off, help and default are reserved.
cleat account restore <name>
Bring a removed login back. The trash is swept after 30 days.
cleat account trash
List removed logins. The picker reaches the same list with the right arrow.
cleat account held
List logins Cleat kept aside instead of deleting, such as a login made as a different Anthropic account than the one it was saved under. Swept after 30 days.
cleat account adopt <id> <name>
Save a held login as an account. A new name creates that account.
cleat browser origins
Print the origins a box may ask your host to open.
cleat browser allow <origin>
Add one origin to that list, for every box.

Capabilities & trust

cleat config [box]
Open the .cleat editor (capabilities + resources), or use --enable / --disable / --memory / --cpus / --list. A box name edits that box's [box.<name>.*] sections inside the same .cleat, never a separate file. The global-scope editor can also generate a project .cleat for you.
cleat trust [path] [box]
Approve a project's (or a box's) .cleat capabilities and [setup]. A lone box name trusts that box. --list to review approvals.
cleat untrust [path] [box]
Remove a project's (or a box's) trust approval.
cleat setup [box]
Run this project's [setup] provisioning now, or retry it after a failure.
cleat setup [box] --show
Preview the setup payload plus its trust and marker state. Runs nothing.

Kits

cleat kit
Interactive picker, like cleat config: pick a kit (a detail pane shows what it does), then set each agent model with the arrow keys, enter to enable.
cleat kit list
Plain kit library and this project's per-box selections.
cleat kit <name> [box]
Enable a curated Claude pre-configuration for a box. Merges on top of your own config. Takes effect next session (one exception: a pre-kits box is offered a one-time rebuild).
cleat kit off [box]
Disable the box's kit. Back to your own config next session.
cleat kit show <name>
Print every line a kit injects, before you enable it.

Image & version

cleat build
Pull or build the image if it is missing.
cleat rebuild
Rebuild the image from scratch (--no-cache).
cleat upgrade-claude [channel|version]
Upgrade the bundled Claude Code in place. Default channel: latest.
cleat update
Update the CLI to the latest tag and pull the matching image. On a Homebrew keg it runs brew upgrade instead.
cleat version
Print the version. Aliases: -v, --version.

Setup

cleat install [--force]
Symlink the cleat script onto your PATH (/usr/local/bin), for manual or cloned installs. Refused when another cleat is already installed anywhere on the machine. --force replaces a conflicting symlink. A Homebrew keg or a real file at another bin path is never replaced.
cleat help
Print the built-in command reference. Aliases: -h, --help. trust, untrust, describe, fork, kit, account, browser and setup each take --help for their own grammar.
cleat uninstall
Remove the cleat symlink from your PATH. On a Homebrew keg it offers brew uninstall instead, asking first. Docker state is left alone (see nuke for that).

Teardown

cleat stop-all
Stop and remove every Cleat container.
cleat storage
Read-only breakdown of Docker disk use: your images and boxes vs the shared build cache vs other projects, with a fill bar and reclaim levers.
cleat prune
Remove stale cleat images: superseded builds and pulls. Boxes and other projects' images are never touched.
cleat prune --cache
Also clear the shared Docker build cache (regenerable, used by all projects). A typed flag with a default-No confirm. Add --yes for scripts.
cleat clean
Remove all Cleat containers and the image.
cleat nuke
Remove all Cleat containers and images, plus the shared build cache. Asks you to type 'nuke' to confirm.

Flags#

--cap <name>
Enable a capability for this run only: git, ssh, env, gh, hooks, docker, unsafe-rm.
--env KEY=VALUE
Set an env var in the container. Works even without the env cap.
--env KEY
Pass a host env var through by name.
--env-file PATH
Load env vars from a file. Works even without the env cap, exactly like --env.
--project
With config: write to ./.cleat instead of the global config.
--enable / --disable <cap>
With config: toggle a capability non-interactively.
--memory <val>
With config: set the box memory ceiling (e.g. 4g). default clears it. A ceiling that reaches your whole Docker VM is allowed and prints the full warning. A project .cleat is written as typed but capped at 8g at run time.
--cpus <val>
With config: set the box CPU limit (e.g. 2). all clears it. Warns when the value exceeds the cores Docker reports.
--list
With config: print enabled capabilities and resource limits. With a box name, each value is marked declared or inherited.
--fork
Give the box its own copy of the project instead of the live tree. A box's workspace is fixed when the container is made, so passing it to a box that already exists as a plain box is refused rather than ignored.
--trust-project
Approve this project's .cleat for this run and record it.
--trust-setup
Approve this project's [setup] provisioning for this run and record it.
--desc "<text>"
Set a box's description when it is created.

Configuration files#

~/.config/cleat/config
Global capabilities, [resources] limits, [kits] agent-model overrides, [fork] dir and the [browser] origins cleat browser allow adds. Trusted, because you wrote it.
~/.config/cleat/env
Global env vars (loaded when the env cap is on).
<project>/.cleat
Project capabilities, [resources] limits, [setup], [fork] exclude and every per-box section. cleat config can generate it for you. Capabilities require a trust approval before they apply.
<project>/.cleat.env
Project env vars. A bare KEY inherits from your shell only after you approve it at the trust prompt, which lists the names it asks for. KEY=VALUE always passes. A symlink here is refused and never read.
[box.<name>.<kind>]
Per-box config, as sections of that one .cleat. <kind> is caps, resources, setup or fork. A declared section replaces the bare one. An absent section inherits it. Project-only.
<project>/.cleat.<box>.env
Per-box env vars for a named box, the one per-box setting that is still its own file. Falls back to .cleat.env, which is also the only env file the default box reads. Bare KEY lines need the same trust approval and a symlink here is refused too.
~/.config/cleat/forks/
Fork workspace copies, one per fork box. The default root, moved by [fork] dir.
~/.config/cleat/trust
Recorded trust approvals (mode 0600).
~/.config/cleat/state/hook-drops.log
Hook events that failed validation and hook spool discards. Neither is run on the host.
~/.claude/projects/<key>/
This project and box's Claude conversations, one .jsonl per conversation plus a directory of its subagent transcripts and tool results. On the host, so they survive cleat rm. <key> is the project's directory name and a hash of its path, plus the box name for a named box.
~/.config/cleat/accounts/<name>/
A named Claude login: the credential (mode 0600) and a small metadata file. Deliberately not under ~/.claude, which every box mounts read-write. Removed logins wait 30 days in .trash/ beside them. Held logins wait 30 days in .held/.
~/.config/cleat/session-trash/<key>/
Deleted conversations wait 30 days here before the sweep, outside every folder a box can reach. Restore one with cleat session restore. <key> matches the projects key above.
~/.config/cleat/box-accounts/<container>
Which named login a box is pinned to (mode 0600). The first line is the login and the second is the per-project key of the box, which is how cleat account rm finds the .claude.json of a box in another project. Absent means the shared ~/.claude login.

Environment variables#

CLEAT_TRUST_PROJECT=1
Approve this project's .cleat capabilities non-interactively (for CI / scripts).
CLEAT_TRUST_SETUP=1
Approve this project's [setup] provisioning non-interactively (for CI / scripts). CLEAT_TRUST_PROJECT does not cover it.
CLEAT_CLAUDE_CHANNEL
Claude Code update channel: stable, latest (default), or a pinned version. Applies to the on-start update check. cleat upgrade-claude takes its channel from its argument.
CLEAT_NO_UPDATE_CHECK=1
Disable the CLI self-update check (no network call to GitHub on start).
CLEAT_NO_CLAUDE_UPDATE_CHECK=1
Disable the Claude Code update check (no network call on start).
CLEAT_BROWSER_BRIDGE
Browser-bridge open policy: auto (default: open an OAuth authorize URL at an allowlisted origin, defer everything else to the terminal), always (open any URL the box picks, within the rate cap, at any origin, which turns the destination check off for the tab. The login callback proxy still needs an allowlisted origin, so a hands-free login at an unlisted origin needs cleat browser allow), or off (never auto-open, though the login callback proxy still runs for an allowlisted origin). always and off are named on a Browser: line in every launch summary.
CLEAT_BROWSER_ORIGINS
Extra origins the browser bridge may open, comma or space separated. APPENDS to the shipped list, never replaces it, so adding one cannot break your Claude login. Host-side only: the box cannot read or set it. Example: CLEAT_BROWSER_ORIGINS="auth.acme.example,oidc.eu-west-1.amazonaws.com".
CLEAT_NO_CLIPBOARD_IMAGE=1
Turn off native image paste. The box's ctrl+v stops pulling the host clipboard image and behaves as it did before v1.4.0, where a paste inside a box finds no image. Copying out of the box is unaffected.
CLEAT_NO_IDLE_SWEEP=1
Disable the on-start idle-session sweep (which stops detached boxes that no longer have an agent running, to free Docker VM memory).
CLEAT_NO_DOCKER_GATE=1
Skip the hold on start when the Docker VM is undersized or swap is left at the default (the advisory still prints). The hold needs both stdin and stdout on a terminal, so cron, pipes and CI never reach it. Only Docker Desktop arms it. WSL2 is skipped.
CLEAT_NO_DISK_GATE=1
Skip the hold on start when the shared Docker disk is nearly full (the banner still prints). Interactive-only. Separate from CLEAT_NO_DOCKER_GATE.
CLEAT_IDLE_GRACE_MINS
Minutes a box must be detached and idle before the sweep may stop it (default 30).
CLEAT_NO_AUTOSTART=1
Disable Docker autopilot (session verbs normally auto-start a down Docker Desktop / OrbStack / Colima and wait, but with this set, cleat just prints the exact start (or install) command and exits).
CLEAT_NO_INSTALL_CHECK=1
Silence the on-start warning when more than one cleat install is found on the machine. cleat status still shows the full install list on demand.
CLEAT_AUTOSTART_TIMEOUT_SECS
How long autopilot waits for the started daemon before giving up (default 90, sized for a Docker Desktop cold boot).

All ~/.config/cleat paths honor $XDG_CONFIG_HOME if it is set.

Full release history is on the changelog. The source lives on GitHub.

Get started

Drop into any project and type cleat.

curl -fsSL https://cleat.sh/install | bash

MIT licensed macOS, Linux, WSL2 Read the script first