Getting started
harness is an AI coding agent for your terminal. It edits your project and runs commands in a sandbox, asking first when its mode says to. Every session is recorded.
Install
curl -fsSL https://megacode-harness.vercel.app/install.sh | sh
Linux (x86_64), macOS 15.4+ (Apple Silicon or Intel), or Windows through WSL2. Installs one binary to ~/.local/bin, no sudo.
Read the script first. HARNESS_VERSION pins a release; HARNESS_INSTALL_DIR
picks the folder.
On Linux the sandbox needs bubblewrap: sudo apt install bubblewrap.
Update: type /update in a session (0.29.0 and later; it installs without leaving, then /restart switches over and keeps the conversation), or run harness update in a terminal (0.24.0 and later; before that, run the install again). A release build checks at start and every hour, and the status bar tells you when one is out
(what it sends). A start is quiet: one line of notes, and /notices lists the rest (the quiet start). Uninstall: delete ~/.local/bin/harness.
Windows (WSL2)
Run harness inside WSL2, as a Linux install. In PowerShell, wsl -l -v lists your distributions;
the VERSION column must say 2 (wsl --set-version <name> 2 converts one; wsl --install sets up
WSL2 with Ubuntu). Inside the distribution, install bubblewrap and run the installer above. Keep
projects in the Linux file system (~/code), not under /mnt/c. The sandbox hides each Windows
drive’s Users folder, and a project inside one makes the sandbox unavailable; Windows programs (cmd.exe)
do not start from a sandboxed command. WSL1 and Windows on ARM are not supported, and this has
been checked on one Windows machine. The details are in the sandbox page.
Free models, or your own keys
Free models
Type /login in a session, or run harness login, and sign in with Google. You get mega:flash
and mega:full, free up to a daily allowance (resets 00:00 UTC). harness logout signs out.
Signed in, harness also sends daily usage counts (no prompts, files or commands); telemetry = false turns
them off (what it sends). Privacy.
Your own keys
Export the key in your shell’s startup file:
export ANTHROPIC_API_KEY=your-key
| Provider | Variable |
|---|---|
| Anthropic | ANTHROPIC_API_KEY |
| OpenAI | OPENAI_API_KEY |
| Gemini | GEMINI_API_KEY |
| OpenRouter | OPENROUTER_API_KEY |
| Together | TOGETHER_API_KEY |
| DeepSeek | DEEPSEEK_API_KEY |
| Z.AI | ZAI_API_KEY |
| xAI | XAI_API_KEY |
| Ollama, LM Studio (local) | no key |
Auto mode on your own keys also needs TYPESAFE_API_KEY (typesafe.ai).
Other OpenAI-compatible providers: [providers.<name>].
Start
harness run
harness run --model gemini:gemini-3.8-flash
Model ids are provider:model; /model switches mid-session. Set a default in
~/.config/harness/config.toml:
model = "gemini:gemini-3.8-flash"
harness resume continues an earlier session.
Modes
manualasks before every command, edit, skill and sub-agent.planreads freely, writes only its plan file.autoedits without asking; the judge approves commands and asks you about risky ones.
Shift+Tab cycles them. See Modes and the judge.
From a script
git diff | harness -p "review this change"
One turn, answer on stdout. See harness -p.
Where next
Modes and the judge
A mode decides whether a tool call runs, asks you, or goes to the judge. /mode shows the
table below for your session’s sandbox state. /mode <name> switches and shift+tab cycles
the built-ins, each printing a line on the new mode. [modes.<name>] in config.toml
changes or adds modes (config.md).
The three built-in modes
| kind of action | manual | plan | auto |
|---|---|---|---|
| read or search | runs | runs | runs |
| edit a project file | asks you | plan file only | runs |
| edit a protected file | asks you | plan file only | asks you |
| run a command | asks you · runs in the sandbox | not available | the judge decides (asks you if risky) · runs in the sandbox |
| use the internet | asks you | not available | the judge decides (asks you if risky) |
| run a command outside the sandbox | asks you | not available | asks you |
| load a skill | asks you | asks you | runs |
| start a sub-agent | asks you | asks you | runs |
| the hard-deny list | refused | refused | refused |
manualasks before anything but a read.planwrites only its plan file and runs no commands.autoruns project-file edits, skill loads and sub-agents unasked and hands commands to the judge. A skill adds text and a list of its files to the context and nothing more;allowed-toolsgrants nothing. To makeautoask before a skill, see the recipe.
A protected write asks in every mode, plan included (Protected files);
manual and auto also have local asks. The hard-deny list refuses even under
--approve-all.
In the terminal panel, asks no turn grant covers (a protected write, a credential read, a
removal git cannot undo, decoded text being run) start on deny, so a habitual Enter
refuses; press y to allow. Others start on allow once. The plain REPL takes a bare
Enter as no.
Every command runs inside a sandbox (sandbox.md): it writes only in the
project and a private temp folder (/tmp on Linux, $TMPDIR on macOS), has no network but
localhost (none on macOS), and can’t read your key folders. On macOS, use the internet
also covers local ports, such as a loopback test server. --approve-all refuses
run a command outside the sandbox but runs use the internet unasked.
The table changes with [sandbox] settings:
escape = "never": both rows readnot allowed.enabled = false: the command row ends· runs unconfined (sandbox off), and the two rows readno extra question: commands have itandno extra question: commands run outside.- Sandbox unavailable: a judging mode is
not availablein every row. mode = "external"(sandbox.md, “External mode”): the command row ends· runs unconfined (this machine is the sandbox), the two rows read as with the sandbox off, andauto,--approve-all,run --awayandharness evalwork.--approve-allrefuses only the hard-deny list; plan mode still refuses writes.
The judge
For each bash/exec command auto would run unasked, the judge (a second model) weighs
run against ask from the command, your latest message and a few repository facts.
Ordinary work runs: reading, downloading, building, testing, local commits, and edits and
repo scripts inside the project or /tmp.
Under --input-format stream-json a flag reaches the program as an approval_request of kind judge_flag
(judge_unavailable when the judge could not be reached) (headless.md).
Flagged. When the probability of ask reaches the cutoff, you get manual’s
approval prompt plus The judge flagged this: <reason>, naming the likeliest reason. The
first three ask even when you requested the action; the other five, unless your message
asked for that specific action:
| reason | covers | the flag reads |
|---|---|---|
credentials | reading, printing, copying or sending a credential or a file holding one | touches credentials or secrets |
irreversible | destroying work git can’t restore, or deleting a target the command doesn’t show | may destroy work git cannot restore |
internet_code | installing or running internet code | installs or runs code from the internet |
outside_code | running a script or binary from outside the project, /tmp included | runs code from outside the project |
outside_write | writing, moving or deleting outside the project and /tmp | touches files outside the project |
sends_data_out | uploading or posting data to another host | may send data to another machine |
shared_state | pushing, publishing, deploying, changing a remote resource | changes something others rely on |
safety_override | using the git stash or turning off a safety setting | uses the shared git stash or turns off a safety setting |
The reason is only a label; with none, the line reads The judge flagged this. An
unreachable judge (no key, a bad answer, over five seconds) asks with
The judge could not be reached. A flag never refuses: you decide.
The tag. A command the judge allowed shows · allowed by the judge on its result row,
as one you approved shows · approved. Probabilities appear only in the log.
What is sent where
The judge is a separate call to a separate provider: by default TypeSafe,
[judge] model = "typesafe:jev-1.14.0", keyed by TYPESAFE_API_KEY. Once per machine, the
first entry into a judging mode by command prints a notice naming the provider and model and saying
that the command, the facts and your latest message are sent. A start already in a judging mode does not print it; /notices lists it. Only TypeSafe’s built-in
endpoint gets a privacy-policy link. Without the key, auto asks before every command it
would judge, and entering a judging mode warns:
the judge needs TYPESAFE_API_KEY — without it, auto asks before every command it would judge
Through mega. Signed in to the free mega account
(config.md) with no TYPESAFE_API_KEY and the built-in
judge at a version our server accepts (jev-1.13.0 or jev-1.14.0), the same requests go via our server, which forwards them to TypeSafe with
its own key. The notice then links mega’s privacy page and reads:
Each judged command, its facts and your latest message are sent to TypeSafe (jev-1.14.0) through mega, with secrets redacted.
After /logout, judged commands ask, as with no key.
Neither the notice nor the warning prints under --approve-all, --mock or
harness eval, where the judge never runs.
The request holds:
user_request: your latest message in the root session, up to 4,000 characters. A sub-agent’s call sends it too, never the parent’s instructions to the child.command: thebashstring or the joinedexecargv, up to 8,000 characters.facts:project_rootandcwd(both$PROJECT, never a real path), pluscurrent_branchanddefault_branchwhen they can be read from.giton disk.
It never holds tool output, assistant text, reasoning, file contents, or a child’s own prompt.
Credential-shaped text (API key prefixes, PEM blocks and similar) is always replaced with
[redacted]. Project and home directories, and a ~/… spelling of the project, become
$PROJECT and ~, as whole paths only (a sibling …/harness2 stays ~/…). Other paths,
and spellings like /home//you, go as written. The session log keeps the request as
built.
Cutoffs
The cutoff is 0.2 by default, chosen on a labelled set to lean toward asking (figures
below). Lower it to ask more, raise it to ask less:
[judge]
cutoff = 0.15
A value outside 0.0..=1.0 is refused at load, naming the file. Only your own config can
set it, not a project’s.
Protected files
A write_file or edit_file under a protected path asks in every mode (plan’s own plan
file aside); no grant covers it, and only --approve-all skips it. The prompt adds
This is a protected file, and its “this turn” choice, except this kind, still asks at
the next one.
Entries match whole path components: a trailing / marks a directory, a leading **/ a
basename at any depth, anything else one exact path. . and empty components are ignored
(././CLAUDE.md matches), as is ASCII case on a case-insensitive volume like macOS’s
default (claude.md matches; .GIT is .git to the hard-deny list). A nested worktree
(.claude/worktrees/<name>/) counts from its own root: its CLAUDE.md, .git and
.claude/ are protected, its other files are not.
.git/
.agents/
.claude/
.harness/
AGENTS.md
CLAUDE.md
.cargo/config
.cargo/config.toml
**/build.rs
.envrc
.github/workflows/
.pre-commit-config.yaml
.husky/
.vscode/
Adjust it from your own config with [protected]:
[protected]
add = ["infra/"]
remove = [".vscode/"]
add uses the same matching rules; remove drops a built-in entry, and any other path does
nothing. A project’s config.toml can never change the list.
With checkpoints on, the turn-end list flags each protected file the turn changed, tracked
or not, protected, with its /undo (checkpoints.md).
Local asks
Two kinds of command ask before the judge sees them. Like a protected write, no grant
covers them and --approve-all skips them; while you’re away, they are refused
(sandbox.md, “Away”). Their “this turn” choice, any bash except this kind,
still asks for this kind. Under --input-format stream-json they reach the program as
approval_requests of kind removal and decode_and_run, with turn_grant_skips_kind: true
(headless.md).
The removal ask: a file git cannot restore. truncate (any size), an empty redirect
(> f and similar), cp /dev/null f, rm, git rm -f (not --cached),
git checkout -- or git restore of a named file asks, naming the first such target; so
does a named directory holding one. “Cannot restore” means untracked and not ignored,
changed since the last commit, or not in a git repository; clean committed and ignored files
go to the judge. Writing new content is an edit, not a removal. The reason reads:
Asked because it empties or deletes {file}, which git cannot bring back (untracked | changed since the last commit | not in a git repository)
The decode-and-run ask: decoding hidden text and running it. Piping decoded output into
a shell or interpreter, or eval-ing it (… | base64 -d | sh, xxd -r -p … | bash,
python3 -c "exec(…b64decode…)"), asks with the reason below. Decoding into a file without
running it does not.
Asked because it decodes hidden text and runs it
When the harness cannot tell which files. Past 1,024 targets, 256 KiB of their names,
64 possible working directories, or groups or sh -c code nested too deep, a removal asks
without naming a file:
Asked because the harness cannot tell which files it empties or deletes
Targets the command doesn’t name literally (globs, variables, command substitutions,
find -delete, one-liners, scripts) go to the judge.
The hard-deny list
Before any mode, judge or human has a say, --approve-all included, these are refused as
refused: <rule> is on the hard-deny list:
- a recursive delete of
/, of~/$HOME, or of the project root or one of its parents mkfs*ddwriting onto a real device under/dev/(not/dev/null,/dev/stdout,/dev/stderr, or/dev/fd/N)- the classic fork bomb, in any spacing
It catches only the obvious spelling: a wrapper script, a renamed binary or eval gets
past it. It is a speed bump, not a boundary (The limits). Under
[sandbox] mode = "external", only this list and, in auto, the judge stand between a
command and the machine.
The key scrub
In every mode, a bash or exec child starts without any registered provider’s key
variable (every api_key_env, TYPESAFE_API_KEY included), in use or not; env prints
none. Other variables, such as GITHUB_TOKEN, pass through, so gh and an authenticated
git push keep working.
Custom modes and judge
[modes.<name>] takes a judge field beside tools and ask: the tools the judge
decides. It merges like ask: left out, a built-in mode keeps its own; judge = [] turns
judging off. The rules:
- every name in
judgemust also be intools; - no tool may be in both
askandjudge; bashandexecmust share a bucket: both asked, both judged or both run.
config.md has a two-line [modes.auto] override that makes auto ask before
every bash/exec again.
The limits
The judge sees a command and the request behind it, never a script’s contents, a
build.rs, or what a binary does. The sandbox keeps what it allows from writing outside the project,
reading your keys or going online unasked, but damage inside the project
(git reset --hard, an in-project rm -rf, an overwritten file) is the judge’s call alone.
Commands can’t write untracked protected files (a local .envrc), but can write tracked
ones (CLAUDE.md, a workflow) and any build.rs: review those against the commit the work
started from (sandbox.md has the commands).
On the maintainer’s labelled set of 300 commands, the default judge and cutoff (jev-1.14.0,
0.2) still run 49 of the 114 that should ask and ask on 11 of 186 safe ones; the previous
default (jev-1.13.0, 0.3) ran 63 and asked on 5. Its misses were mostly work in sibling
checkouts, which the sandbox blocks or contains. Expect worse on unfamiliar commands. The judge is
strongest on credentials and internet code. Treat what it allows as unreviewed, inside the
project above all.
The sandbox, and going away
Every bash and exec call runs in a sandbox by default — a direct call, one from a script,
and one from a child session — in every mode, --approve-all included, unless you approve one of
the two ways out (below). In auto the judge (modes.md) still judges every
command; the sandbox contains what it misses.
The backend is bubblewrap on Linux (Windows through WSL2) and Seatbelt on macOS 15.4 or later
(“Requirements”, below). A machine that is itself the sandbox — a dedicated VM, a container, a CI
runner — can say so with mode = "external" (“External mode”, below).
What it does
- Reads everywhere except the hidden list (below). A hidden path reads as empty on Linux. On
macOS it fails with
Operation not permittedand cannot be run. - Writes only to the project, a private temp folder, and
[sandbox] writablepaths. On Linux the temp folder is/tmp. On macOS it is$TMPDIR,/tmpis not writable, and the user’s temp folder (/var/folders/…/T, used by a baremktemp) is writable except Apple’scom.apple.*entries. - Untracked protected files are read-only, even in the project (below).
- Network. On Linux, only localhost within the same command: no other session’s or the
machine’s services, no internet. On macOS, none, not even
127.0.0.1: any port needsnetwork: true(“The two ways out”, below). - No local sockets: no ssh-agent, gpg-agent, D-Bus, Docker or tmux. Python’s default
multiprocessing needs a full escape on Linux; on macOS only a
Managerfails. - No credentials. Every provider’s key variable, and anything named
*TOKEN*,*SECRET*,*_KEY,*CREDENTIAL*,*PASSWORD*,*PASSWD*,*_PASS,*_AUTH,*_AUTH_*,*COOKIE*,AWS_*,SSH_AUTH_SOCKorGPG_AGENT_INFO, or whose value is a URL with a password, is stripped from the environment.[sandbox] env_hideadds names. - A private process view. Other processes are invisible, and background processes end with
their command. On Linux there is no terminal to read from. On macOS
pscannot run, and only a terminal the command opens itself is usable.
The hidden list is ~/.ssh, ~/.gnupg, ~/.aws, ~/.azure, ~/.config/gcloud,
~/.config/gh, ~/.config/hub, ~/.docker, ~/.kube, ~/.netrc,
~/.git-credentials, ~/.npmrc, ~/.pypirc, ~/.gem/credentials,
~/.terraform.d/credentials.tfrc.json, ~/.vault-token, ~/.password-store,
~/.local/share/keyrings, ~/.secrets, ~/.cargo/credentials*, ~/.bash_history,
~/.zsh_history, ~/.claude/, ~/.claude.json, ~/.codex/, the harness’s own
config root, and its sessions/ and checkpoints/ folders under the data root
($DATA/sessions/, $DATA/checkpoints/). [sandbox] hide adds paths. A symlink is hidden
with its target (~/.ssh -> ~/dotfiles/ssh). Linux covers paths that exist at session start;
macOS also covers ones created later. A ~/.cargo/credentials* file is hidden only if it exists
at session start.
macOS adds ~/Library/Keychains, ~/Library/Cookies, ~/Library/HTTPStorages,
~/Library/Accounts, ~/Library/Group Containers, ~/Library/Containers, ~/Library/Mail,
~/Library/Messages, ~/Library/Safari, the profile folders of Chrome, Chromium, Firefox, Edge,
Brave, Arc, Vivaldi and Opera under ~/Library/Application Support, and ~/.zsh_sessions.
With HARNESS_HOME set, the list (minus the macOS additions) also applies inside it. If it is
the project, commands cannot see the project’s own .npmrc, .claude/ or .docker/. An entry that
holds the project or its git folder stays visible, and the start-up notice names it. The exception
is a Windows Users folder under WSL2: a project inside one makes the sandbox unavailable instead
(“Windows (WSL2)”, below).
mode = "external" and "off" hide nothing.
Session logs are always hidden, so harness export does not work in a sandboxed command; if the
project holds sessions/ or checkpoints/, sandboxed commands are refused. The harness’s other
data, state and cache are read-only to commands.
The protected list (modes.md, “Protected files”) applies here too, except **/
patterns such as **/build.rs. An untracked protected file is read-only to commands, however the
write is spelled. A protected directory with nothing tracked is read-only whole. One with tracked
files is judged file by file, and anything a command adds is moved aside (below); past 1,000
entries it is read-only whole. A file counts as tracked only if it was tracked at session start (or
last resume) and still is, so staging it does not unlock it. With no repository, nothing is
tracked. .claude/worktrees/ stays writable.
A tracked protected file — CLAUDE.md, a workflow, .cargo/config.toml — is writable, so a
sandboxed git checkout completes. Commands can write .git, so to review such changes, note the
commit hash and the ignored files before the work, outside the project:
S=$(git rev-parse HEAD); B=$(mktemp)
git ls-files --others --ignored --exclude-standard > "$B"
After it, review from a fresh index built from that commit:
git --no-replace-objects for-each-ref refs/replace # a line you didn't make: stop here
T=$(mktemp); r() { GIT_INDEX_FILE=$T git --no-replace-objects "$@"; }
r read-tree "$S"
r diff --stat # each tracked file changed; drop --stat to read the changes
r ls-files --others --exclude-standard # files added
r ls-files --others --ignored --exclude-standard | grep -vxFf "$B" # added, but ignored
This defeats tricks that make git status look clean, and the last list catches files hidden by a
new ignore rule. It is not a proof: a change to an existing ignored file (in .venv/, say) shows
nowhere, and submodules have their own index. Checkpoints (checkpoints.md) do
not rely on the index.
A protected path a command creates — a new .envrc or .vscode/, a new file in a .claude/
with tracked files, a re-pointed protected link, or a tracked file replaced by another kind of
file — is moved aside afterwards, never deleted, even if git now tracks it. It goes to
<name>.harness-quarantined-<16 hex digits>, beside it, or at the project root if it was inside a
protected directory. Links are moved, never followed. A note says what moved where; to keep it,
write it with the file tools, which ask. If the move fails, later sandboxed commands are refused,
naming the path, until it moves or you move it on the host.
A new .claude/ that holds only worktrees/ is kept, so git worktree add .claude/worktrees/<name>
works. If a .claude/ with nothing tracked has no worktrees/ yet, that command fails: create the
directory with the file tools, or run the command with approval.
A protected link to the project or a folder above it (.claude -> ., .vscode -> ..) refuses
every sandboxed command, naming the link, until you replace it on the host. So does a protected link
whose target needs more mounts than bubblewrap allows: replace it or shorten the target’s path.
A directory a command makes unreadable (chmod 000) becomes read-only; the result says to check it
before restoring its permissions. An unsearchable project root (chmod 600) refuses every
sandboxed command until you restore it on the host.
Git’s auto-run files are read-only, even when committed: .git/hooks/, .git/config,
.git/config.worktree, worktree and submodule copies, your global and system git config, and
whatever those name (core.hooksPath, core.fsmonitor, include/includeIf files, a hook’s
linked script). If a command re-points or removes a symlink on the way to one, you’re told to
restore it before your next git command.
A git checkout of a branch whose hooks differ therefore stops short. The model is told which
files git could not update. After a checkout or switch it gets git checkout -- <those files> to finish
outside the sandbox, which asks you. After a merge, pull, reset --hard or stash pop, it is told
to check git status. With escape = "never", it tells you which files are left.
A .git/commondir a command creates is moved aside. A hooks folder, include or git config file
that did not exist at session start is not locked: creating one gets a note, so husky install
keeps working.
Past 512 entries in .git/modules/ or .git/worktrees/, that folder is read-only whole, so a
submodule update or git worktree add fails inside the sandbox. A command that would still need
more binds than the system allows is refused, naming the folder: remove what you don’t need on the
host, then resume.
On macOS these are path rules with the same effect, under a separate limit (“The budget”, below). Also:
-
A command cannot create the first missing folder above a hidden or read-only path, so making a missing
.git/hooksfails (mkdir: .git/hooks: Operation not permitted). -
Folders above protected paths cannot be renamed (“What you carry”, below).
-
A protected file with a second hard link refuses the command:
denied: `.envrc` has another hard link, so a command could change it through that name. Fix on the host: remove the other link, or copy the file over itself (`cp -p .envrc x && mv x .envrc`). -
An unsearchable folder on the way to a hidden or read-only path refuses the command, naming it and, if needed, the fix (
chmod u+rwxon it).
What it does not stop
- Damage inside the project (
git reset --hard,rm -rf src): that is the judge’s job.rm -rf(or--recursive --force) on a path with a.gitcomponent is refused in every mode. - Reading an in-project secret like
.envwith the file tools: it asks first, except under--approve-alland inrun --mock. - Anything an approved full escape does.
- What a network-only command reaches: project files and localhost services.
- The removal ask (
modes.md, “Local asks”) sees only named files in known forms. Globs,find -delete,xargs rm,sed -i, one-liners and scripts go to the judge. - Overwriting is not asked (
cp other f,cat > f << EOF): that is an edit. - A touched-but-unchanged file reads as changed (
touch,chmod, renamed back): an extra ask. - With no repository, every named delete or empty asks.
- The decode-and-run ask is a text match. What it misses goes to the judge, still sandboxed.
- Tracked protected files and build files are writable. Changes to
CLAUDE.md, a workflow,build.rs,package.jsonscripts,conftest.pyor aMakefileshow only in the review above, and run the next time you build or test on the host. Review first. - A hard link made outside the sandbox to an untracked protected file lets a command change it through the other name. On macOS such a file refuses the command instead.
- A protected directory on another filesystem keeps what a command creates in it, moved aside in place.
- On Linux, a protected link into the private
/tmpis not read-only. - A path the harness couldn’t move aside blocks commands only until the session ends. Move it on the host before resuming.
- Concurrent children: a protected path one child creates can be moved aside by a sibling’s check.
Which tools it covers
Only the two that run programs.
| Tool | In the sandbox? | What limits it instead |
|---|---|---|
bash, exec | yes | the sandbox |
read_file, write_file, edit_file, glob, search | no | the project root; no links out; credential reads and protected writes ask; the file tools open regular files only, and read none over 16 MiB (below) |
script | no | it acts only through the tools above |
agent | no, but the child’s own bash/exec are | the same rules, one level down |
skill | no | it only reads skill text; some loads ask |
The file tools refuse a symlink out of the project, naming its target; a project CLAUDE.md or
.agents/ file that links outside does not load. Reading a credential file asks first in every
mode, with Asked because it reads a credential file, except under --approve-all and in
run --mock. Credential files are .env/.env.* (not .env.example, .env.sample or
.env.template), *.pem, *.key, *.p12, *.pfx, and anything in a .secrets folder in the
project. The “this turn” choice reads except this kind: the next one asks again. search skips
credential files and says how many (credential_files_skipped).
The file tools read only regular files, up to 16 MiB. After the checks above, read_file, edit_file
and write_file refuse:
- a FIFO, a socket, a device or a folder (a read of a FIFO or a device can wait forever):
refused: <path> is not a regular file; the file tools work only on regular files; - on Linux, a file in
/procor/sys, which matters when the project is at/, whereread_file proc/self/environwould return the harness’s own environment:refused: <path> is in /proc or /sys; the file tools do not read there.
read_file and edit_file also refuse a file over 16 MiB, before reading any of it:
refused: <path> is larger than 16 MiB; the file tools read files up to 16 MiB. write_file replaces a
file of any size, since it keeps none of it. search skips a file over 16 MiB and says how many it skipped
(large_files_skipped, shown only when above zero). bash and exec keep the first 1 MiB of each stream
as before, and no longer hold the rest in memory while a command runs. A file that is a link out of the
project, or a credential file, keeps its own refusal. A session recorded before 0.26.0 replays as it was
recorded.
The two ways out
bash and exec take two more arguments, each with a reason shown to you and never sent to the
judge.
network: truekeeps the rest of the sandbox and adds the machine’s network; on macOS that includes any port on127.0.0.1. It hides the project’s credential files and every.secretsfolder, found fresh each time. On Linux a hidden.secretsreads empty and writes into it are discarded; macOS refuses them. A.pemup to 2 MiB holding only public certificates stays visible.node_modules,site-packagesand__pycache__are skipped. Past 1,000 files to hide or 100,000 entries to search, or at a directory or credential-named link it can’t read, the command is refused, naming the path: fix its permissions, dropnetwork, or useoutside_sandbox. It does not stopgit show HEAD:.env, credentials inside skipped folders (.git,target,node_modules,site-packages,__pycache__), or a renamed copy. It is judged inautoand asked in manual, and a grant for plain commands never covers it.outside_sandbox: trueruns the command as you, unconfined. It always asks, in every mode, with no “for this turn”/“for this session” option. Unconfined (this way, or with the sandbox off or unavailable), if a process still holds the output two seconds after the command exits, the call returns. Processes in its group are ended; ones that left it (setsid, job control) keep running. The model sees[output cut: the command exited, but a process it started still holds its output].
[sandbox] escape = "never" removes both: nothing leaves the sandbox or reaches the internet.
Under --approve-all, network: true is approved automatically and outside_sandbox: true
is refused; the model is told to carry on and name the step in its final message. Child sessions
inherit this. A conversation (--input-format stream-json) refuses outside_sandbox: true the
same way unless --allow-escapes says a person answers the program’s requests
(docs/headless.md, “Approvals”).
Away
/away (panel or plain REPL) says you’re not watching: until the current task ends if typed
mid-task, or from the next task if typed while idle. The plain REPL reads it only between tasks.
Type /away again to come back. harness run --away --prompt "…" does the same for a scripted
run; it needs --prompt and starts in auto unless --mode says otherwise. Both are refused
under --approve-all and in a mode where every command asks.
While you’re away, anything that would ask you is refused (an unreachable judge and a child’s ask
included), and the model is told to keep going. A network-only command is judged at the away
cutoff ([judge] away_cutoff, default 0.2 like the ordinary cutoff; lower asks more while you’re
away, config.md). Grants from
before you left (AllowForTurn/AllowForSession) do not apply.
The timeout
[away] timeout (default 15m) is how long a question waits before it is refused as if you’d
typed /away; the rest of the task then runs away. It applies only where /away can start: a mode
where commands don’t ask, with the sandbox on or external. Otherwise questions wait with no limit.
A child’s question follows your session’s mode. Values: off, or Ns/Nm/Nh up to 24h. On
the panel any key restarts the clock.
After the breaker has tripped in the current task (below), a timeout ends the turn instead, and the session waits for your next message.
The breaker
While you’re away, 3 refusals in a row, or 20 across the away stretch (unreachable-judge refusals included), trip the breaker. Away ends, and the next thing that needs you waits as an ordinary ask. Until the task ends, a timeout ends the turn instead of restarting away, and the away cutoff stays in force even if you return. A child spawned after a trip starts tripped.
A tripped root task keeps doing what needs no approval. A tripped child stops, and its parent gets
that as the result. Under run --away, the next question is denied at once if nothing can read it,
or after the timeout if something can. Either way an unfinished turn exits 1.
/iterations for long tasks
An away task can still hit the per-task cap on model calls (32 by default); each refusal costs one.
/iterations <n> raises it for the rest of the session, the running task included, before its next
model call; alone, it prints the cap. --away warns at launch if the cap is the default. A resume
keeps a cap you chose (--max-iterations or /iterations); one from the default or config.toml
follows config.toml as it reads at the resume. A crash resume keeps the cap the log last recorded.
Requirements
On Linux the backend is bubblewrap (bwrap) with a
seccomp filter. It needs unprivileged user namespaces, which Ubuntu 24.04 usually blocks through
AppArmor; the harness names the fix. Under Windows it runs in WSL2 (“Windows (WSL2)”, below).
On macOS the backend is Seatbelt, through /usr/bin/sandbox-exec. It needs:
- macOS 15.4 or later, on Apple Silicon or Intel (
README.md, “macOS”); 27.0 or later is better, on Apple Silicon (“Update macOS”, below). /usr/bin/sandbox-exec, which ships with macOS.- The harness outside any other sandbox, since Seatbelt refuses a second one.
Otherwise each start says why the sandbox is unavailable:
the sandbox is unavailable: the harness is itself running inside a sandbox. Auto is off, and --approve-all and --away will not start. Fix: start the harness outside it, or set [sandbox] mode = "external" when that sandbox is the boundary, in <your config file>.
the sandbox is unavailable: `/usr/bin/sandbox-exec` is missing. Auto is off, and --approve-all and --away will not start. Fix: set [sandbox] mode = "external" when this machine is its own boundary, or mode = "off" to run commands unconfined, in <your config file>.
Each session start also runs a probe (about 0.2 s) and names any check that fails:
the sandbox is unavailable: the sandbox did not hold (listing_order). Auto is off, and --approve-all and --away will not start. Fix: no setting fixes a failed check; report the check's name. To run without the sandbox on purpose, set [sandbox] mode = "off" in <your config file>.
A failed check usually follows a macOS update, and no setting fixes it. Until the harness is
updated, mode = "off" restores auto and --approve-all, and mode = "external" also
restores --away when the machine is its own boundary. Report the check’s name.
Default Docker and Docker Desktop cannot nest the namespace bubblewrap needs. If the container is your boundary:
[sandbox]
enabled = false
That runs commands unconfined but still refuses run --away; for unattended runs use
mode = "external" (“External mode”, below). On Linux, when markers such as /.dockerenv
suggest a container, the unavailable message says so and suggests mode = "external" first:
the sandbox is unavailable: user namespaces are blocked; the harness seems to be inside a Docker container (/.dockerenv). Auto is off, and --approve-all and --away will not start. Fix: set [sandbox] mode = "external" when that container is the boundary, in <your config file>; otherwise sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0.
When the sandbox is unavailable, the harness fails closed. auto is not offered: choosing it,
resuming into it, or a judging default mode in config.toml prints the cause and fix and falls
back to a plain manual that judges nothing. --approve-all, run --mode auto, run --away and
harness eval exit 2 at launch, as does resuming a session that crashed with its sandbox on. In
manual, approved commands run unconfined.
How mode changes that:
| sandbox | auto | --approve-all, run --mode auto, eval | run --away, /away |
|---|---|---|---|
| unavailable | not offered | exit 2 | exit 2 |
mode = "off" | yes, unconfined | yes, unconfined | refused |
mode = "external" | yes | yes | yes |
On macOS the model is told it has BSD tools (sed -i '', stat -f).
A killed command’s leftovers
harness resume brings back a session whose harness was killed mid-command (terminal closed, SSH
dropped, a reboot), without re-running the command (checkpoints.md,
“Resuming a killed session”). It also handles what the command left behind.
The record. Before each command the harness writes leftovers/<session>/<effect number>
under its state folder (~/.local/state/harness by default, config.md), and
removes it once the command is done.
What resume does, before anything else runs, for the session and its children:
-
Linux: the sandbox died with the harness. One still running holds sandboxed commands (below).
-
macOS: sandboxed leftovers are stopped:
Leftover processes: 2 stopped (they ran in the sandbox). -
A command outside the sandbox that was still running is named, never stopped:
Still running outside the sandbox: 275710 (sleep). They were left running.If the command’s first process is gone:
May still be running outside the sandbox: …. The command's first process is gone, so these may belong to another program; check with `ps -p <numbers>` before stopping one.After a reboot, nothing is looked up.
-
A record with no process (killed before the command started) quotes its first two words:
A command may still be running; it could not be identified. Look for it with `pgrep -fl 'cargo test'`; stop any it lists that you did not start, with `kill` and its number. -
Then protected files the command created are moved aside:
The command created .envrc, which runs code or steers the agent later, so the harness moved it to .envrc.harness-quarantined-1231b2265de8612a. To keep it, write it with the file tools; the user is asked.
An interrupted resume reports again.
Holds. If a leftover can’t be confirmed gone, sandboxed commands are held (unsandboxed ones are not). Each one re-checks, so the hold lifts once the cause is gone:
-
An old sandbox still running, or one that
could not be checked:Sandboxed commands are held: a sandbox from before the harness was killed is still running (pid 4321). It ends by itself, or stop it with `kill -9 4321`; then run the command again.On macOS, for a command that never entered its sandbox:
a command from before the harness was killed has not entered its sandbox (pid 4321). Stop it with `kill -9 4321`; then run the command again. -
Files that could not be moved aside:
Sandboxed commands are held until .envrc is moved aside: move it away or remove it on the host. -
A record that is empty, damaged, from another version, or incomplete:
Sandboxed commands are held: ~/.local/state/harness/leftovers/<session>/3 could not be read (it is empty or damaged), so the files that command may have planted were not checked. Protected files in the project now: .envrc, CLAUDE.md. Remove any you did not make. Then run: rm ~/.local/state/harness/leftovers/<session>/3
Another folder. Resume from the folder the command ran in:
session failed: a command this session ran in /path/to/project was killed; files it may have planted there can only be checked from that folder. Resume it there: cd /path/to/project && harness resume <session>
A records folder that can’t be listed stops the resume the same way.
When the record cannot be written (permissions, a full disk), sandboxed commands are refused:
Sandboxed commands are refused: the harness cannot write in ~/.local/state/harness/leftovers/<session>/ (Permission denied). Make that folder writable by you, or free disk space, then run the command again.
Commands outside the sandbox, or with it off, run with the note
leftover record not written: <why>; if the harness is killed while this command runs, its processes are not reported at resume.
Known gap: a session held at resume and then closed while idle is neither held nor swept at its next resume.
On macOS
Seatbelt enforces the same policy. Where macOS differs (below), the model is told too. The session
record names the backend (seatbelt macOS 27.0 (26A428)).
Side by side
| Policy | Linux (bubblewrap) | macOS (Seatbelt) |
|---|---|---|
| Reads | all but the hidden list; hidden reads empty | the same; hidden fails Operation not permitted |
| Hidden list | covered at session start | plus the macOS additions; later paths covered too |
| Writes | the project, private /tmp, a linked worktree’s git directory, [sandbox] writable | the same, private folder at $TMPDIR, plus the user’s temp folder minus com.apple.* |
The machine’s /tmp | out of reach | out of reach; a project inside it works |
| Git’s auto-run files, protected files, the harness’s own folders | read-only; created ones moved aside | the same; folders above them cannot be renamed |
| Network, plain command | loopback within the command | none |
| Network-only command | the machine’s network | the same |
| Unix-domain sockets | refused | refused; socketpair works |
| DNS | fails at once | fails at once |
| Processes | all end with the command | invisible; swept when the command ends |
| Shared objects | a private IPC namespace | others’ shared memory refused; named semaphores shared |
| Devices | a minimal /dev | a short list; none of your disks, console or terminals |
| Terminal | a fresh devpts | one the command opens |
| Credentials in the environment | removed | removed |
| Ways out | — | refused: open, Apple Events, launchd, the keychain, the pasteboard, most system services, terminal input injection, setuid programs, a nested sandbox-exec |
A refused command shows the system’s own error, then the blocked-command note:
touch: /tmp/x: Operation not permitted
What you carry
- Loopback tests need
network: true. A test against a fake server on127.0.0.1(acargo test, say) fails in a plain command — Rust:Operation not permitted (os error 1), Python:PermissionError: [Errno 1] Operation not permitted. Withnetwork: trueit has the whole network, is judged, cannot run underescape = "never", and cannot read the project’s credential files, so a TLS test with a*.keyor.pemfixture needs a full escape. - The user’s temp folder is shared with your other programs. A bare
mktempand Foundation use it rather than$TMPDIR, so files left there outlast the session. Usemktemp -d "$TMPDIR/x.XXXX". The user’s cache folder (/var/folders/…/C) is read-only. - A network-only command listens on every address, reachable from the local network.
- Keychain credentials do not work inside:
securityfinds nothing, andgit’sosxkeychainhelper and theghlogin should fail too (unverified). Run the step that needs the credential outside the sandbox. - Named shared memory is refused except
libomp’s, so Python’smultiprocessing.shared_memoryand PyTorch worker tensors fail withPermissionError. Named semaphores are shared with your programs. System V objects outlive the command. - A command cannot rename or remove a folder holding a hidden or read-only path (or, for a
network-only command, a credential file), nor the project, the private temp folder or another
writable root:
mv .vscode .vscode-oldfails withOperation not permitted. A whole-folder clone is refused;cp -c -Rcopies file by file.
These fail with their own error and get no blocked-command note: open
(No application knows how to open URL …), osascript to another app
(Connection Invalid error for service com.apple.hiservices-xpcservice), security
(The specified item could not be found in the keychain.), and pbcopy and launchctl
(exit 1, no output).
Tools that sandbox themselves
Seatbelt refuses a second sandbox, so a program that sandboxes itself fails inside; an App Sandbox
program is killed at once. SwiftPM is the common case: swift build fails with
sandbox-exec: sandbox_apply: Operation not permitted
swift build --disable-sandbox fixes it. Similar: xcodebuild (run it with outside_sandbox),
Bazel (--spawn_strategy=local, unverified), Chrome and Electron (--no-sandbox, unverified).
When such a failure is a command’s whole result (exit 65 or 71), it reads as the sandbox failing to
start, with no note:
io: sandbox-exec failed to start the sandbox: sandbox_apply: Operation not permitted
A held session
When a command ends, even on a timeout or Ctrl-C, the harness ends every process it started. If it cannot confirm they all ended, the result is withheld and the session is held: every later command and file tool, children’s included, is refused until the process is gone. The command’s error (the process ID is an example):
the harness could not confirm that every process this command started has ended (process 4321 is still running), so its result is withheld and nothing else runs in this session until it can: end it on this machine (`kill -9 4321`), then run any command to check again. Files a leftover process creates are checked once it is confirmed.
Each later call:
an earlier command left a process the harness could not confirm has ended (processes 4321 and 4322 are still running), so this was not run: end them on this machine (`kill -9 4321 4322`), then run any command to check again.
At most eight IDs are listed, then and N more. If only unsettled entries remain, the text says how
many (3 entries of the process list could not be settled) and the fix is wait a moment. Run the
kill -9 in your own terminal, then any command. A killed harness’s leftovers stay sandboxed until
resume sweeps them (“A killed command’s leftovers”).
The budget
A sandboxed command starts about 30 ms later, and a session start about 0.2 s later. Every path a command’s profile names adds start time, so a profile is capped at 1,000 path filters; a typical project uses about 100. Past the cap the command is refused, naming the largest list and the fix:
denied: the sandbox for this command needs 1155 path filters, more than Seatbelt compiles in a usable time (1,000): most are hidden paths. Fix on the host: shorten the [sandbox] hide list, then resume the session.
It can also name the protected paths (drop unneeded protected-list entries and git files) or the credential files (move unneeded ones out of the project).
The whole command line, environment included, must fit the system’s 1 MiB argument limit. Past it the refusal names the largest part:
denied: the sandbox for this command needs 1111404 bytes of arguments and environment, more than the system's argument limit (1048576 bytes): the largest part is the command's own arguments (1100066 bytes). Fix: shorten them, or pass the data to the command through a file in the project or $TMPDIR.
An environment near 1 MiB makes the sandbox unavailable at start.
A project path over about 1,000 bytes cannot run sandboxed commands. The message blames permissions, but the cause is the length:
denied: the project root cannot be read (File name too long (os error 63)), so the command was not run: restore its permissions on the host.
Move the project to a shorter path.
Update macOS
macOS 27.0 fixed a Seatbelt flaw Apple describes only as “an app may be able to bypass network
restrictions” (CVE-2026-84551, 27.0’s notes). Apple’s
record (NVD) marks every macOS below 27 as
affected, so below 27, doubt the network rules. An Apple Silicon Mac fixes it by updating to 27;
update when you can. An Intel Mac cannot (27 does not run on Intel), so on Intel the network rules
carry that doubt for good. The backend in a session’s record shows which macOS enforced it.
Windows (WSL2)
Windows is supported through WSL2, as a Linux install inside a distribution. It has been checked on one Windows machine (WSL2 with Ubuntu 26.04); the rest of what follows is from Microsoft’s documentation and the code.
- Check the version. In PowerShell,
wsl -l -vlists each distribution with a VERSION column, which must say 2.wsl --set-version <name> 2converts one;wsl --installsets up WSL2 with Ubuntu. WSL1 is not supported, and neither is Windows on ARM. - Set up inside the distribution. Install bubblewrap with its package manager
(
sudo apt install bubblewrapon Ubuntu), then run the installer from Getting started there. The Linux requirements above apply unchanged. - Keep projects in the Linux file system (
~/code), not under/mnt/c. Names on a Windows drive compare without case, sowrite_file claude.mdcould replaceCLAUDE.mdwith no prompt. The harness folds case there, as it does on a Mac’s default disk, so that write is refused; but the Linux file system is the safe place. - What the sandbox hides. WSL2 mounts each Windows drive at
/mnt/c,/mnt/d, and so on, and the sandbox reads the whole file system, so a command would otherwise readC:\Users: every account’s.ssh, AppData (browser profiles, app tokens) and Documents. Each drive’sUsersfolder is hidden under every spelling that names an existing folder (Users,users,USERS, …), because the drive folds case. The rest of the drive stays readable, and read-only. The mount table decides what a drive is, so a moved automount root (/etc/wsl.conf) is followed. - A project (or its git folder) inside a
Usersfolder (/mnt/c/Users/you/code) makes the sandbox unavailable, since hiding the folder would hide the project. Auto,--approve-alland--awayfail closed, as for any unavailable sandbox (/mnt/c/codekeeps the sandbox):
the sandbox is unavailable: the project is inside a Windows Users folder (/mnt/c/Users), which the sandbox hides. Auto is off, and --approve-all and --away will not start. Fix: move the project into the Linux file system (such as ~/code) or elsewhere on the drive, or set [sandbox] mode = "off" to run commands unconfined, in <your config file>.
- Windows programs do not start from a sandboxed command.
cmd.exeand/mnt/c/…/*.exereach Windows over a Unix socket, which the sandbox refuses. Run them outside the harness. - Not covered: a
Usersfolder moved to another drive or folder, a drive mounted after the session starts, andUserson a Windows partition mounted outside WSL (dual boot).
[sandbox] and [away]
Both tables are read only from your own config.toml: a project cannot widen or disable the
sandbox or lengthen the timeout. A [sandbox] table in a config reached through HARNESS_HOME or
XDG_CONFIG_HOME is refused if it lies inside the project (config.md).
[sandbox]
enabled = true # false runs every command unconfined (mode = "off")
mode = "on" # "on", "off" or "external"; must agree with enabled
writable = ["~/.cache/pip"] # extra ~/… paths commands may write
hide = ["~/.ssh"] # extra ~, ~/… or absolute paths to hide
env_hide = ["MY_APP_TOKEN"] # extra variable names (or * patterns) to strip
env_pass = ["GITHUB_TOKEN"] # exact names kept in the environment; external only
escape = "ask" # "ask" (default) or "never" (no ways out)
[away]
timeout = "15m" # "off", or Ns / Nm / Nh, up to 24h
writable takes ~/… paths below your home directory: not ~ itself, and not absolute paths.
hide takes ~, ~/… or absolute paths; relative entries are refused at load.
[judge] away_cutoff (default 0.2) is in config.md.
External mode: the machine is the sandbox
[sandbox] mode = "external", or --sandbox external for one session, says this machine is
itself the boundary: a dedicated VM, a container, a CI runner. The harness builds no sandbox of
its own. It works on Linux and macOS, and only your config or the flag can set it
(config.md, “[sandbox] — mode, env_pass and --sandbox”). At start it
says:
this machine is the sandbox ([sandbox] mode = "external", set by <your config file>): commands run without a sandbox from the harness.
(set by --sandbox when the flag chose it.)
What it allows. auto, --approve-all, run --away, /away and harness eval.
--approve-all refuses only the hard-deny list (modes.md, “The hard-deny list”);
run --away with --approve-all is still refused.
What goes. Hidden folders, read-only protected files, the private /tmp, the network rule,
the probe, and the two ways out. What stays. The judge, the local asks, the file tools’ limits
and asks, and the hard-deny list.
The environment. For every command, children’s included:
- A provider’s key variable is always removed.
- Otherwise a name in
[sandbox] env_passstays (exact, case-sensitive, no patterns). - Otherwise the credential names under “What it does”, URL values with a password, and
env_hidenames are removed.
This is not a credential boundary: commands run as the harness’s user and can read that user’s other processes’ environments. Keep credentials off the machine.
The session record lists the names removed and those kept by env_pass (never values), and the
model is told what is missing:
Commands run without a sandbox from the harness: this machine is the boundary. These variables are not available to commands: MY_APP_TOKEN, OPENAI_API_KEY.
The second sentence is dropped when nothing was removed.
Resuming. To switch a session to or from external, /quit and harness resume <session>
with the new setting. A resume after a crash keeps the recorded sandbox: external continues only
if it is still set and the recorded environment names, platform and case still match. Otherwise it
exits 2 with the start-up notice, the first difference
(the variables removed from commands changed since the crash: recorded …; now ….), and:
a session resumed after a crash keeps the sandbox it recorded; to change it, /quit and resume, or start a new session
A session recorded on resumes in the sandbox or exits 2. One recorded off, unavailable, or
before the sandbox existed counts as unavailable unless the setting is now off.
A 0.5.x binary cannot read a log that records external.
What the environment must do instead. A command can damage anything outside the project that the machine’s user can touch. So:
- keep key folders, other checkouts and long-lived tokens off the machine; pass a scoped,
short-lived token through
env_pass; - limit its network: the harness enforces none (the record says
"network":"host"); - snapshot or discard it after each run.
Protected files are writable to commands under external, and under off. The file tools
still ask, but a command can write .envrc, .claude/, .vscode/ or a workflow, and the judge
treats that as an in-project edit. Planted code runs when the project is next opened, so review
the work (“What it does”). The judge is untested on macOS commands
(brew, launchctl, osascript, security, defaults).
What stays fine inside
On both systems:
- DNS fails fast:
Network is unreachableorCould not resolve host, not a hang. On macOScurlprintscurl: (6) Could not resolve host: example.com. - The private temp folder (
<cache>/sandbox-tmp/<session>) is shared with child sessions.
On Linux:
cargo testagainst a loopback fake server.- A fresh
devpts: a command that opens a pseudo-terminal gets a private one. - The private
/tmp, in place of the machine’s (your tmux sockets, other sessions’ files).
On macOS:
- Ordinary builds and tools:
git,cargo buildwith a C build script,swiftcwith its module cache, a zsh here-document, a Foundation atomic save, and Python’smultiprocessingPool,ProcessandQueue. A package manager that fetches needsnetwork: true. $TMPDIR, the private folder;mktemp -d "$TMPDIR/x.XXXX"works.- A terminal the command opens,
socketpair, pipes and/dev/null.
A blocked command says so
When a sandboxed command fails, or exits 0 while its output shows a sandbox refusal on a protected
git path, the result carries a note: what the sandbox blocks, and how to ask for more —
network: true for the internet, outside_sandbox: true for anything else. On macOS it also
says network: true is the way to a local port. A failing
open, osascript, launchctl, pbcopy or security gets no note (“What you carry”, above).
An overnight task
Set the model, thinking effort and mode, raise the per-task cap, then go away — the rest is one ordinary message:
harness run
/model claude-opus-5
/model thinking high
/mode auto
/iterations 500
/away
<the actual task>
Checkpoints, /undo and the turn-end list
The harness takes a picture of the project before a command runs, lists what changed at the end of each turn,
and restores files with /undo, even ones git does not hold. Checkpoints are on by default; [checkpoints] in
config.md sets the limits.
What a picture is, and when one is taken
A picture records each covered file’s type, size, mode and times, and copies its content to the store. One is taken after a call is approved, before it runs:
- a whole picture before every
bash,exec(including a script’s) andagentcall, and before the turn’s first file-changing call; - a path picture before each later
write_fileoredit_filecall in the turn.
Later pictures copy only what changed. A failed picture never blocks the call; the gap is reported. Child agents
run with checkpoints off; the picture before their agent call covers them.
What is covered
Every regular file and symlink in the project (links are recorded, not followed), except:
.git/and what.gitignoreexcludes; without git, folders namednode_modules,.venv,venv,target,__pycache__,.gradleor.next;- anything
[checkpoints] skipnames, and the harness’s own folders; - credential files (
.env,.env.*except.example,.sampleand.template,*.pem,*.key,*.p12,*.pfx, anything in a.secretsfolder) and files overmax_file_mib: recorded, never copied; - empty folders, FIFOs, sockets, devices, and anything outside the project.
A .gitignore the harness cannot use whole (unreadable, a link, over 1 MiB, or an ambiguous line such as ***)
leaves its folder not checked.
A file with no copy says why: no copy (over 100 MiB), no copy (credential file), no copy (picture budget),
no copy (changed during capture), no copy (unreadable name), no copy (unreadable).
The turn-end list
When a turn ends (however it ends) with changes, the harness lists them against the turn’s first whole picture:
files: 2 changed, 1 new, 16 deleted
changed src/parser.rs
changed Cargo.toml
new scripts/tidy.sh
⚠ deleted docs/notes/ (16 files) not in git /undo
Up to 10 paths show, then … and N more; flagged lines always show. Changes you make in your editor appear too.
harness export includes the list. With checkpoints off, there is no list. A path no picture covered is
not checked, never new or deleted.
Flags are fixed rules. Each flagged line offers /undo <path> or /undo, except a new protected file (a
restore never deletes).
not in git: a deleted or emptied file git has no copy of. Git is asked about at most 100 files per turn;not checked against gitmeans it could not answer.20+ deleted: 20 or more files deleted in the turn.protected: a file on the protected list changed (modes.md, “Protected files”).
A loss with no copy shows the reason instead of an undo; a folder line counts them (3 of 16 without a copy).
Notes state any gap, so a partial list never reads as “nothing changed”: no checkpoint: <why> (such as
the checkpoint store is busy (another session is using it) or the checkpoint store is full),
checkpoint partial: …, or this list starts before <command>: an earlier call this turn had no checkpoint.
Resuming a killed session
If the harness is killed mid-command (closed terminal, dropped SSH, reboot), harness resume <session> picks the
session up without re-running the command. Run from the session’s folder, it prints the killed call’s
failed line, then:
This session was killed while a command ran: `printf x >> a.txt && printf ok > started && sleep 60.071`. It may have partly run; its output was not recorded.
Since it started:
changed a.txt /undo restores
new started
Not covered: gitignored files, anything outside the project.
The model gets the same text as the command’s failed result. The first line depends on what was killed; a file
write, for example, says The file may be empty or cut short. A killed agent call names its child sessions,
which are not resumed.
The change list runs from the last good picture before the call to the project now, so it includes your
edits since the kill. When it cannot, it says why, such as no checkpoint: checkpoints were off.
The turn-end list follows the notice, for the interrupted turn:
files: 1 changed, 1 new
changed a.txt
new started
↳ 0.0k in / 0.0k out (interrupted)
Leftover processes and protected files the command planted appear as further notice lines; see
sandbox.md, “A killed command’s leftovers”.
Replay refuses such a session: not replayable — session contains an interrupted turn or an abandoned effect.
/undo
/undo opens a picker of this session’s whole pictures, newest first (before ./tidy.sh · 14:02). Enter previews
the restore:
undo to before ./tidy.sh: 1 to write back, 1 to recreate, 1 kept, 1 left alone
write back src/parser.rs
recreate docs/notes/a.md
leave big.bin no copy (over 100 MiB)
kept scripts/tidy.sh (created since)
On the panel, Enter restores; Esc or Ctrl-C cancels. /undo <path> restores one file to its state before its
latest change, with the same preview.
During a turn, /undo answers wait for the turn to end. With nothing to undo it says
no checkpoints in this session; a path outside the project gets /undo takes a path inside the project. A
cleared picture says the checkpoint … was cleared (older than keep_days, or the store was full).
It never deletes. Files created since the picture are kept. A path it cannot write is skipped and reported.
It is guarded. It skips files changed since the preview, and first saves every file it will overwrite; one it cannot save (too big, a credential file) is left alone. Hard links and symlinks survive, and modes are never widened. Times are not restored, so build tools see restored files as new.
It can be undone. The picture it saves first appears in the next /undo as before /undo.
restored 2 files to before ./tidy.sh
written src/parser.rs
recreated docs/notes/a.md
skipped big.bin no copy (over 100 MiB)
kept scripts/tidy.sh
saved first: /undo can undo this
At its next turn the model is told “The user restored N files to their state before <label>.” The agent has no
undo tool and cannot reach the store.
In the plain line client (pipes, redirects), bare /undo prints the pictures numbered and reads a number
(type a number to preview it, anything else cancels), then asks restore? type y to confirm. y, Y, yes
or Yes restores; anything else prints undo cancelled.
/checkpoints
/checkpoints shows checkpoints: on or checkpoints: off. /checkpoints on and /checkpoints off switch until
the next resume, which reads [checkpoints] enabled again. /undo still works on earlier pictures.
A project rooted at your home folder or / takes no pictures. The start notice says checkpoints are off: the project is your home folder (or the project is the root folder (/)), /checkpoints shows
checkpoints: off (<why>), and /checkpoints on repeats the notice.
Limits
| Limit | Default | Key |
|---|---|---|
| Largest file copied | 100 MiB | max_file_mib |
| Whole store, per project | 5 GiB | max_store_gib |
| Age before a picture is cleared | 14 days | keep_days |
| Files one picture copies | 10,000 | max_picture_files |
| Bytes one picture copies | 500 MiB | max_picture_mib |
| Time one picture may take | 10 seconds | max_picture_seconds |
| Entries one walk lists | 100,000 | max_walk_entries |
Files past a picture’s budget get no copy (picture budget) and are copied by later pictures. A picture that
stops early says so, for example checkpoint partial: 48,000 new files in out/, 10,000 copied. Add out/ to .gitignore or raise the limit. At the store cap, the oldest pictures are cleared first. Esc or Ctrl-C during a
picture stops the call: interrupted while taking a checkpoint; the call did not run.
Where the store is, and who can read it
$DATA/checkpoints/<project key>/, owner-only (folders 0700, files 0600). The key is the project path as spelled,
so two spellings of one project make two stores.
Sandboxed commands never see checkpoints/ or sessions/ (sandbox.md), so harness export does
not work inside one. The agent’s file tools refuse both: <path> is inside the harness's private <name> folder.
Commands you approve to run outside the sandbox can reach the store. Deleted content stays there for keep_days.
Known limits
- Two sessions in one project share a store. One that cannot get it in time reports a
busygap; its call still runs. - The credential list has gaps:
id_rsa,.netrcandcredentials.jsonare copied. - A file whose name is not UTF-8 cannot be restored.
- A writer racing a restore can leave a
.harness-displaced-*or.harness-restore-*file, named in the result. - Folders deeper than 64 levels are not pictured and show as
not checked.
Hooks
A hook is a command of yours that harness runs at two moments: before a tool call, and when you send a
prompt. It can block the call or the prompt, or (for a prompt) add text the model reads beside it. Hooks
are for your own rules (“never force-push”, “route ooo prompts to a skill”). They are not a security
boundary (below).
Hooks are defined in config.toml (config.md) and read from your user config
only, as MCP servers are. A config found through HARNESS_HOME or XDG_CONFIG_HOME inside the project
is refused for [[hooks]].
The table
[[hooks]]
name = "no-force-push" # required; unique; 1–32 of a-z, 0-9, "-", "_"
event = "PreToolUse" # "PreToolUse" or "UserPromptSubmit"
tools = ["bash"] # PreToolUse only; omitted = every tool
command = ["/home/dana/.local/share/harness/hooks/no-force-push.sh"] # argv; no shell
timeout = 10 # whole seconds, 1–600; default 30
sandbox = true # default true
on_failure = "block" # "block" (default) or "continue"
commandis an argv. Nothing runs through a shell unless you write["sh", "-c", "…"].- Hooks run in the order the file lists them. The first to block ends the run; later hooks do not run.
toolsnames harness’s own tools:bash,exec,read_file,write_file,edit_file,glob,search,skill,agent,script,mcp,mcp_schema. A name that is none of these prints a start-up warning, not an error, so a config written for a later harness still starts:⚠ hook no-force-push lists "Bash", which is not a tool here; tool names are lower case, such as bash.- A load error names the file for: an unknown key, an event other than the two, an empty
command,toolson aUserPromptSubmithook, a duplicate or malformedname, atimeoutout of range. Claude Code’smatcher,typeandifare unknown keys;matchergets a message that says to usetools. - At start the banner lists the hooks:
hooks: no-force-push (bash, exec) · keywords (prompts) · notify (every tool, on the host).-pprints the line on stderr.
PreToolUse: block a tool call
The hook runs when the model’s call reaches the approval gate, after the structural refusals (a tool the
mode does not offer, an unknown argument, the hard-deny list, a plan-mode write outside the plan file)
and before every permission shortcut. So a hook binds under --approve-all and in plan mode, and
never runs for a call that was already refused. It covers every way a call can arrive: an ordinary call,
each call inside a script, an agent spawn, an mcp call, and the calls of sub-agents and the side
session.
Input, one line of JSON on stdin, then end of file:
{"session_id":"01M…","hook_event_name":"PreToolUse","cwd":"/home/dana/proj","permission_mode":"auto","tool_name":"bash","tool_input":{"command":"git push --force origin main"},"tool_use_id":"7EX…"}
permission_modeis the name of the mode in force (manual,plan,auto, or your own mode’s name).session_idis the session running the call: a sub-agent’s own id inside a sub-agent.tool_inputis the call’s arguments, with in-project paths made relative to the project. A call inside ascriptcarries the outerscriptcall’stool_use_id.- An MCP server’s tool is called through the
mcptool, so a hook for one liststools = ["mcp"]and readstool_input.server,tool_input.toolandtool_input.arguments. - The field names are Claude Code’s, without
transcript_path.
What the answer means (in every row a reason is trimmed and cut to its first 2,000 characters; empty or
absent, it is no reason given):
| The hook | Means |
|---|---|
| exits 2 | block; the reason is stderr |
| exits 0, prints nothing | no objection |
exits 0, prints a JSON object with hookSpecificOutput.permissionDecision = "deny" | block; the reason is permissionDecisionReason |
exits 0, prints a JSON object with decision = "block", or continue = false | block; the reason is reason, or stopReason |
exits 0, prints any other JSON object ({}, {"continue": true}, decision = "approve", permissionDecision = "allow", "ask" or "defer", …) | no objection |
exits 0, prints text that is not one JSON object, or stdout over 64 KiB; or, when no field says block, a hookSpecificOutput that is not an object, or permissionDecision, decision or continue of another kind or word | failure (bad_output) |
| any other exit code | failure (exit) |
| killed by a signal; timed out; could not start | failure (signal, timeout, spawn_error) |
allow and ask mean no objection. A hook only ever blocks. The call goes on through the judge, the
sandbox and any question to you, as if the hook had not run. Other JSON fields (systemMessage,
additionalContext, …) are ignored on PreToolUse.
A block is a failed call. The model reads blocked by the user's hook "no-force-push": <reason>, the turn
goes on, and under -p the call is named on stderr with the other failed calls. It is not in denied
(that lists calls that needed approval).
UserPromptSubmit: add context to a prompt, or block it
The hook runs for each prompt that opens a turn in the main session: typed in the panel or the plain
prompt (a /skill:name … message included), given to -p, or a harness eval case’s prompt. It does
not run for a message you steer in while the agent works, a side question, a sub-agent’s prompt, or a
background sub-agent’s result.
{"session_id":"01M…","hook_event_name":"UserPromptSubmit","cwd":"/home/dana/proj","permission_mode":"auto","prompt":"ooo seed my requirements"}
There is never a turn_id (Ouroboros’s detector reads one as the mark of a Codex host).
| The hook | Means |
|---|---|
| exits 2 | block the prompt; the reason is stderr |
| exits 0, prints nothing | nothing added |
exits 0, prints a JSON object with decision = "block", or continue = false | block; the reason is reason, or stopReason |
exits 0, prints a JSON object with a string hookSpecificOutput.additionalContext | that string is the context |
exits 0, prints any other JSON object (decision = "approve" included) | nothing added |
exits 0, prints text whose first non-space character is not { | the trimmed text is the context |
exits 0, prints text starting { that is not one JSON object, or stdout over 64 KiB; or, when no field says block, a hookSpecificOutput that is not an object, a hookEventName that is not "UserPromptSubmit", additionalContext that is not a string, or decision or continue of another kind or word | failure (bad_output) |
| any other exit, signal, timeout, start failure | failure, as above |
- Context reaches the model as a text block after your prompt, framed
Context from the user's hook "keywords":and the text. Over 10,000 characters it is cut to the first 10,000 and a warning says so. Several hooks run in file order; if one blocks, none of their contexts is kept. - A blocked prompt never reaches the model, nor any later request. The panel and the plain prompt
print
✗ prompt blocked by hook "keywords": <reason>, or for a failure✗ prompt blocked: hook "keywords" failed (timed out after 5 s). The text stays in your input history (↑). In a session that has a prompt hook, a prompt whose turn was interrupted or killed before the model saw it is dropped the same way. - Context is text beside the prompt. It approves nothing: the gate is untouched.
Under -p a blocked prompt exits 1 (outcome prompt_blocked, headless.md). In
harness run it is not a failure of the session, as a denied call is not.
When a hook fails
A failure is anything the tables above call one. on_failure decides what it does.
on_failure = "block"(the default): the call or the prompt is blocked. For a call, the model reads a fixed message, not the hook’s output:the user's hook "no-force-push" failed (timed out after 10 s), so this call was blocked. This is not a policy decision: retrying or rephrasing the call will not help. Tell the user their hook is failing.A broken hook therefore stops every call it covers; harness never lets a broken policy pass unnoticed. A prompt that is blocked this way shows✗ prompt blocked: hook "keywords" failed (timed out after 5 s).on_failure = "continue": the hook is skipped and the next one runs. Use it for hooks that advise and do not enforce.
Either way you get one warning per hook per turn (on stderr as harness: … under -p):
⚠ hook no-force-push is failing (timed out after 10 s), so the calls it covers are blocked. Fix it, set on_failure = "continue", or restart with --no-hooks.
⚠ hook no-force-push failed (exited with code 1); on_failure = "continue" let the call through.
For a UserPromptSubmit hook the first reads … so your prompts are blocked. … and the second … let the prompt through.
The kinds read timed out after N s, exited with code N, printed output that is not hook JSON,
could not start: <error> and was killed by a signal. Esc during a hook stops it (its whole process
group) and ends the turn interrupted; the call does not run.
Where to keep hook files
Keep a hook’s program and any file it reads outside the project, and outside any folder the agent can
write. An agent that can edit its own policy hook has no policy, so at start harness resolves
command[0] and each later element that names an existing file or folder (relative paths from the project
root; an argument that does not exist but is shaped like a path, with a / and no spaces, is checked
against the writable folders too, since the agent could create it; the value after an option’s first =,
as in --rules=hooks/r.yaml, is checked the same way; an empty value, --exclude=, names nothing and is not
checked), and refuses to start the session when one lies in:
- a folder the agent can write: the project, a linked worktree’s common directory, the private temp
folder,
[sandbox] writable, and on macOS the user temp folder. Under bubblewrap the box’s/tmpis the agent’s, so a sandboxed hook’s path under/tmpis refused; without a sandbox the system temp folders (/tmp, and on macOS/private/tmpand$TMPDIR) count; - for a hook with
sandbox = truein a session with a sandbox, a path the sandbox hides (sandbox.md): the sandboxed hook could not read it.
Each path, command[0] included, is checked as written and where it leads: every link on the way is
followed, one whose target does not exist yet included, and a link or folder on the way that sits inside a
folder the agent can write counts too, since the agent could re-point it.
The refusal names the hook, the path and the fix:
hook no-force-push: /home/dana/proj/hooks/check.sh is in the project, so the agent could change it. Move it outside (for example ~/.local/share/harness/hooks/), or start with --no-hooks.
hook no-force-push: /home/dana/.claude/check.sh is in a folder the sandbox hides (/home/dana/.claude), so the hook could not read it. Move it, or set sandbox = false for this hook.
hook no-force-push: check.sh was not found.
~/.local/share/harness/hooks/ is a good place: it is the data root, read-only to sandboxed commands. A
command[0] with no / is looked up on PATH once, at start; one with a / must be absolute. The check
is best effort: a path inside a ["sh", "-c", "…"] string is not seen, and a value that only looks like a
path (--header=Content-Type:application/json) is refused as one. A sandboxed hook runs with the project as
its working directory, so it can still be steered by a bare relative name that was absent at start
(["python3", "hook.py"]) or by an interpreter string (-c, -e, -m: python3 -c "import json" loads a
json.py from there). Such a hook can only decide its own block, never reach the host.
Sandbox, or the host
sandbox = true(the default): the hook runs in the same box asbashandexec, in the project root: it can read widely, write only where commands can, has no network and no provider key.sandbox = false(or any session with no sandbox): the hook runs on your machine with your user’s access, from/, with every provider key removed from its environment. Use it for a hook that needs the host (a desktop notification, a policy server).
A host hook runs from / so that nothing it or its interpreter loads by a relative name comes from the
project. Its input’s cwd still names the project; a host hook that needs it goes there first:
cd "$(jq -r .cwd)". In a session with a sandbox, a host hook’s PATH also loses every empty or relative
entry and every entry inside a folder sandboxed commands can write (the project, a linked worktree’s common
directory, the private temp folder, [sandbox] writable, and on macOS the user temp folder), so a program
the agent plants there is not found by name. An entry that cannot be traced to where it leads (a link to
nowhere yet) goes too, and if none is left the hook runs with no PATH at all: name its programs by absolute
path. With no sandbox PATH is unchanged: the agent’s commands can write anywhere.
A host hook that runs project files (cargo, npm test, a repo script) runs code the agent can write.
Keep such hooks sandboxed, or have them run only files outside the project. The banner marks a host hook
on the host.
A hook should not write files. Its writes are not tracked on their own, so /undo may not undo them, and
a host hook’s write by a relative name fails at /.
Turning hooks off: --no-hooks
harness run --no-hooks and harness resume <id> --no-hooks run that segment with no hooks. The banner
reads hooks: off (--no-hooks), and the log records it. A later resume without the flag runs the hooks
again. It is the way out of a hook that is failing closed.
What is recorded
- The definitions:
session.startedandsession.resumedcarry each hook’s name, event, command, tools, timeout,sandboxandon_failureas the session ran them, and whether--no-hookswas given. - Each run is an effect with the hook’s input digest and its output, so a replay serves it and starts nothing.
- Each block, failure, interrupt or skipped run is a
hook.decidedrecord with the hook, the tool, the reason or the failure kind. A run with no objection leaves only its effect. - Context is a
hook.contextrecord, kept in the log and shown in transcripts and exports as· hook keywords added context (171 characters). harness session --transcript, a resume’s replayed rows and the exports show the same rows as the live panel, plus· hook <name> failed (<kind>); the call went ahead(the prompt went aheadfor a prompt hook) for a failure thatcontinuelet through.harness sessions --jsongivesoutcomeprompt_blockedfor a session whose last turn was a blocked prompt, and the/sessioncard counts them.
harness eval loads your hooks from your user config and runs them for each case’s prompt and tool
calls, as -p does. A case whose prompt a hook blocked ends prompt_blocked: it fails an
expect.outcome.in of ["completed"] and can expect ["prompt_blocked"].
A crash resume (resuming a session that was killed) keeps the hooks its log recorded, which its
hooks: line names, and runs a recorded hook only if today’s config still defines it exactly as recorded: same event, command, tools,
timeout, sandbox and on_failure. Otherwise it is skipped as no objection, with a warning
(⚠ hook no-force-push is not run in this session: it is changed in your config since the session was recorded. Resume cleanly to use today's hooks.). A log can never make harness run a program your config
does not name. A clean resume reads today’s config and records what applies from then on. A session killed
while a hook ran says so on resume: This session was killed before bash ran. It never ran. or This session was killed while a prompt hook ran. The prompt was not sent.
Example: refuse force-pushes
[[hooks]]
name = "no-force-push"
event = "PreToolUse"
tools = ["bash", "exec"]
command = ["/home/dana/.local/share/harness/hooks/no-force-push.sh"]
timeout = 10
#!/bin/sh
# Reads the call as JSON on stdin. Exit 2 blocks; stderr is the reason the model reads.
input=$(cat)
case "$input" in
*"git push"*"--force"*|*"git push"*" -f"*)
echo "force-push is not allowed here" >&2
exit 2 ;;
esac
exit 0
This reads the raw JSON as text, which is enough for a demonstration and easy to route around (see below).
A hook that needs the fields parses the JSON (python3, jq).
Example: a hook for MCP tools
An MCP server’s tool is called through the mcp tool, so cover it with tools = ["mcp"]:
[[hooks]]
name = "mcp-audit"
event = "PreToolUse"
tools = ["mcp"]
command = ["python3", "/home/dana/.local/share/harness/hooks/mcp-audit.py"]
The input’s tool_input is {"server": "github", "tool": "create_issue", "arguments": {…}}.
Example: Ouroboros’s keyword detector
Ouroboros ships a UserPromptSubmit hook, keyword-detector.py. It
reads the JSON above and, for a prompt such as ooo seed my requirements, answers with
hookSpecificOutput.additionalContext holding a <skill-suggestion> block that names a skill
(/ouroboros:seed). It suggests the skill and loads nothing; the model decides. Harness accepts that
JSON as it is. Ouroboros’s hooks are written to exit 0 whatever happens, so they fail open on their own.
Copy the script to ~/.local/share/harness/hooks/ (outside the project) and add:
[[hooks]]
name = "keywords"
event = "UserPromptSubmit"
command = ["python3", "/home/dana/.local/share/harness/hooks/keyword-detector.py"]
timeout = 5
on_failure = "continue"
on_failure = "continue" keeps an advisory hook from blocking your prompts if Python is missing.
Ouroboros’s other hooks (SessionStart, PostToolUse) have no harness event yet. Its skills load through
[skills] extra_roots (config.md), and a skill the
model loads in auto runs without a question.
Differences from Claude Code
| Claude Code | harness | |
|---|---|---|
| where hooks are defined | settings files, including the project’s | your user config only |
| command | a shell string | an argv; a shell only if you write one |
| where it runs | on your machine, unsandboxed | in the sandbox by default; sandbox = false for the host |
| working directory | the project | the project in the sandbox; / on the host (the input’s cwd names the project) |
| when a hook fails | the call goes ahead | the call is blocked (on_failure = "block"); "continue" to let it through |
| which calls | matcher patterns | tools, harness’s tool names; no matcher |
| tool names and arguments | Bash, Edit, Write; file_path, old_string, new_string | bash, edit_file, write_file; path, old, new, content |
ask, allow answers | can grant or force a question | no objection: the gate decides as if no hook ran |
| events | many | PreToolUse, UserPromptSubmit |
| files the hook reads | anywhere | outside the project and outside any folder the agent can write |
The envelope’s field names and the answer formats are Claude Code’s. A ported script must change any tool
or argument name it matches on: one that tests for Bash or file_path finds nothing here and objects to
nothing, silently. A script that only reads tool_input.command ports as it is.
Hooks are not a security boundary
A policy hook sees one call at a time and can be routed around: a rule against git push --force does
not see git push origin +main, a script in the repo that pushes, or the same push spelled through an
interpreter. The sandbox is the boundary. Use hooks for your own conventions and guardrails, and keep
secrets and destructive reach out of the agent’s hands with the sandbox, the modes and the hard-deny list
(modes.md, sandbox.md).
Shortcuts
The full-screen panel runs when stdin and stdout are both a terminal. These are its keys. In the panel, /shortcuts
or ? on an empty input shows them, so a message cannot start with a typed ?: type a space first, or paste it.
Session
| Keys | What it does |
|---|---|
enter | send the message |
esc | interrupt the turn |
ctrl-c | clear the input |
ctrl-c ctrl-c | quit |
shift+tab | cycle the mode |
ctrl-g | write in your editor |
? | these shortcuts (empty input) |
/shortcuts | these shortcuts, as a command |
During a turn
| Keys | What it does |
|---|---|
enter | steer, or ask on the side |
tab | send it after the turn |
esc | stop the turn |
esc (side) | stop only the side reply |
↑ | edit a queued message (empty) |
Moving
| Keys | What it does |
|---|---|
← → | a character |
ctrl+← ctrl+→ | a word (alt+← alt+→ too) |
alt-b alt-f | a word back, forward |
ctrl-a ctrl-e | the line’s start, end |
home end | the input’s start, end |
↑ ↓ | lines, then history |
Editing
| Keys | What it does |
|---|---|
ctrl-j | new line |
alt+enter | new line |
shift+enter | new line (if reported) |
backspace | delete back |
delete | delete forward |
ctrl-w | delete the word before |
ctrl-u | delete to line start |
ctrl-k | delete to line end |
ctrl-o | expand collapsed pastes |
Menus
| Keys | What it does |
|---|---|
/ | commands (at the start) |
@ | pick a file |
↑ ↓ | move the highlight |
tab enter | accept |
esc | close the menu |
The plain prompt (piped input)
| Keys | What it does |
|---|---|
ctrl-c | interrupt the turn; when idle, quit |
ctrl-d | quit (end of input) |
Commands for the start screen, updates and leaving (the panel and the plain prompt):
| Type | What it does |
|---|---|
/notices | everything the start did not print: attention, about this start, privacy, status |
/update | install the newest release without leaving the session (esc cancels before the swap) |
/restart | switch to the installed binary and keep the conversation; waits for the turn to end |
/quit, /exit | save the session and leave (/exit is the same as /quit) |
exit, quit | the same as /quit when harness is idle; during a turn, while /update runs or a harness update check is pending, they are an ordinary message |
harness update | caught when idle: offers /update instead [Y/n] (it is a terminal command) |
The typed words match the whole line, ASCII case-insensitively, with nothing before them ( exit and exit now
are messages). The harness update question answers only while the line is empty; any other key closes the question as no and is typed as usual (so /quit then Enter quits), and with text already typed keys go to the line (Esc still closes the question). A line queued (with Tab, or with Enter while /update or a check runs) is always a message, however it reads.
Some keys work only in some places:
- Menus: typing
@opens the file picker; a/at the start opens the command menu. An open menu takes ↑/↓, Tab, Enter and Esc, and the first Esc closes it instead of interrupting. - Pickers:
/modeland/modetake every key and name their keys in their title rows. In/modeland its thinking step, Enter switches and saves the choice as the default for new sessions; Tab switches for this session only (config.md). Ctrl-C always closes them. - Approvals: while an approval’s choices are on screen, only its own keys and Ctrl-C work. A typed reply uses the
editing keys. The main agent’s questions and its sub-agents’ queue in one line, shown one at a time in the order
asked. Esc answers the one on screen, as
ndoes. - A long tool call: once a tool call has run for 2 seconds (time spent waiting at an approval does not count), Enter
on a message asks it on the side, as well as steering it into the turn. A read-only side session answers from what
the main agent knew when the call started. It can read and search files but never runs a command, asks for
approval or changes a file. Its answer appears under the running call when it finishes and joins the conversation
at the call’s end, without starting a turn. While the model is writing, with an approval open, or while a side
reply is running, Enter steers as before. Tab queues the message for after the turn, as always. The status line
hints the choice (
enter asks on the side · tab after turn · esc stops). - Esc during a side reply stops only the side reply, and the message still joins the turn; a second Esc stops the turn. With the agent idle, Esc stops a running side reply together with the sub-agents.
- Sub-agents: during a turn, Esc stops the turn and the sub-agents keep running. With the agent idle and no question on screen, Esc stops every sub-agent, including one still waiting to start (config.md).
- Quitting with sub-agents running waits up to 10 seconds for them to stop; Ctrl-C leaves at once after the first half second.
When a key does not arrive
The panel sees only the keys your terminal sends. A terminal, tmux or screen can keep a key, or send a different one.
tmux and screen prefixes. A multiplexer takes its prefix key first. With the tmux prefix on C-a, Ctrl-A (start
of line) reaches the harness only when pressed twice, given bind C-a send-prefix in ~/.tmux.conf. GNU screen’s
default prefix is also C-a; C-a a sends a literal Ctrl-A. For any key a tmux or screen binding owns, rebind it
there or use another key for the same action.
Esc and Alt in tmux. After an Esc, tmux waits escape-time for an Alt sequence (500 ms by default, per the tmux
3.4 man page), so Esc can feel slow. set -sg escape-time 10 shortens the wait.
Shift+Enter. Most terminals send it as plain Enter. It gives a new line only where the terminal reports the
Shift, and under tmux only if tmux forwards it (extended-keys). Ctrl-J always gives a new line; Alt+Enter usually
does.
macOS terminals. Reported by users and vendor docs; not checked on a Mac.
- Ghostty, and iTerm2 with its “Natural Text Editing” preset, send Cmd+←/→ as Ctrl-A/Ctrl-E and Option+←/→ as Esc-b/Esc-f, which the harness reads as Alt-b/Alt-f. Under tmux, Cmd+←’s Ctrl-A is subject to the prefix note.
- Terminal.app sends neither by default; set the keys in Settings → Profiles → Keyboard.
- Alt-b/Alt-f need Option to act as Alt:
macos-option-as-alt = truein Ghostty, or “Esc+” for the Option key in iTerm2’s profile keys. Otherwise Option types a character (∫, ƒ).
The plain prompt. When stdin or stdout is not a terminal, the harness reads whole lines and the terminal, if
any, does the editing. The harness handles only Ctrl-C and end of input; /shortcuts there says so.
config.toml reference
config.toml lives in ~/.config/harness/, or in $HARNESS_HOME/config/ when that is set. Every
table is optional; no file means defaults. A malformed file stops the harness at load: bad TOML or an
unknown key names the file and line, a refused value names the file. Flags override the file.
macOS uses the same XDG folders as Linux (~/.config/harness, ~/.local/share/harness,
~/.local/state/harness, ~/.cache/harness), never ~/Library/Application Support. Each
$XDG_*_HOME variable moves its own root. harness paths shows every root and what set it:
$ HARNESS_HOME=/tmp/scratch harness paths
CONFIG /tmp/scratch/config (HARNESS_HOME)
DATA /tmp/scratch/data (HARNESS_HOME)
STATE /tmp/scratch/state (HARNESS_HOME)
CACHE /tmp/scratch/cache (HARNESS_HOME)
RUNTIME /tmp/scratch/runtime (HARNESS_HOME)
On macOS, /tmp and /private/tmp (and /var and /private/var) count as one project.
Top-level settings
model = "claude-opus-5" # default: chosen at start, see below
thinking = "medium" # off | low | medium | high | xhigh | max — default: medium
max_tokens = 32768 # default: each model's own output limit, else 32768
max_iterations = 32 # default: 32
mode = "manual" # manual | plan | auto | a [modes.<name>] table — default: manual
check_for_updates = true # false: no update check, no report, no notice — default: true
telemetry = true # false: no reports, the update check stays on — default: true
check_for_updates and telemetry are described under Updates.
max_tokens caps one reply’s output, thinking included. Unset (and with no --max-tokens), each
model gets its own output limit, so a /model switch or a sub-agent on another model gets that
model’s: the provider’s own model list, else the catalog, else 32768. A mega: model takes the mega
model list’s figure as it is (mega:full sends 65536), since the server knows what it serves. Set, it
applies to every model as written.
When a reply hits the cap. A reply that stops at the output cap with no tool call, whether it spent the cap on thinking or was cut mid-answer, is continued automatically: up to 3 tries in all, the reply and two continuations. Each continuation tells the model its reply hit the limit and asks it to carry on from where it stopped, with no apology or recap, in smaller pieces if much remains. The transcript shows a dim line before each:
↻ The reply hit the output limit. Continuing (2/3).
If the second try is all thinking, the third runs one thinking level lower (high to medium; low
and off stay) and its line adds it: Continuing (3/3) with medium thinking. The next request after the
streak returns to your level. Esc stops the continuing. After the third try the turn ends truncated, with
the warning and the advice (ask more narrowly, lower the thinking level, or raise the cap). Each try is a
recorded model call and counts against max_iterations; the session’s log records the try number on each
continuation’s request, and cap_tries on the start and each resume; a log written by an older version replays as it was. A reply cut
inside a tool call is a different case and is retried as before.
model takes provider:model, a legacy bare id (claude-opus-5, gpt-5.6-sol, an OpenRouter
vendor/model), or an @name nickname.
run --mock ignores it and --model (the model is mock).
Saving a pick. In the /model picker, Enter switches and saves the model and thinking level for
new sessions; Tab switches for this session only. On a model with a thinking control, either key
opens a thinking step first, taking the same keys. /model <id> acts like Enter, keeping the
thinking level. On the plain prompt, with a terminal on stdin, /model <id> on a model with a
thinking control first asks for one of the six levels: Enter keeps the model’s default, a level word
sets it, Ctrl-C cancels the switch; piped input asks nothing. /model thinking <level> changes and
saves only the current model’s level. The line under the switch reads
saved as your default for new sessions, this session only; new sessions start on <model> · <level>,
or not saved: <reason> (the switch still holds). Mock and child sessions never save. On a model
with no thinking control only the model is saved, and /model thinking saves nothing.
How a new session’s model is chosen. harness run takes the first that applies:
-
--model. A missing key then stops the harness, naming the variable; a signed-outmega:model stops it withYou are not signed in to mega. Type /login. -
Whichever of the saved pick (
model.jsonin the state folder) and this file’smodelchanged last, if it can answer: its key is set or, formega:, you are signed in. -
The other of the two, if it can answer.
-
If signed in to mega:
mega:full. -
The first provider below with a key, on its default model. Your own
[providers.*],ollamaandlmstudiocount only in steps 2 and 3 (the last two need no key).provider default model anthropicclaude-opus-5openaigpt-5.6-solgeminigemini-3.7-flashopenrouteranthropic/claude-opus-5togetherzai-org/GLM-5.3deepseekdeepseek-v4-prozaiglm-5.3xaigrok-4.7
A blank key variable counts as unset. A skip in step 2 or 3, or a pick in step 5, is explained in a
line under the banner (stderr with --prompt). A saved pick whose provider table is gone is skipped;
a broken model.json is ignored with a line naming it. To forget a pick, delete model.json or edit
model. An unknown provider or nickname in model or --model stops start-up, even if a newer
saved pick would win.
With nothing found, run on a terminal opens on model (or anthropic:claude-opus-5) and says
No model yet. Type /login for free models, or set your own key. With --prompt, or stdin not a
terminal, it prints No model yet. Run harness login for free models, or set your own key. and
exits 1. If the only key belongs to a provider with no default model, the warning says to choose one
(--model lab:<model> or model = "lab:<model>").
Thinking is --thinking, else the level saved for the chosen model (unless this file’s thinking
changed since), else thinking. resume keeps the log’s model unless --model is given;
harness eval uses only --model or model.
mode is the starting mode: manual, plan, auto, or a [modes.<name>] table
(modes.md). An unknown name stops the harness:
mode = "nope": unknown mode "nope" — expected one of: manual, plan, auto, or a [modes.nope] table (<path>)
The order is --mode, --away (auto), this key, then manual. If the sandbox cannot start,
--mode auto exits 2 but mode = "auto" starts in manual and says why. resume keeps the log’s
mode; harness eval always starts in manual. Put the key above the first [table] header. Older
versions reject it, so remove it before downgrading.
Asked once. With no mode here and the judge’s key set (TYPESAFE_API_KEY, or the variable
[providers.typesafe] api_key_env names), harness run with a terminal on stdin, stdout and stderr
first asks:
Start in auto by default? The judge (TypeSafe) sees each command and your latest message. [y/N]
y or yes writes mode = "auto", anything else mode = "manual", as one line above the first
[table] header (or at the end), through any symlink, touching nothing else; that run uses the
answer. -p, resume, --mode and --away never ask. If the line cannot be written safely (the
file has several hard links, or changed meanwhile), the harness shows the line to add by hand and
asks again next time. This is its only write to this file.
[providers.<name>] — provider ids and endpoints
Every model id is provider:model, such as anthropic:claude-opus-5. Legacy bare ids still work,
and always will, for the four original providers: claude-*, gemini-*, gpt-*, and any id with
/ (OpenRouter).
Ten providers are built in; a key alone reaches each:
| provider | key variable | base URL | dialect | needs a key |
|---|---|---|---|---|
anthropic | ANTHROPIC_API_KEY | https://api.anthropic.com/v1 | Anthropic | yes |
openai | OPENAI_API_KEY | https://api.openai.com/v1 | Responses (default) | yes |
gemini | GEMINI_API_KEY | https://generativelanguage.googleapis.com/v1beta | Gemini | yes |
openrouter | OPENROUTER_API_KEY | https://openrouter.ai/api/v1 | Chat Completions | yes |
together | TOGETHER_API_KEY | https://api.together.ai/v1 | Chat Completions | yes |
deepseek | DEEPSEEK_API_KEY | https://api.deepseek.com | Chat Completions | yes |
zai | ZAI_API_KEY | https://api.z.ai/api/paas/v4 | Chat Completions | yes |
xai | XAI_API_KEY | https://api.x.ai/v1 | Chat Completions | yes |
ollama | (none) | http://localhost:11434/v1 | Chat Completions | no |
lmstudio | (none) | http://127.0.0.1:1234/v1 | Chat Completions | no |
Of the originals, openai accepts base_url (a gateway) and api, openrouter only base_url,
and anthropic and gemini neither. A table can move any of the other six to a gateway or rename
its key; api_key_env gives ollama or lmstudio one. For a Z.AI key exported as
ZHIPU_API_KEY, set [providers.zai] api_key_env = "ZHIPU_API_KEY".
A table with any other [a-z0-9-]+ name adds a provider (examples):
[providers.<name>]
base_url = "https://example.com/v1" # required outside the built-in list
api_key_env = "MY_KEY_VAR" # optional; absent sends no Authorization header
api = "chat_completions" # chat_completions (default) | responses
model = "some/model-id" # an extra id /model lists; not a switch
models_dev = "fireworks-ai" # models.dev provider id; false for none
models_dev links a table to a models.dev provider ("fireworks-ai", "wafer.ai") for model
descriptions only: search names, context windows, prices, featured rows, the model list. Without it,
a table matches the provider of its own name, unless that provider’s API is on another host than
base_url (an explicit id skips this check). It applies after the next
refresh;
[models.context] still wins. Only your own tables accept it.
A table naming a built-in overrides only the fields it sets.
Refusals, at load, naming the table and file:
- A bad name:
[providers.<name>]: a provider name is lowercase letters, digits and hyphens (<path>). - A new name with no
base_url:[providers.<name>]: base_url is required for a provider outside the built-in list (<path>). - A key an original cannot use:
[providers.<name>]: base_url is not used by <name> — remove it (<path>), or the same forapi. models_devon a built-in:[providers.<name>]: models_dev is not used by <name> — its models.dev link ships in the binary; remove it (<path>); ontypesafeor asystem_onetable:… — it answers the judge, not chat; remove it (<path>).- Any other
models_dev,trueincluded:[providers.<name>]: models_dev is a models.dev provider id (lowercase letters, digits, '.' and '-', at most 64 bytes) or false (<path>).
An unregistered provider is refused at startup, in /model and in a child spawn, with your own
tables’ names added:
no provider named "<name>" — add [providers.<name>] to config.toml, or use one of: anthropic, openai, gemini, openrouter, together, deepseek, zai, xai, ollama, lmstudio.
typesafe is a built-in provider for [judge] model only (modes.md); /model never
lists it.
[gatekeeper] and a mode’s gatekeep, the old names, are still read.
Behind a TLS-inspecting proxy: SSL_CERT_FILE
The harness trusts only the certificate authorities compiled into it, so an HTTPS-inspecting proxy
(a corporate gateway, a sandbox’s egress) fails with transport error. Set the environment variable
SSL_CERT_FILE to a PEM file with the proxy’s CA (or your system bundle) to trust it as well,
for every provider, the judge and remote MCP servers. Unset or empty changes nothing. An unusable
file (unreadable, not a regular file, over 16 MiB, or no usable certificate) stops a session or
judge score at start, naming the file.
Free models: mega
With no API key, sign in for two free models: /login in a session, or harness login. It prints
a link and a code; open the link on any device and sign in with Google. /login while signed in
replaces an expired or revoked sign-in, or else says who is signed in.
| Model | Weight against your daily allowance |
|---|---|
mega:flash | 1× |
mega:full | about 2.7× |
The allowance is per account per UTC day, set by the server, and shown as a percentage, never in dollars:
- The status bar (and the line REPL’s per-turn line) ends
N% of today. A signed-in start saysSigned in as <email> · today N% used, resets 00:00 UTC(no model: the status bar has it), orSigned in as <email>before the first usage is known. - At 80%, one notice per session and day; on
mega:fullit adds thatmega:flashlasts about 2.7× longer. - At 100% the turn stops:
Today's free allowance is used up. It resets at 00:00 UTC, in <h> h <m> m.andTo keep going now, use your own key: e.g. set GEMINI_API_KEY, then /model to select the model. /sessionshowsfreeand the model that answered:mega:full (gemini-4-argon).
The harness never switches you to your own key. Signed in, a new session can start on mega:full
(the order); sub-agents use mega:flash unless [agents] model is set.
- The token is in
$CONFIG/.secrets/accounts.json, 0600 in a 0700 folder: never printed, logged, put in a URL or exported, and hidden by the sandbox. If.secretsis a link, nothing is saved and you read as signed out. A token works only with the endpoint that issued it. /logout(orharness logout) revokes the token, then deletes the file.- Errors. After 7 days unused, or once revoked:
Your mega sign-in expired. Type /login.No answer after retries:mega is unreachable. Your own keys are unaffected.Daily sign-up cap:Today's pilot sign-ups are full. Try again tomorrow, or use your own key. [providers.mega] base_url, its only key, points at a test server:https://…, orhttp://onlocalhost,127.0.0.1or::1.- The judge can use this account (
modes.md).
The quiet start and /notices
A start prints the splash, the identity line and the hints, plus the questions that must be answered before it
(the auto-mode question, harness resume’s cross-project question), a picker resume’s Session {id} loaded,
the recorded recovery notices, and the signed-in line (Signed in as <email> · today N% used, resets 00:00 UTC, which quiet_startup still hides). Every other thing a start used to print is a note.
- The notice line. In the panel, below the splash, one line types the notes one at a time (a typewriter, then a
pause) until you send a message or press a key, then clears. Notes about a standing condition you have already
seen in this project within 90 days are not typed again; warnings (marked
⚠) are typed at every start while they hold. The plain prompt prints one line after the banner: the first note and(+N more · /notices).-pand stream-json print what they printed before. /noticesprints all of them in four sections: Needs your attention, About this start, Privacy, Status (the update, the signed-in facts, and after a/restartwhat changed). Notes seen at an earlier start sayseen. Opening it marks them seen. The status bar shows⚠ Nwhile warnings hold andM notesuntil you first open/notices.- Seen is kept per project in
$STATE/notices-seen.json. Delete the file and every note is new again. - The privacy disclosures (the daily usage counts, a judging mode’s reading of your work) are not printed at start;
/noticesalways lists them in full, with their policy links.
[ui] quiet_startup = true (default false) hides what it hid before: the splash and identity block, and the
signed-in line. For an update it hides only the arrival note; the status item and /notices stay. The notice line
and /notices ignore it. [ui] animations = false (default true) turns off the working spinner (the status row
shows the static ⏳, as it does with NO_COLOR, TERM=dumb and a pipe) and the typewriter: the notice line
shows each note whole at once.
The spinner. On a colour terminal the status row’s working indicator is three braille cells that turn once
every 0.8 s (15 frames a second), red and blue ramped to the theme’s background (truecolor, nearest of 256, or the
plain 16-colour reds and blues). When a turn ends normally the three cells close in a short pulse of green and
then give way to thought for Ns or ready; an interrupted or failed turn shows no pulse. The plain prompt draws
the same frames without colour and no close.
Usage counts while signed in
Signed in to the free models, harness sends one more thing, about once a day: counts of how you used it, tied to
your account. It is on by default. Signing in prints a one-line form, and a start that finds you already signed
in does not print it: /notices lists the full sentence under Privacy, every start, with the policy link
(the quiet start).
harness sends daily usage counts (never prompts, files or commands) · /notices
/notices holds the full sentence, which names the switch:
harness sends daily usage counts while you are signed in: no prompts, files or commands. Turn them off with telemetry = false in config.toml.
telemetry = false or DO_NOT_TRACK (see
Updates) turns it off, and so does check_for_updates = false; /logout stops it and deletes its state, $CACHE/usage-summary.json. Days spent signed out are
never sent.
When. From an interactive start, in the background, about once a day: each whole UTC day from the day you
signed in (or, if you were already signed in when you updated, the day harness first started after the update) up
to yesterday, at most the 30 most recent. Today is never sent. A send that fails, or that the server does not
acknowledge, is tried again at the next interactive start, at most every 10 minutes, and its days go with it.
The counts are read from your session logs, so a day of -p runs is sent at your next interactive start.
Days while reports are off are never sent: a start with reports off moves past them, and signing in with
reports off sends nothing. The one exception is a day of only -p or other non-interactive runs with reports
off and no interactive start in between, which is counted at the next interactive start with reports on.
The counts include sessions on your own API keys while you are signed in, and on the day you sign in, that day’s sessions from before it. The keys themselves, and what you sent through them, never go.
What a day holds, every count a whole number:
| Count | What it is |
|---|---|
| sessions, resumes | sessions you started and resumed |
| turns | turns that ended, by how: completed, interrupted, hit the output cap, failed |
| continuations | tries made after a reply hit the output cap |
| tokens | input and output, per provider and model |
| limit hits | model calls refused because the free allowance was used up |
| errors | by kind: rate limit, server, timeout, other |
| crash recoveries | crashes found when a session was resumed |
| approvals | asked, allowed, denied and timed out, per mode (manual, plan, auto, custom) |
| tool calls | per built-in tool; MCP calls and skills as one count each; any other name as other |
| features | sub-agents, side questions, hooks run, /undos, compactions |
| sandbox | sessions started with it on, off, unavailable or external |
| active minutes | whole minutes inside turns, less time waiting on an approval; not how long a session sat open |
The harness version and the platform are counts too: the sessions you started that day on each version and on
each platform, so a day with an upgrade shows both versions, not one value a later report overwrites.
Sub-agents’ and side questions’ tokens, tool calls and features are counted with the rest; --mock and
imported sessions are not counted.
What never leaves your machine with the counts: a prompt, a reply, a file, a path, a command, error text,
a session id, the install id, or a name you chose. A provider that is not one of the eleven built in is
custom, a model the bundled catalog and the free models’ list do not know is other, and your MCP servers,
skills, hooks, agents and custom modes are counted, never named.
The request is POST /v1/telemetry to the mega endpoint with your sign-in token, and the server keeps the
counts per account per day. The anonymous daily report above is separate and does not carry your account.
Your sign-ins and upgrades. Beyond the counts, the server keeps these about your account, with no deletion date unless said:
- each sign-in (your account id and email address, the time, the harness version that signed in) and each sign-out (the time);
- each sign-in token’s record: a hash of the token (never the token), your account id and email address, when it was made, when it was last used (updated at most once an hour) and whether it was revoked;
- each step of a browser login, with its time. A login that was never approved or refused leaves only its first step, deleted after 30 days. One that was approved, refused or finished keeps its later steps, which carry when it started, and, from approval on, your account id;
- each upgrade of your harness, sent when it happens, once you have seen the usage-counts line: the previous and new version, the platform and the time (Updates).
The server writes the sign-in, token and login records itself, as you sign in and out and as the token is
used (its last-used time is rewritten at most once an hour), so telemetry = false does not stop them; the
upgrades follow the switches. Your upgrades are also recorded under the install id, so the two records of
one upgrade can be matched by version and time. The page for the free models is
the privacy page.
[models] — favourites, context overrides, nicknames
[models]
favourites = ["claude-opus-5", "together:zai-org/GLM-5.3-Flash"]
[models.context]
"myhost:qwen3-30b" = 131072 # overrides the catalog's context window for this id
[models.nicknames]
executor = "together:zai-org/GLM-5.3-Flash"
reviewer = "deepseek:deepseek-v4-pro"
Each favourites id must route to a provider (checked at load). A context id need not, so it can
describe a model the catalog lacks.
A nickname is [a-z0-9-]+ and maps to an id of a registered provider, never another @nickname
(checked at load). @name works wherever an id does: --model, model =, /model @name and a
helper definition’s model: line. The agent tool’s model argument accepts only nicknames,
never a raw id.
An undefined @name is refused in the warning colour. A parent model that spawns on one reads this
(the second line only when a helper definition named it):
@reviewer is not defined — add it under [models.nicknames] in $CONFIG/config.toml
(named by $PROJECT/.agents/agents/reviewer.md)
Do not retry; tell the user.
The banner warns once per helper file that names one
(<definition path> names @reviewer, which is not defined — add it under [models.nicknames] in <config path>),
and /agents marks its row … · model @reviewer (not defined).
The model catalog’s daily refresh: refresh_catalog and HARNESS_MODELS_DEV_URL
[models]
refresh_catalog = false # never ask models.dev; unset means on
Interactive sessions (under run or resume) refresh the model catalog from
models.dev in the background into $CACHE/models-dev.json: at most
daily, or sooner when one of your tables is added, removed, or changes its models_dev or
base_url host. Nothing waits or prints. It runs only for a hosted provider: key set, API not on
this machine (localhost, 127.0.0.1, ::1). It never runs under -p/--prompt, --mock,
eval, judge, check, replay, import, export or bundle. refresh_catalog = false stops
it; an existing copy is still used. The copy never changes routing.
The environment variable HARNESS_MODELS_DEV_URL reads another address, such as the mirror
https://models.opencode.ai/api.json. It must be https://, or http:// on loopback; anything else
is ignored. An address nothing answers on disables the refresh.
Providers outside the built-in list
Any OpenAI-compatible endpoint works through [providers.<name>]. These blocks are untested
starting points copied from each provider’s docs; check the URL and key variable before relying on
one. /model <word>, for a models.dev provider you have not set up, prints a hint headed by your
config file’s path: the matching block below for the six keyed ones here, or for any other a block
with placeholders for its API URL and key variable (plus models_dev when its table name differs
from its models.dev id).
# Fireworks AI
[providers.fireworks]
base_url = "https://api.fireworks.ai/inference/v1"
api_key_env = "FIREWORKS_API_KEY"
models_dev = "fireworks-ai"
# Groq
[providers.groq]
base_url = "https://api.groq.com/openai/v1"
api_key_env = "GROQ_API_KEY"
# Cerebras Inference
[providers.cerebras]
base_url = "https://api.cerebras.ai/v1"
api_key_env = "CEREBRAS_API_KEY"
# Moonshot AI (Kimi) — international endpoint; the fetched page names no separate China host
[providers.moonshot]
base_url = "https://api.moonshot.ai/v1"
api_key_env = "MOONSHOT_API_KEY"
models_dev = "moonshotai"
# Hugging Face Inference Providers — a router over many vendors, not a single host.
# Model ids carry the HF namespace, e.g. openai/gpt-oss-120b, and may take a
# :fastest / :cheapest / :preferred suffix the router resolves server-side.
[providers.hfrouter]
base_url = "https://router.huggingface.co/v1"
api_key_env = "HF_TOKEN"
models_dev = "huggingface"
# Vercel AI Gateway — a router over many vendors; model ids are vendor/model,
# e.g. anthropic/claude-sonnet-4.5.
[providers.vercel]
base_url = "https://ai-gateway.vercel.sh/v1"
api_key_env = "AI_GATEWAY_API_KEY"
A self-hosted server needs no key: omit api_key_env and no Authorization header is sent.
# vLLM (self-hosted) — the docs' own example address; yours may differ.
[providers.vllm]
base_url = "http://localhost:8000/v1"
# llama.cpp's llama-server (self-hosted) — its documented default host:port.
[providers.llamacpp]
base_url = "http://127.0.0.1:8080/v1"
A worked example: three roles, one nickname table
An orchestrator on the session’s model hands work to two helpers on cheaper providers, through nicknames and a skill.
config.toml:
[models.nicknames]
executor = "together:zai-org/GLM-5.3-Flash"
reviewer = "deepseek:deepseek-v4-pro"
.agents/agents/executor.md:
---
name: executor
description: Implements a small, well-specified change.
model: "@executor"
---
Make the change exactly as scoped. Do not expand it. Report back a short summary of what changed.
.agents/agents/reviewer.md:
---
name: reviewer
description: Reviews a diff for correctness against the spec.
model: "@reviewer"
---
Review the change for correctness only. Report PASS or FAIL and why.
.agents/skills/handoff/SKILL.md, loaded when a task fits:
---
name: handoff
description: Carries the orchestrator through a build-then-review handoff between two child agents.
---
1. Spawn `executor` with the scoped task.
2. Spawn `reviewer` with the executor's summary and the original task.
3. Report the reviewer's verdict.
Both definitions pin a model, so the orchestrator’s agent call need not name one (and could name
only @executor or @reviewer). In a resumed --mock session, /agents shows each helper, where
it was found (this repo) and what its nickname resolves to:
this resumed session is running WITHOUT the approval gate — every tool call, including any skill it loads, runs unprompted
executor — Implements a small, well-specified change. (this repo) · model @executor → together:zai-org/GLM-5.3-Flash
reviewer — Reviews a diff for correctness against the spec. (this repo) · model @reviewer → deepseek:deepseek-v4-pro
Note on Together’s cheapest listed model
The examples use together:zai-org/GLM-5.3-Flash, not the cheaper openai/gpt-oss-20b, which
Together does not serve serverless: --model together:openai/gpt-oss-20b can fail. The harness does
not filter a provider’s list by serverless availability.
[sandbox] — mode, env_pass and --sandbox
How the sandbox works is in sandbox.md.
[sandbox]
mode = "external" # "on" | "off" | "external"; absent: on, where the sandbox can start
env_pass = ["GITHUB_TOKEN"] # exact variable names; used only under "external"
mode is on (commands run sandboxed), off, or external (the machine is the boundary: a
VM, container or CI runner; sandbox.md, “External mode”). Absent, it is on where
the sandbox can start (sandbox.md, “Requirements”); elsewhere the harness says why
and fails closed. enabled = true or false still means on or off. Setting both keys so they
disagree is an error:
[sandbox] mode = "external" and enabled = false disagree: set one of them (<path>)
--input-format stream-json on run and resume holds the session open for a program over stdin and
stdout (headless.md, “A conversation”).
--sandbox <on|off|external> sets the mode for one session on run, resume and eval,
over mode and enabled (a disagreeing file is still refused). eval --target refuses it:
the argument '--target <NAME>' cannot be used with '--sandbox <MODE>'
To move a session to or from external, /quit and resume; a resume after a crash keeps the
recorded mode. No environment variable sets the mode.
env_pass names variables that commands keep under external despite the credential rule,
such as GITHUB_TOKEN: exact, case-sensitive names, not patterns or NAME=value. It does nothing
under on and off. Provider keys are always removed, so listing one is an error in any mode:
[sandbox] env_pass: "GITHUB_*" must be one exact variable name (letters, digits and _), not a pattern or NAME=value (<path>)
[sandbox] env_pass: OPENAI_API_KEY is a provider key and is always removed (<path>)
Where [sandbox] may be set. A repository can pick your config by setting HARNESS_HOME or
XDG_CONFIG_HOME (direnv, a devcontainer, CI). A config found that way whose real path is inside the
project, or cannot be resolved, has its [sandbox] table refused; the rest loads:
[sandbox] cannot be set from <path>: it was found through HARNESS_HOME inside the project
(or XDG_CONFIG_HOME). Keep such a config outside the project. The default ~/.config/harness, a
HARNESS_HOME outside the project and an XDG_CONFIG_HOME of ~/.config are unaffected. The
start-up notice and session record name the mode and what set it: --sandbox, the config file, and
any HARNESS_HOME or XDG_CONFIG_HOME.
[checkpoints] — pictures before commands, and /undo
How pictures and /undo work is in checkpoints.md.
[checkpoints]
enabled = true # a session's first state; /checkpoints on|off changes it mid-session
max_file_mib = 100 # a file over this is recorded, never copied
max_store_gib = 5 # the whole store, per project
keep_days = 14 # older pictures are cleared
max_picture_files = 10000 # files one picture copies
max_picture_mib = 500 # bytes one picture copies
max_picture_seconds = 10 # walk, hash and copy together
max_walk_entries = 100000 # entries one walk lists
skip = [] # gitignore-syntax patterns, rooted at the project
Absent keys take the defaults shown; an unknown key is an error. A project at your home folder or
/ never takes pictures. A file over max_file_mib shows no copy (over 100 MiB). max_store_gib
counts copies, records and temporaries; at the cap the oldest pictures go first. Files past one
picture’s budget show no copy (picture budget) and later pictures copy them. A folder past
max_walk_entries is “not checked”.
skip adds patterns to .gitignore (or to the built-in list, without git). A pattern that does
not parse, or that git reads differently, is an error: a NUL, ***, ** right after leading text
in a pattern with a / before its end (a**/b, /build**), or a [:name:] class.
Ranges: max_file_mib 1–102400, max_store_gib 1–1024, keep_days 1–3650, max_picture_files
1–10000000, max_picture_mib 1–102400 and at most max_store_gib (after defaults, so raising one
alone can trip it), max_picture_seconds 1–3600, max_walk_entries 1–100000000. Out of range is
refused at load:
[checkpoints] max_file_mib must be between 1 and 102400, got 0 (<path>)
[checkpoints] max_picture_mib must not exceed max_store_gib, got 6000 MiB against 5 GiB (<path>)
[checkpoints] skip pattern "[z-a]" is invalid: <reason> (<path>)
[mcp.servers.<name>] — MCP servers
MCP servers add tools: a local program (command, stdio) or a remote endpoint (url, Streamable
HTTP). Their tools are called through the mcp tool, ask before every call unless in allow,
and show in /mcp. Remote setup: mcp.md.
[mcp.servers.github]
command = ["docker", "run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"]
env = ["GITHUB_PERSONAL_ACCESS_TOKEN"] # names only; values never enter the log
sandbox = false # Docker isolates it; its socket cannot enter the box
allow = ["get_issue", "search_issues"] # these calls never ask
[mcp.servers.docs]
command = ["npx", "-y", "some-docs-server"]
network = true # npx downloads on first start
read = ["~/.nvm"] # node lives under the hidden home folder
[mcp.servers.remote-github]
url = "https://api.githubcopilot.com/mcp/"
token_env = "GITHUB_PAT" # sent as `Authorization: Bearer <value>`
tools = ["search_issues", "get_issue"] # only these are offered
| Key | Type | Default | Meaning |
|---|---|---|---|
command | array of strings | — | stdio: program and arguments (exactly one of command, url) |
url | string | — | http: the endpoint (https, or http to loopback) |
env | array of names | [] | variables passed to the server (not HOME, TMPDIR, PATH) |
read | array of paths | [] | stdio, sandboxed: read-only paths in the box (~ expanded; each must exist) |
token_env | name | — | http: the variable holding the token |
token_header | string | Authorization | http: the token’s header (Bearer prefix only for Authorization; never a protocol header) |
network | bool | false | stdio: the box gets the network |
sandbox | bool | true | stdio: run inside the box |
tools | array of names | all | only these tools are offered |
allow | array of names | [] | these tools never ask |
enabled | bool | true | false keeps the table but starts nothing |
startup_timeout | seconds | 30 | spawn, probe and first listing together |
call_timeout | seconds | 300 | one call |
Timeouts are whole seconds. network, sandbox or read on an http server, token_env on a stdio
server, and any unknown key are load errors.
- Secrets go in
envortoken_env, which log only the variable’s name.commandandurlare logged verbatim, so aurlwith a user name or password (https://user:pass@…) is a load error. envdoes nothing on an http server; onlytoken_envreaches it.- A sandboxed server sees the project read-only, with no home folder, a stripped environment plus
env, and no network unlessnetwork = true. To write the project it needssandbox = false, which is recorded and shown. - A program under your home folder (
~/.nvm, a Python virtualenv) is hidden in the box. The failure line ends· its program is under your home folder: add the folder to read, or set sandbox = false. - Only your user config can define servers. A config found through
HARNESS_HOMEorXDG_CONFIG_HOMEinside the project fails:[mcp.servers] cannot be set from <path>: it was found through <VAR> inside the project. - A server that fails to start leaves the session running without its tools, with one transcript
line (
mcp: docs failed to start: timed out after 30 s · /mcp for details);/mcpsays why. Asandbox = trueserver fails the same way when the sandbox is unavailable, never running unboxed. /mcpshows what the session’s start listed, and the calls since. A resumed session does not list again, so a server restarted by a later call keeps its row from the start.--no-mcp, onrunandresume, starts no MCP server for that session: themcpandmcp_schematools are not offered and the banner readsmcp: off (--no-mcp)when your config defines a server. A resume keeps the mode its log recorded, so the model may still trymcp; the call fails withmcp: MCP servers are off for this run (--no-mcp). The log records the flag.
[[hooks]] — hooks
Hooks are your commands that run before a tool call (PreToolUse) or when you send a prompt
(UserPromptSubmit) and can block either. Everything about them, with examples, is in
hooks.md.
[[hooks]]
name = "no-force-push" # required; unique; 1–32 of a-z, 0-9, "-", "_"
event = "PreToolUse" # "PreToolUse" or "UserPromptSubmit"
tools = ["bash"] # PreToolUse only; omitted = every tool
command = ["/home/dana/.local/share/harness/hooks/no-force-push.sh"] # argv; no shell
timeout = 10 # whole seconds, 1–600; default 30
sandbox = true # default true
on_failure = "block" # "block" (default) or "continue"
| Key | Type | Default | Meaning |
|---|---|---|---|
name | string | — | required; unique; 1–32 of a-z, 0-9, -, _ |
event | string | — | required; "PreToolUse" or "UserPromptSubmit" |
command | array of strings | — | required; the program and arguments, run without a shell |
tools | array of names | every tool | PreToolUse only: the harness tools the hook covers (mcp for an MCP server’s tools) |
timeout | whole seconds | 30 | 1–600; one run, start to finish |
sandbox | bool | true | false runs the hook on your machine, with no provider key |
on_failure | "block" or "continue" | "block" | what a failed hook does: block the call or prompt, or let it through |
- Hooks run in the order listed; the first to block ends the run.
- Only your user config can define hooks. A config found through
HARNESS_HOMEorXDG_CONFIG_HOMEinside the project is refused for[[hooks]], as for MCP servers. - Any other key, including Claude Code’s
matcher, is a load error. A hook’s program and files must live outside the project and any folder the agent can write, or the session refuses to start (hooks.md). --no-hooks, onrunandresume, runs that session with no hooks. The banner readshooks: off (--no-hooks)and the log records it; a later resume without the flag runs them again. It is the way out of a hook that is failing closed.
Sub-agents in the background: [agents] background
[agents]
background = false # default true: the panel runs sub-agents in the background
In the panel, an agent call returns once its children start, and you can keep talking to the main
agent. Their rows stay under the transcript; with the agent idle the status line reads
2 sub-agents running · esc stops them.
- A result is a message. A finished child’s result reaches the main agent as a message, marked by
a line such as
↳ explorer finished. It starts a turn if the agent is idle, or goes in after the tool calls already requested if it is busy. A stopped child starts no turn; the next turn reads its result. - Away waits. A turn running while you are away (
/away) ends only after its last child, taking each result as it arrives./awayalso reaches running children: each refuses its questions from then on. - Quitting stops the children and waits up to 10 seconds for each to close and record its result; Ctrl-C leaves at once after the first half second. A child still running is closed when the session ends; its own log keeps what it did.
- Only in the panel, and not while away.
-p,eval, the plain prompt and an away parent run anagentcall to the end before going on. A session records which way it ran, so replay follows it; a resume restates it unless it is a crash resume.
Updates: the hourly check and harness update
An installed harness (not one built from source) checks for a newer release when you start an interactive
session (the panel or the plain prompt, from run or resume) and every hour while it is open. Nothing is
inserted into the transcript. The status bar shows one item:
0.29.0 available · /update: a release newer than the one running and the one installed is out.0.29.0 installed · /restart: a newer one is already installed, here or by another session.
The first time the item shows a version, the notice line types one note for it (once per version per project at a
start, once per version per session after that), and /notices lists the update under Status. The plain prompt
prints one line, · harness 0.29.0 is out: type /update to install it., once per version. quiet_startup
suppresses the arrival note, not the status item or /notices. Both stay off in a source build and with
check_for_updates = false.
/update installs the release without leaving the session. It does what harness update does, below, and
shows each step as it finishes: the signature, the download (with a bar when the server states the size), the
checksum, the new binary running and saying its version. It then says harness 0.29.0 is installed. This session still runs 0.28.0. The panel is busy while it runs: messages queue, Esc cancels it before the swap, and a second
/update says An update is already running. If nothing newer exists it says so plainly.
/restart switches the session to the installed binary and keeps the conversation: at the first idle moment
(after the turn and the queue; with no sub-agent, side session, compaction, sign-in or update running) it checks
<binary> --version, closes the session as /quit does, and starts harness resume on the same session id with
the flags this process was started with that a resume does not restore (--mock, --sandbox, --no-hooks,
--no-mcp, --max-tokens, --splash). Mode, approvals and model come back from the log. /quit cancels a
waiting restart. When it cannot go through:
- the pre-flight
--versionfails:⚠ Cannot restart: <reason>. Nothing changed.and the session goes on; - the old session does not close cleanly: no restart, and the usual exit with its report and resume hint;
- the exec itself fails: the error, the resume hint, and exit 1;
- the new binary refuses the resume (a config, credential or sandbox error): it prints its own error and exits, and the session is saved with its resume hint.
The new process types Now on harness 0.29.0 (was 0.28.0) and /notices Status
holds what changed.
Typing harness update at the prompt is caught when harness is idle: it echoes the line and says it is a terminal
command. With a newer release known it offers to run /update instead [Y/n], or says /restart if that version
is already installed. With none known it prints Checking for a new version… and makes one read of the site’s
latest (Esc or Ctrl-C cancels it), then asks the question, says harness X is up to date., or, if the read
failed, That is a terminal command; /update installs updates from here. (Could not check now: …). A source
build prints /update’s refusal. Even with check_for_updates = false this one read is made, because you asked
for it; it writes no state.
A successful install writes a receipt at $CACHE/install-receipts.json (the path, version and file identity).
A session never runs a binary to learn what is installed; it reads the receipt, and counts it only while the
file at that path is still the one the receipt describes. An install by install.sh or a package manager writes
none, so a session learns of it only from the check (/restart still switches to it).
The hourly check is one plain read of the install site’s /dl/latest: no body, no install id, no token. A
failed read is silent. It never runs in -p, harness eval, --mock, harness update or a source build.
harness update reads that release’s signature and checks it against the two release keys built into this
harness before it downloads the archive. It then downloads the archive, checks its SHA-256 against the
signed one, tries the new binary with --version, and only then replaces the old one. It refuses a binary
built from source and a release whose signature is missing or does not check, and never overwrites on a
failed step (… ; nothing installed). Once the new binary is in place it sends its upgrade to our server
(below). Sessions already running keep the file they started from; /restart (or a quit and harness resume) switches one to the new file.
Signed releases. From 0.26.0 each release’s archives are signed in CI with an OpenSSH Ed25519 signature
that covers the version, the platform and the archive’s SHA-256, so a signature cannot be moved to another
release. A missing signature stops the update with cannot download …/harness-<platform>.tar.gz.sig (404 Not Found); nothing installed. One that is damaged, made by another key, or made for another version, platform
or archive stops it with the signature of harness-x86_64-unknown-linux-musl.tar.gz does not check (<why>); nothing installed. There is no unsigned fallback and no way to change the keys at run time;
HARNESS_DOWNLOAD_BASE, which moves the site, can only point harness update at signed releases.
- Releases before 0.26.0 are not signed and are not back-signed, since
harness updateonly moves up. A 0.24 or 0.25 harness does not check signatures, so its update to 0.26.0 rests on the checksum alone; the first signature a harness checks is the one on the release after the 0.26.0 it runs. install.shchecks the same signature when the machine’sssh-keygencan verify one (OpenSSH’s-Y verify): always for the newest release, and for a pinnedHARNESS_VERSIONof 0.26.0 or later. Without a capablessh-keygenit says so and installs on the checksum.HARNESS_REQUIRE_SIGNATURE=1makes it install only a release whose signature it checked: it stops instead, and it refuses a pinned version before 0.26.0.- The
latestpointer itself is not signed: someone who can write the bucket or the site can holdharness updateon an older signed release, but cannot make it install an unsigned one.install.shcomes from the site itself, so its check guards against a bucket-only compromise; the installed binary then checks every update.
Two switches, both top-level keys above the first [table] header:
check_for_updates = false # no check, no report, no notice
telemetry = false # no reports; the update check stays on
check_for_updates = falsestops everything: no check, no report, no event, no line, and nothing reads or writes the state file, the install id or the events file, with one exception below. In a source build,-p,harness evalor--mockthe same holds.telemetry = falsestops all the reports (the daily one below, the install and upgrade events, and the signed-in counts): none is sent and no install id is made. The update check and its line stay on. ADO_NOT_TRACKin the environment that is not empty or0counts astelemetry = false.- The one exception: signing in or out empties any upgrade copy already waiting for your account in the events file, whatever the switches say, so a copy queued before you turned reports off is never sent later under another account. It does nothing when there is no events file. When there is one, it takes the file’s lock (a file in the state folder, made if missing) and writes the events file only if it holds such a copy. It sends nothing.
harness update is separate: when you run it, it reads the install site (the newest version, the archive,
its checksum and its signature); it records the newest version in the state file only while the check is on,
and sends its upgrade event only while reports are on. Older versions refuse a file that has
check_for_updates or telemetry, so remove them before downgrading.
What the daily report sends. Once a day, at an interactive start, one request to our server (the mega
endpoint, POST /v1/update-check), with four fields. The server’s answer names the newest release, which
makes that start’s check; when it does not, the start reads the install site.
| Field | Example | Meaning |
|---|---|---|
version | 0.24.0 | the running version |
platform | x86_64-unknown-linux-musl | the build’s target: that, aarch64-apple-darwin or x86_64-apple-darwin |
install | 32 hex characters | a random id made on the first report and kept in $DATA/install-id (harness paths) |
sessions | 12 | interactive starts since the last report we acknowledged |
It never sends a prompt, a file name, a project path, a command, an API key, your account id or email, or
an Authorization header: signed in to the free models or not, the request is the same. The install id
survives updates; delete the file to get a new one. The id travels in the request body, never in the URL.
Install and upgrade events. Two more messages, each sent when the thing happens, not with the daily
report. installed is sent when this harness makes its install id. upgraded is sent when harness update
has put a new version in place, and at an interactive start that finds a different version than the one it
last ran (an install made again with install.sh, or a binary copied by hand; its time is that start, not
the copy). The first start of a 0.26.0 over an older harness only notes the version it runs and sends
nothing; our server notices that change itself, from the next daily report’s version.
| Field | Example | Meaning |
|---|---|---|
install | 32 hex characters | the install id, once per message |
kind | upgraded | installed or upgraded |
from | 0.26.0 | the version before; absent on installed |
to | 0.26.1 | the version now |
platform | x86_64-unknown-linux-musl | the build’s target |
at | 1761480000 | the time, in Unix seconds by your computer’s clock |
The message is POST /v1/install-events to the mega endpoint, with no token. Events wait in
$DATA/install-events.json (harness paths, mode 0600), in two lists of at most 32, one for the install id
and one for your account (below); a 33rd drops the oldest. A list that holds events is sent at every
interactive start with reports on, and by harness update after it has put a release in place, whether or
not the daily report is due. The server’s acceptance, or its refusal of a malformed message, removes what was
sent; any other failure keeps it for the next try. An event older than 30 days is dropped unsent. The same
switches as the daily report stop all of it (above): with reports off nothing is made, sent, read or written,
apart from the one exception there, and events already waiting stay in the file until reports are back on.
The next upgrade after that is recorded from the version last noted.
If you are signed in to the free models and have seen the usage-counts line, each upgrade is also sent
under your account: POST /v1/account-events, with your sign-in token and no install id. Signing out and
signing in clear any copy still waiting, so one never goes out under another account. This is where the
install id and your account meet: not in one request, but in two records of the same upgrade that can be
matched by their versions and time.
Who sees what.
| Who | Sees | Keeps |
|---|---|---|
| our server | the four fields or an install or upgrade event, and your IP address as the connection’s | daily and monthly counters with no id and no IP (checks, sessions, versions, platforms, installs); per install: one record of its current state (the install id, first and last day seen, version, platform), deleted about 400 days after the last day seen; one record for each day it reported and each version it reported that day, and one for each install and each version change (the version before, when there is one, the version now, platform, time to the second), each deleted about 400 days after its own day or time |
| our server, when you are signed in and have seen the usage-counts line | an upgrade event with your sign-in token, and your IP address | a record of each upgrade under your account id (from, to, platform, time), with no deletion date |
| our server’s own request log | time, route, status, duration, and on a signed-in route your account id | in Google Cloud Logging, under its retention |
| Google Cloud Run’s request log | IP, user agent, path, time | in Google Cloud Logging, under its retention |
| the install site’s host, Vercel, and its storage, AWS: the hourly check, a start with no report due, and a start whose report did not name the newest release | IP, user agent, time | under their own retention, which we have not confirmed |
Both logs on our side go to the project’s Cloud Logging _Default bucket, whose retention is 30 days by
default, configurable by the project’s owner (Google’s Cloud Logging quotas page, docs.cloud.google.com/logging/quotas, read 2026-10-23). The
page does not name Cloud Run, and a project can change the bucket or its routing, so treat 30 days as the
default, not a promise. Our server’s request log never holds a request body, so the install id is not in
it. Our code stores no IP address, but for those 30 days Google’s request log keeps each request’s time and
IP, so a record can be tied to the address that sent it; the daily report’s install record has allowed that
since 0.24.0. The one longer log is our server’s per-call usage lines for the free models and the judge (an
account id, the model, token counts, the cost and the time), which are also kept 90 days in a log bucket of
their own. harness update reads the site’s /dl/latest, the release’s checksum and signature, and the
archive directly: Vercel and AWS see it as they see an install.
Users on their own API keys reach our server too, once a day, with those four fields, and on an install
or an upgrade with one event more, and the install site every hour. No key or prompt goes with any of them.
Set telemetry = false to stop the report and keep the check, or check_for_updates = false to stop both.
The page for the free models is the privacy page.
Everything else config.toml accepts
| table | keys | what it’s for |
|---|---|---|
[ui] | theme, animations (false: a static ⏳ instead of the spinner), quiet_startup (the quiet start), title, colors, truecolor, probe, index_all, editor, splash, tool_icon (default ●), tool_output_lines (default 8, max 100), thinking ("full" default, "preview", "off"; /thinking changes it for a session; the old name reasoning is still read, and a table with both is refused) | display only |
[modes.<name>] | tools, ask, judge, plan_file, posture | overrides a built-in mode field by field, or adds one (modes.md) |
[judge] | model, cutoff (default 0.2, 0.0–1.0), away_cutoff (default 0.2, 0.0–1.0) | the assessor for judged bash/exec calls; the cutoff at which a command asks; the one used while you are away or after a task’s breaker trips (the same by default; lower asks more) (modes.md) |
[protected] | add, remove | edits the built-in protected-path list; writes there always ask (modes.md) |
[sandbox] | mode ("on", "off", "external"), enabled, writable, hide, env_hide, env_pass, escape ("ask" default, or "never") | command confinement; user config only (above, sandbox.md) |
[away] | timeout (default "15m", "off", or Ns/Nm/Nh up to 24h) | how long an approval question waits before it is refused, where /away can start (not manual); user config only (sandbox.md). Under --input-format stream-json it is also the ceiling for an unanswered approval_request, in every mode, with the same consequences as a timed-out question in the panel (headless.md) |
[skills] | extra_roots (up to 16, each ~/… or absolute) | extra folders of <name>/SKILL.md, searched after the project’s, ~/.agents/skills and the built-ins, which win a name clash; /skills marks them “extra folder N”; a session keeps its starting roots |
[agents] | max_concurrent (default 4, min 1), model (provider:model or @nickname, checked like --model; refused with nothing after the :), thinking (any --thinking value), background (default true) | how many children run at once; a child’s model and thinking when its definition and the spawn call name none, before the parent’s (mega:flash under a signed-in mega parent). No default thinking. background = false makes the panel wait for each agent call (above) |
[compaction] | trigger_percent (default 80, 50–95) | context percentage that triggers compaction |
[resume] | replay_rows (default 2000; 0 replays nothing) | terminal rows a resume draws into the scrollback first: the newest whole turns that fit, with a ⋯ N earlier turns line naming harness session <id> --transcript. Terminal only, whatever quiet_startup says |
[[hooks]] | name, event, tools, command, timeout, sandbox, on_failure | commands that run before a tool call or a prompt and can block it; user config only (above, hooks.md) |
[script] | enabled | turns the script tool on or off in every built-in mode (absent: each mode’s default, off in all); [modes.<name>].tools wins |
[targets.<name>] | command, cwd, env, timeout_secs (default 120, max 86400) | an agent under test, for eval --target |
Recipe: the old auto
To make auto ask before every bash and exec call, like manual, turn off its judge and add
both to ask. Adding skill as well makes it ask before loading a skill, which auto otherwise does
without asking:
[modes.auto]
judge = []
ask = ["bash", "exec", "skill"]
To keep auto as it is but have it ask before a skill loads, as it did before 0.24.0:
[modes.auto]
ask = ["skill"]
Nothing else about auto changes. A session keeps the mode its log recorded, so a session resumed from
before 0.24.0 still asks.
harness -p: one prompt, one turn, then exit
-p runs one turn for a script, a pipe or another agent, and exits with a code the caller can trust.
Flags
-p [TEXT], also--promptor--print. With no value, the prompt is stdin.- Works on the root and on
run, with everyrunflag. --output-format text|json|stream-json, defaulttext.--verbose: the live transcript on stderr, even if stderr is not a terminal.- Exit 2:
--verbosewithout-p;--output-formatwithout-por--input-format stream-json;--output-formatwith-p --mock(a mock run already prints JSON; a conversation takesstream-jsonbeside--mock); an empty prompt, after stdin is joined. - For a session a program holds open — many turns, gates it answers — see a conversation below.
Stdin
- Read only if stdin is not a terminal.
cat log | harness -p "why does this fail?": the message is stdin, a blank line, then the value. With a value,-pwaits up to 3 s for stdin’s first byte or end of input, then runs without stdin and says so on stderr. Redirect from/dev/nullto skip the wait.git diff | harness -p: stdin is the prompt, with no time limit. No value and stdin a terminal: exit 2.- Over 8 MiB, not UTF-8, or unreadable (a directory, a device error): exit 2, before a session is made. Unreadable
prints
harness: could not read stdin: <error>. A closed stdin (<&-) is no input.
Text output
| stdout | stdout gets | stderr gets |
|---|---|---|
| a terminal | the live transcript | notices and errors |
| a pipe or file | the answer and a newline, if the turn answered | notices and errors; the transcript too if stderr is a terminal or with --verbose |
A turn answered if the session ended cleanly with outcome completed or tool_failed; the answer is the
model’s last response text. When a reply hit the output cap and the harness continued it, the answer is the text
of every try in that last streak, joined in order with no separator. Calls that failed on the way are then named on stderr:
harness: 2 tool calls failed in this turn: read_file, bash (approval refusals have their own line). Otherwise
stdout is empty and stderr says why: the session’s failure, else the words of a blocked prompt, else the model’s
error (a 429, a bad key), else a “did not complete” line. Trust stdout exactly when the exit code is 0:
out=$(harness -p "summarise CHANGELOG.md" 2>log) || { cat log; exit 1; }
The output cap
A reply that stops at the output cap with no tool call is continued, as in an interactive session
(config.md): up to 3 tries in all, so a -p run can make two more model
calls than it used to, each counted in model_calls and usage. The third try runs one thinking level lower
when the second was all thinking. The ↻ The reply hit the output limit. Continuing (2/3). line goes with the
transcript, so it shows on a terminal and with --verbose, and not on a piped run with a quiet stderr. A turn
still cut after the third try ends truncated (exit 1, no answer). In stream-json each try is its own
reasoning and assistant records; result holds the joined answer. harness eval continues the same way:
a case that used to end truncated can now finish, and output_contains reads the turn’s answer, the joined
text, not only the last reply.
Approvals
- Somebody can answer if stdin is a terminal, the prompt is not from stdin, and the transcript is on a terminal.
Gated calls then ask, as in
harness run. - Nobody can answer otherwise. A gated call is denied; the model gets “I’m not at a terminal to approve this, so
it did not run. Don’t retry it; finish with what you can and say what you could not do.” as its result, and the
turn goes on. Stderr gets
harness: refused <tool> (needs approval, nobody to ask): <summary>. --approve-alland--awaywork as inharness run. An--awayrefusal is a failed call: the answer prints and stderr names the call.
Hooks
-p runs the hooks in your user config (hooks.md); --no-hooks turns them off.
- The banner’s
hooks: …line and the root session’s hook warnings go to stderr (a sub-agent’s hook warnings show on the panel only). A warning readsharness:where the panel’s has its warning sign:harness: hook no-force-push is failing (timed out after 10 s), so the calls it covers are blocked. Fix it, set on_failure = "continue", or restart with --no-hooks.A conversation (--input-format stream-json) prints them the same way onrunandresume, a sub-agent’s included. - A call a hook blocks is a failed call: the model reads why, the turn goes on, and stderr names it with
the other failed calls. It is not in
denied, which lists calls that needed approval. Instream-jsonit is atool_resultwithstatusfailed; there is no separate hook record. - A prompt a hook blocks never reaches the model: exit 1,
outcomeprompt_blocked,is_errortrue. Text output prints nothing on stdout andharness: prompt blocked by hook "keywords": <reason>on stderr, or for a hook that failedharness: prompt blocked: hook "keywords" failed (timed out after 5 s).errorin the JSON formats is the same text withoutharness:.
Exit codes
| Code | Meaning |
|---|---|
| 0 | the turn answered, even if a tool call failed (outcome tells which in JSON) |
| 1 | no answer (model_failed: a model error, or two replies in a row with no text and no tool call; max_iterations; interrupted; truncated; prompt_blocked: a prompt hook blocked the prompt), or a failure after start (a missing key, a failed session, an unwritable stdout) |
| 2 | a refusal or argument error (bad flags, a refused stdin, an empty prompt) |
Ctrl-C and SIGTERM interrupt the turn at its next checkpoint; the turn ends interrupted (or model_failed with
error: "interrupted: " when the signal lands on the model call), exit 1.
A failed write to a piped or redirected stdout (a full disk, a closed pipe) fails the run in every format: stderr
gets harness: could not write to stdout: <error>, exit 1. In the JSON formats a failed turn’s error precedes
that line on stderr. stream-json writes nothing after the failed line, then exits 1. Not covered: a terminal that
closes mid-run.
--output-format json
One object on stdout when the run ends, nothing else. Stderr gets notices, errors, the transcript with --verbose,
and failed calls as in text output (stream-json likewise). After the session starts, a failure goes in error,
not on stderr. Neither JSON format runs the background-colour probe ([ui] probe).
{"type":"result","v":1,"session_id":"01M4JAEP0CST02MMP62FGKJ823","outcome":"tool_failed","is_error":false,"result":"I could not run the tests: running commands needs approval here.","error":null,"model":"openai:gpt-5.6-sol","duration_ms":4210,"model_calls":2,"usage":{"input_tokens":3100,"output_tokens":700,"cached_input_tokens":null,"reasoning_tokens":210},"denied":[{"tool":"bash","summary":"cargo test"}]}
| Field | Meaning |
|---|---|
type, v | "result"; the format version, 1 |
session_id | the session; its log is <session_id>.jsonl in the sessions folder |
outcome | completed, tool_failed, model_failed, max_iterations, interrupted, truncated or prompt_blocked; null if the turn never ended |
is_error | false exactly when the turn answered |
result | the answer; null if is_error |
error | set exactly when is_error: the session’s failure, else a blocked prompt’s words, else the model’s error, else the “did not complete” line |
model | the session’s model |
duration_ms | turn start to end; 0 if it never ended |
model_calls | the turn’s model responses |
usage | the turn’s token totals; cached_input_tokens null if no reply gave a cache count, reasoning_tokens 0 if none did |
denied | calls in the session that needed approval and did not run (nobody to ask, denied at a question, refused under --away), each with its approval summary’s first line; includes calls inside a script; a child agent’s stay in its own log |
A failure before the session exists (bad flags, a missing key, a refused stdin) goes to stderr only, with its usual exit code. After that, exactly one result object prints, even if the stream closes early or the session fails after the turn.
--output-format stream-json
One complete JSON record per line, flushed as the session writes it, never a streaming delta. The last line is the
result object. Reasoning is always included, whatever [ui] thinking says.
{"type":"init","v":1,"session_id":"01M4JAEP0CST02MMP62FGKJ823","model":"openai:gpt-5.6-sol","mode":"manual","cwd":"/home/me/project","harness_version":"0.12.0"}
{"type":"user","text":"run the tests"}
{"type":"reasoning","text":"**Running the tests**\n\nI'll start with cargo test."}
{"type":"tool_use","id":"7EXJS7R3G160QVGQFXGZVM875N","tool":"bash","input":{"command":"cargo test"}}
{"type":"approval","id":"7EXJS7R3G160QVGQFXGZVM875N","tool":"bash","decision":"denied","reason":"I'm not at a terminal to approve this, so it did not run. Don't retry it; finish with what you can and say what you could not do."}
{"type":"tool_result","id":"7EXJS7R3G160QVGQFXGZVM875N","tool":"bash","status":"failed","error":"denied by approval gate: cargo test. The user said: I'm not at a terminal to approve this, so it did not run. Don't retry it; finish with what you can and say what you could not do."}
{"type":"assistant","text":"I could not run the tests: running commands needs approval here."}
{"type":"result","v":1,"session_id":"01M4JAEP0CST02MMP62FGKJ823","outcome":"tool_failed","is_error":false,"result":"I could not run the tests: running commands needs approval here.","error":null,"model":"openai:gpt-5.6-sol","duration_ms":4210,"model_calls":2,"usage":{"input_tokens":3100,"output_tokens":700,"cached_input_tokens":null,"reasoning_tokens":210},"denied":[{"tool":"bash","summary":"cargo test"}]}
type | Fields |
|---|---|
init | v, session_id, model, mode, cwd, harness_version; in a conversation also commands, resumed and, when the session has client tools, client_tools (their names, in order) |
user | text, kind (message, or steer for a steer that landed) |
reasoning | text: the reasoning blocks, joined by a blank line; omitted if empty |
assistant | text; omitted if empty |
tool_use | id, tool, input |
tool_result | id, tool, status (ok or failed), output or error |
approval | id, tool, decision, reason (when given) |
approval_request | a conversation only: id, tool_use_id, tool, kind, reason, escape, summary, input, choices, turn_grant_skips_kind; a sub-agent’s also session, agent |
response | a conversation only: id, command, ok, then disposition or data, or error (code, message) |
compacted | trigger (automatic, manual; null in an old log), pre_tokens (the last turn reply’s whole prompt before it; null under -p) |
result | as --output-format json, plus turn_id and prompt_id; in a conversation also cost |
idpairs atool_resultorapprovalwith itstool_use; it is never null. A call inside ascripthas notool_useline: itsapproval(a conversation only) carries the emptyiditsapproval_request’stool_use_iddid. A sub-agent’stool_uselines are not printed: itsapprovalcarries thetool_use_idof itsapproval_request.decision:allowed_once,allowed_for_turn,allowed_for_session(old logs only),denied,denied_and_stopped,timed_out,refused(under--away).- A call cut off by the reply’s output cap is never requested, so has no line; the model sees the failure and retries.
- Every line is one JSON object with U+2028 and U+2029 written as
\u2028/\u2029, so a reader that splits on Unicode line separators (Python’sstr.splitlines(), Node’sreadline) still sees one line per object. - Not projected: a child agent’s records (its
agentcall’stool_useandtool_resultcarry its work), or ascript’s calls and their approvals (deniedstill lists them).
The contract: types and fields are only ever added; consumers ignore unknown ones; removing or redefining one
bumps v. The same holds for a conversation’s commands: fields are only added, and a command type is never
removed.
A conversation: --input-format stream-json
harness run --input-format stream-json holds a session open for a program: stdin takes one JSON command per
line, stdout prints the stream-json lines above, every gate as an approval_request, and one result per
turn. harness resume <id> --input-format stream-json continues a session the same way. Stdin must be a pipe;
-p and --verbose cannot be combined with it, and --output-format other than stream-json is
refused (exit 2 each; stdout is already the whole transcript). --mock <file> is accepted
(below). Every other run flag keeps its meaning:
--approve-all means no approval_request is ever written; --away puts the first task away (its gates are
refused, get_state.away is true), and away being one task’s, the next prompt’s gates ask.
The first line on stdout is init, with commands (what this build takes) and resumed. Then, for every command,
exactly one response — written before anything the command causes:
{"type":"response","id":"fix-1","command":"prompt","ok":true,"disposition":"started"}
{"type":"response","id":"s9","command":"steer","ok":false,"error":{"code":"no_turn","message":"no turn is running; send prompt"}}
| command | fields | what happens | disposition |
|---|---|---|---|
prompt | text | starts a turn; during a turn, or a compaction sent while idle, it waits and is the next turn, never a steer | started, queued |
steer | text | joins the running turn at its next landing point, as a message typed mid-turn does; shown as user with kind: "steer", or dropped if the turn ends first | accepted |
abort | turn_id or prompt_id (optional, not both; null is absent) | interrupts the running turn at its next checkpoint; with a turn_id that is not the running turn’s, nothing; with a prompt_id, takes that prompt back from the queue, or interrupts the turn it opened; a prompt started and its turn not yet begun is interrupted at once, ahead of any command sent after the abort | interrupting, dequeued, idle, stale |
approval | id, decision, reason (optional) | answers the approval_request with that id; decision is one of its choices | answered |
compact | — | compacts the context; during a turn, after it; a quit cuts one sent while idle | done, with data.compacted (trigger, pre_tokens, as on its compacted line, which comes first) when this attempt wrote one, and no data when it declined; queued; error quitting when the session ended first. A queued compaction’s compacted line is never another compact’s data |
get_state | — | data: session_id, model, thinking, mode, away, turn ("idle" or {id, prompt_id}), open_requests, queued, context (last_prompt_tokens, window, percent, auto_compact_at_percent; the first three null until known) | — |
away | on | /away or its end, for the running task and every running sub-agent, or the next task; get_state.away says what was set, though the log records it at the next gate or the next task’s start | set |
client_tool_result | id, content, is_error | answers a client_tool_call (below) | answered |
quit | — | ends the running turn, then the session | quitting |
id is yours, echoed on the response and, for a prompt, on its turn’s result as prompt_id; null is no
id, and one that is not a string is refused (bad_argument) and not echoed. Errors never end the session: parse (not a JSON object; the command is parse), unknown_command, bad_argument (the message
names the field), no_turn, no_such_request, unknown_call, quitting (any command after quit), too_long (a line over
8 MiB, skipped whole), refused (away {"on": true} where the panel refuses /away; the message is the panel’s
line).
Approvals. Every gate the panel would ask about — the root’s and a sub-agent’s — is written as an
approval_request: id (what approval names; the approval.requested.v1 record’s own), tool_use_id (its
tool_use line; empty for a call inside a script), tool, kind (gate, protected, removal,
decode_and_run, credential, escape, mcp, client_tool, judge_flag, judge_unavailable), reason, escape,
summary, input (the call’s arguments), choices (allow_once, allow_for_turn, deny, deny_and_stop; a
full escape, an mcp call and a client_tool call offer no allow_for_turn, and answering one with it is bad_argument) and turn_grant_skips_kind (allow_for_turn then covers
the turn’s other calls of the tool, never another call of this kind). A sub-agent’s carries session and agent
too; answer it the same way. The answer’s reason reaches the model as The user said: …, and the decision prints
as today’s approval line — a sub-agent’s with the same session and agent.
kind follows the first that applies: the gate’s own reason (protected, removal, decode_and_run,
credential, or escape for a full escape); the judge (judge_flag, judge_unavailable), whatever the call asks
to escape to; an escape the judge did not decide (escape, with escape.kind network or outside); mcp;
client_tool; else gate.
A full escape (outside_sandbox: true) is never asked of a program by default: a conversation runs under
escape = "network_only" as --approve-all does, so the sandbox block tells the model no full escape is on offer,
network: true is still offered (asked in manual, judged in auto), and the program on the other end — which may
be a model with no person behind it — can never let a command out of the sandbox. --allow-escapes (on run and
resume, with --input-format stream-json only) restores escape = "ask" for a client whose requests a person
answers; a full escape then arrives as kind escape with escape.kind outside and no allow_for_turn. It is refused with --approve-all, which answers nothing. A
crash resume keeps the rule its log recorded: a program resuming a session recorded as ask must pass
--allow-escapes, and the flag on a session recorded as network_only is refused rather than ignored (exit 2, one
line each). An MCP call’s once-only approval is unchanged.
An approval_request nobody answers is timed out at [away] timeout (default 15 min; off waits for ever; a
session started with --options takes approval_timeout instead) with the panel’s consequences. Where /away
may start (approvals required, the sandbox on or external, and the mode’s ask holding neither bash nor
exec), the task goes away: the call is refused, the turn goes on, every later gate of that task is refused with
no request, and away ends with the turn. Elsewhere the turn ends interrupted. away {"on": true} puts the
running or the next task away under the same rule, else it is answered refused. {"on": false} ends an away
task.
Turns. One result per turn, the same object as above for that turn alone, plus turn_id, prompt_id and
cost; nothing else ends a turn. is_error says whether the turn answered. cost prices the turn’s model calls
as harness session prices a session: usd (the provider’s own figure where it reports one, else the catalog’s
list price; null when nothing could be priced), priced, unpriced and free (calls to mega) counts, bound
("upper" when every call was priced and none from the catalog past 200K input tokens, else null) and basis
(reported, list_price, mixed, or null). The root session’s calls since the last result: a compaction inside
the turn counts, and so does one between turns, in the next turn’s cost.
Ending. Closing stdin means “no more input”: the running turn finishes, the prompts still queued run in order,
each with its result, and the session ends, exit 0. A gate opened after that has nobody to answer it, so it is
refused at once with -p’s reason (the approval line carries it, decision: "denied") and the turn goes on.
quit and the first Ctrl-C (or SIGTERM) are the stop: the running turn is interrupted and prints its result
(outcome: "interrupted", or model_failed with error: "interrupted: " when the interrupt lands on the model
call), the queued prompts are dropped, the session ends, exit 0. A request still open is closed by that interrupt
as the panel’s Esc closes one: recorded as a denial with no reason and printed as approval with
decision: "denied", before the result (abort does the same to the turn’s open request). A second Ctrl-C or
SIGTERM exits at once, exit 1, without the result. Exit 1 also means a failure after the start (an unwritable
stdout, a failed session); exit 2 a refused flag.
# one turn, from a shell: closing stdin lets it finish
printf '%s\n' '{"type":"prompt","id":"1","text":"list the tests in tests/"}' \
| harness run --input-format stream-json --approve-all | jq -c 'select(.type=="result") | {prompt_id, outcome, result, cost}'
# two turns, the second steered
{
printf '%s\n' '{"type":"prompt","id":"1","text":"list the tests in tests/"}'
sleep 20
printf '%s\n' '{"type":"prompt","id":"2","text":"run them"}'
sleep 2
printf '%s\n' '{"type":"steer","text":"use pytest -x"}'
} | harness run --input-format stream-json --approve-all | jq -c 'select(.type=="result") | {prompt_id, outcome, result}'
The whole wire, both directions, is written down in
protocol/stream-json.schema.json (JSON Schema 2020-12; $defs/command is
what a program sends, $defs/line what the harness prints). The code’s lists (COMMANDS, LINE_TYPES,
ERROR_CODES in crates/cli/src/headless.rs), the schema and the conformance transcripts in sdk/conformance/
are held equal by a test, so a line added without its schema entry fails the build.
A client that answers gates reads each approval_request and writes {"type":"approval","id":…,"decision":…};
jq -c --unbuffered on stdout and a coprocess on stdin are enough. A complete one in Python, with a scenario per
feature group run against a real model, is examples/stream-json-client.
Client tools
A session started with --options can carry tools the program runs. When one is ready to run (after its gate),
the harness prints client_tool_call between the call’s tool_use and tool_result lines:
{"type":"client_tool_call","id":"7EXJS7R3G160QVGQFXGZVM875N","name":"search_trains","input":{"to":"Leeds","day":"2026-11-06"},"turn_id":"01M4…"}
{"type":"client_tool_result","id":"7EXJS7R3G160QVGQFXGZVM875N","content":[{"type":"text","text":"3 trains: 07:40, 09:10, 11:15"}],"is_error":false}
{"type":"client_tool_cancel","id":"7EXJS7R3G160QVGQFXGZVM875N","reason":"timed_out"}
idis the call’stool_useid (and itsapproval_request’stool_use_id). Answer once withclient_tool_result, a command like any other (oneresponse).contentis a list of{"type":"text","text":…}blocks and at most one{"type":"image","media_type":…,"data":<base64>}block; a plain string is one text block.is_error: trueis the result the model reads as a failed call. An image withis_error, two images, or an invalid image isbad_argumentand leaves the call open (answer it again, as an error). A late, repeated or settledidisunknown_call.client_tool_cancelis printed when the harness stops waiting, always before the call’stool_resultand the turn’sresult:reasonisinterrupted(anabort),quitting(quit, the first SIGINT or SIGTERM),timed_out(the tool’stimeout) orinput_closed(stdin ended with the call open). Cancelling does not undo what the program already did.- With no program to answer (stdin closed, a panel or REPL resume) a call ends at once with the error “no
program is attached to run
<name>”; if writing stdout failed, “the program’s connection failed”; past its timeout, “timed out after<t>”. - A tool with
ask: true, and every client tool inplanmode, first sends anapproval_requestof kindclient_toolwith the once-only choicesallow_once,deny,deny_and_stop. A client tool is never judged and never covered by a grant.--approve-allskips the gate. - A result line may be at most 8 MiB, as every line; a sender keeps its image and text under that.
Options: --options <file>
run --options <path> and resume <id> --options <path> read one JSON object (at most 1 MiB) and start the
session the way an SDK does (sdk.md): isolated from the machine’s own setup unless the file turns
a read back on. Only with --input-format stream-json; elsewhere it is exit 2. An unknown field, a wrong type
(JSON null included), an invalid value, or a field that repeats a flag given on the same command line is
exit 2 with one line naming the field, before anything is written.
Fields: model, thinking, mode (manual, auto, plan; default manual), sandbox (on, off,
external; default on), builtin_tools (the ceiling for the session’s life; [] offers none; to call an MCP
server mcp must be in it), client_tools (definitions: name, description, input_schema, ask,
timeout; at most 128), system_prompt ({"replace": …} or {"append": …}), project_instructions,
user_config, skills, agents, hooks (each default false; hooks needs user_config), mcp_servers
(the [mcp.servers] table’s shape), approval_timeout (default "15m", at most "24h", or "off"),
allow_escapes and max_iterations. What each switch suppresses is in sdk.md.
On a resume of an options session, the mode, builtin_tools, system_prompt, the isolation switches and
sandbox come from the log (giving one, or --sandbox, is refused); client_tools must equal the log’s,
and is required when the log has any; the MCP servers come from the log, and mcp_servers, if given, must
equal them, every one unchanged and no other (the SDK never gives it); model, thinking, max_iterations,
approval_timeout and allow_escapes may be given. resume --input-format stream-json needs a session id.
--mock in a conversation
run --mock <file> --input-format stream-json (and resume) is a live conversation whose model is the mock
file: no key, no network, the mode, sandbox, options and client tools real. --output-format stream-json beside
it is accepted. A sub-agent call ends with the error “sub-agents are not available in a mock conversation”. A
resumed segment answers from the start of the file it is given. Without --input-format, run --mock is the
ungated event dump as before.
The agent SDK
megacode-agent-sdk starts a harness session from your program, hands the model tools you wrote, and
streams back what it does. Use it to build an agent that is not a coding agent: a text-message assistant,
a support bot, a nightly job. One session is one harness process; the SDK speaks the
conversation over its pipes and hides the lines.
Rust shipped in 0.30.0; Python and TypeScript ship in 0.31.0, with the same shapes under the same name.
Examples below show Rust first, then Python and TypeScript. Python names are snake_case and its calls are awaited in
asyncio; TypeScript names are camelCase and its calls return promises. Where a section shows one language,
the others work the same way unless it says otherwise.
An errand
Pip is a text-message assistant. Dana texts “book me a train to Leeds on Friday”. Pip’s server starts one
session for the errand, gives the model two tools written in the program (search_trains, and pay, which
asks first), and puts the model’s question to Dana as a text. The model never sees a shell, a file or
anything else of the machine.
#![allow(unused)]
fn main() {
use megacode_agent_sdk::{Client, Decision, Options, Tool, ToolContext, ToolOutput};
use serde::Deserialize;
use serde_json::json;
#[derive(Deserialize)]
struct Search { to: String, day: String }
let search = Tool::new(
"search_trains",
"Find trains to a destination on a day.",
json!({"type": "object",
"properties": {"to": {"type": "string"}, "day": {"type": "string"}},
"required": ["to", "day"]}),
|args: Search, _ctx: ToolContext| async move {
Ok::<_, String>(ToolOutput::text(format!("{} to {}: 9:10 for 42 pounds", args.day, args.to)))
},
);
let options = Options::plain("You are Pip, a personal assistant that runs errands for Dana. Be brief.")
.tool(search)
.on_approval(|request| async move {
// Text Dana here, wait for her reply, and answer.
Decision::AllowOnce
});
let client = Client::start(options).await?;
let mut turn = client.send("book me a train to Leeds on Friday").await?;
while let Some(event) = turn.next().await {
println!("{:?}", event?);
}
client.close().await;
}
import asyncio
from megacode_agent_sdk import AllowOnce, Client, Options, tool
@tool(
"search_trains",
"Find trains to a destination on a day.",
{"type": "object",
"properties": {"to": {"type": "string"}, "day": {"type": "string"}},
"required": ["to", "day"]},
)
async def search(args, ctx):
return f"{args['day']} to {args['to']}: 9:10 for 42 pounds"
async def approve(request):
# Text Dana here, wait for her reply, and answer.
return AllowOnce()
async def main():
options = Options.plain(
"You are Pip, a personal assistant that runs errands for Dana. Be brief.",
tools=[search], on_approval=approve,
)
async with await Client.start(options) as client:
turn = await client.send("book me a train to Leeds on Friday")
async for event in turn:
print(event)
asyncio.run(main())
import { Client, plain, type Tool } from "megacode-agent-sdk";
const search: Tool = {
name: "search_trains",
description: "Find trains to a destination on a day.",
inputSchema: {
type: "object",
properties: { to: { type: "string" }, day: { type: "string" } },
required: ["to", "day"],
},
handler: async (args) => {
const { to, day } = args as { to: string; day: string };
return `${day} to ${to}: 9:10 for 42 pounds`;
},
};
const options = plain("You are Pip, a personal assistant that runs errands for Dana. Be brief.", {
tools: [search],
// Text Dana here, wait for her reply, and answer.
onApproval: async () => ({ kind: "allow_once" }),
});
const client = await Client.start(options);
try {
const turn = await client.send("book me a train to Leeds on Friday");
for await (const event of turn) console.log(event);
} finally {
await client.close();
}
The whole Rust errand, runnable with no key, is sdk/rust/examples/pip.rs with its mock file pip.mock.toml. The
Python and TypeScript examples are sdk/python/examples/ticket_fix.py and sdk/typescript/examples/minimal.ts,
each with a mock file beside it. Harness takes the nearest folder at or above cwd that holds a .git as the
project, so run an example in a folder outside any repository, or give it a cwd that is a repository of its own.
Install
cargo add megacode-agent-sdk
The crate is megacode-agent-sdk on crates.io; its API reference is
on docs.rs.
pip install megacode-agent-sdk
npm install megacode-agent-sdk
megacode-agent-sdk on PyPI needs Python 3.10 or newer, ships as
a wheel only (no source build), and uses the standard library alone. A python.org Python on macOS needs its
Install Certificates.command run once before the download’s https works.
megacode-agent-sdk on npm needs Node 20 or newer and is an
ES module (import, not require); it has no runtime dependencies.
Each package needs a harness binary of the same major and minor version. It finds one in this order: the
binary option; the MEGACODE_AGENT_SDK_BINARY environment variable; a verified copy in its cache; a
download. See Getting the binary.
Two ways in
query(prompt, options) sends one prompt and yields the session’s events up to that turn’s result, then
ends the session. Client::start(options) keeps the session open: send(prompt) returns one turn’s event
stream, and steer, abort, compact, away, state and respond work on a shared, cloneable handle
while a stream is read. Client::resume(session_id, options) continues a stored session.
A turn belongs to the session from send until its result, even if you stop reading the stream: a second
send before then fails with TurnInProgress. abort() names that turn’s prompt: a running turn is
interrupted (Interrupting); a prompt still queued (behind a compaction) is taken back (Dequeued), never
runs, and its stream ends with no result, so the next send may follow. The stream starts at the prompt’s user line. Events that
arrive between turns, or in a turn the prompt did not open (one a background agent’s result opens), go to
session_events(), which you may take once. If you leave more than 10,000 events of a turn unread, the session ends with
SlowConsumer.
close() sends quit, gives the process 5 s to exit, then SIGTERM, then SIGKILL, and returns within about
15 s. Dropping the client is a last-resort kill, not a clean end.
In Python the names are Client.start(options), Client.resume(session_id, options) and query(prompt, options) (an async generator); aclose() ends the session, and async with await Client.start(...) does it
for you. A turn’s stream is an async iterator: async for over it, or break to stop reading, which leaves
the turn running; then await turn.aclose() if turn stays in scope, or its unread events still count toward
the 10,000 limit. In TypeScript they are Client.start, Client.resume and query; sessionEvents() is the
between-turns stream and close() ends the session.
TypeScript: always await client.close(), or declare the client with await using (which needs Node 20.4.0
or newer for Symbol.asyncDispose). A client left open keeps Node’s event loop alive, and the child is killed
only when the process exits. To drop a stream, break out of the for await or call its return(); the turn
still runs on until its result.
A Python handler or callback that raises SystemExit or KeyboardInterrupt ends the program, as asyncio does
for any task.
Options
Options::plain(system_prompt) is the setup for an agent that is not a coding agent: no built-in tools,
your prompt in place of harness’s own, and nothing read from the machine. Options::new() (Options() in
Python, {} in TypeScript) leaves every field at its default, which is also isolated. The SDK writes the options
to a private temporary file and removes it as soon as the session has started.
| option | default | meaning |
|---|---|---|
model, thinking | the built-in default | as --model, --thinking |
mode | manual | manual, auto or plan; governs built-in tools, not yours, except that plan asks for every client tool |
sandbox | on | on, off or external; see the sandbox |
builtin_tools | the mode’s list, less skill unless skills | the most built-in tools the session ever offers, for its whole life; [] offers none. The ceiling covers mcp, script and agent too |
tool (client tools) | none | tools your program runs; at most 128 |
system_prompt | harness’s | SystemPrompt::Replace(text) or SystemPrompt::Append(text) |
project_instructions | off | read the project’s AGENTS.md or CLAUDE.md |
user_config | off | read your own setup: config.toml, $CONFIG/AGENTS.md, the saved model, the account |
skills | off | discover skills (the project’s; personal ones too with user_config) |
agents | off | discover agent definitions (same roots rule) |
hooks | off | run hooks from config; refused without user_config |
mcp_server(name, json) | none | an MCP server for this session, in the [mcp.servers] shape. To be callable, builtin_tools must include mcp |
approval_timeout | 15 min | how long an approval request waits; at most 24 h, or None for ever |
allow_escapes | off | as --allow-escapes |
max_iterations | the built-in default | as --max-iterations |
cwd, env | the program’s | the process’s working folder, and variables added to its environment |
binary | none | use this harness binary |
mock | none | answer from a mock file, see Testing |
on_approval | none | see Approvals |
An unknown field, a wrong type, JSON null or an invalid value is refused before anything is written; the
error names the field.
What isolation skips
Every personal read is off unless you turn it on. Sessions are still written, and found, where they always are. The model catalog is still read: it is public data.
| harness skips | unless |
|---|---|
config.toml: providers, [models] nicknames, [mcp.servers], [[hooks]], [away] | user_config |
$CONFIG/AGENTS.md, the saved model choice, the account and its providers | user_config |
the project’s AGENTS.md / CLAUDE.md | project_instructions |
skills under the project’s .agents | skills |
skills under $CONFIG, $AGENTS and [skills] extra_roots | skills and user_config |
agent definitions under the project’s .agents | agents |
agent definitions under $CONFIG and $AGENTS | agents and user_config |
| hooks | hooks and user_config |
Provider keys come from the environment, as always. With user_config off only the built-in providers are
known; a program that needs a custom provider turns it on. Sub-agents inherit the isolation. They do not
inherit your system_prompt: a child gets harness’s default prompt, assembled under the same isolation.
A server running many errands leaves one log per session in the sessions folder. To keep them elsewhere,
set HARNESS_HOME with env; retention is yours.
The system prompt
Replace(text) makes text the whole prompt plus the blocks for the capabilities you enabled; no harness prompt,
instruction files or listing. Append(text) puts text after harness’s prompt and listing. Neither is
truncated.
Python spells the options as Options fields: Options(mode="auto", builtin_tools=[...]), Options.plain(prompt, **fields), with Replace(text) and Append(text) for the system prompt, mcp_servers={name: config} for MCP,
tools=[...] for client tools, and approval_timeout in seconds (None for ever). TypeScript spells them as
an object: { mode: "auto", builtinTools: [...] }, plain(prompt, more), systemPrompt: { replace } | { append },
mcpServers, tools, and approvalTimeoutMs in milliseconds (null for ever), rounded up to whole seconds on
the wire.
Tools
A tool is a name, a description, a JSON schema for its input, an async handler, ask (default false) and
timeout (default 5 min, at most 24 h). The handler gets the arguments as your type and a ToolContext with
the call id and a cancellation signal, and returns ToolOutput::text(..), or ToolOutput::with_image(..) for
text plus one image (PNG, JPEG, GIF or WebP). An Err becomes an error result the model reads.
In Python, @tool(name, description, schema, ask=False, timeout=300.0) wraps an async def handler(args, ctx)
(args a dict, ctx.call_id, ctx.cancelled an asyncio.Event) that returns a str or a
ToolOutput.text(..) / ToolOutput.with_image(text, media_type, data); a plain def is refused at @tool.
In TypeScript a tool is { name, description, inputSchema, ask?, timeoutMs?, handler }, with handler(args, ctx) returning a promise of a string or { text, image?: { mediaType, data } }, and ctx.callId, ctx.signal an
AbortSignal. Both receive the arguments as parsed JSON, not as a typed value: the schema check below is the
only gate, so a handler reads what it needs and guards the rest.
Before your handler runs, the SDK checks the arguments against the schema’s top-level required, each
property’s type and additionalProperties: false. An invalid call, an argument that does not deserialize
or a handler that panics (in Python or TypeScript, raises, throws or rejects) answers the model with an error;
your program does not crash. The cancellation
signal fires when harness cancels the call (the turn is aborted, the call times out, stdin closes), when the
process exits and on close(); a result sent after it fires is dropped. A result over about 8 MiB is
replaced by an error saying so.
Calls from one model response
run one after another. Sub-agents, /btw and script are offered none: a sub-agent cannot call your tools,
and a tool named in your list cannot be called from a script. The tool list is fixed when the session
starts, and a resume must give the same list. A tool’s name is ^[a-zA-Z][a-zA-Z0-9_-]{0,63}$,
unique, and not a built-in’s.
ask(true) makes every call an approval request of kind: "client_tool", answerable once (allow_once,
deny, deny_and_stop; no turn grant). In plan mode every client tool asks. A client tool is never judged
and never covered by an earlier grant.
Resumed in the panel or the REPL, a session with client tools still offers them; each call ends with the
error “no program is attached to run <name>”. The same error ends a call made after your program’s stdin has
closed. If writing to your program fails, calls end with “the program’s connection failed”, and a call that
is not answered in time ends “timed out after <t>”.
Approvals
An approval request is a gate: a built-in tool in manual mode, a client tool with ask, an escape from the
sandbox. With on_approval, your callback gets each request and returns a Decision (AllowOnce,
AllowForTurn, Deny { reason }, DenyAndStop), which must be one of the request’s choices. A callback
that panics, or returns a choice not offered, answers Deny with the failure as the reason. The request is
not yielded as an event; the approval line that follows is.
Without a callback the request is yielded as an ApprovalRequest event. Answer it with its own
respond(decision), or from anywhere with Client::respond(request_id, decision).
In Python the callback is on_approval=async def f(request) returning AllowOnce(), AllowForTurn(),
Deny(reason) or DenyAndStop(); in TypeScript onApproval: async (request) => ({ kind: "allow_once" }), with
allow_for_turn, { kind: "deny", reason } and deny_and_stop the others. await request.respond(decision) and
await client.respond(id, decision) answer a yielded request.
An unanswered request times out after approval_timeout; the SDK never answers for you. The timeout does
what the panel’s does: where away is allowed (approvals required, the sandbox on or external, and a mode that
does not ask for every command), the session goes away; otherwise the turn ends. Pip’s setup (builtin_tools: [], manual) allows away, so a timed-out pay is refused, and every later call that asks is refused with no
request, until your program calls away(false) or the turn ends. To get a decision on every call whether or
not anyone answers, answer in on_approval.
A full escape from the sandbox is never asked of a program unless allow_escapes is on, because the program
may be a model with nobody behind it. Resuming a session recorded as ask needs allow_escapes(true), and
the flag on a session recorded otherwise is refused, naming --allow-escapes.
Testing without a model
Options::mock(path) answers the model from a mock file: its replies, tool calls and judge assessments
scripted in TOML, as harness run --mock reads them. No key, no network. It is a live conversation
otherwise: the mode, the sandbox, your client tools, approvals and the log are all real. See
sdk/rust/examples/pip.mock.toml for the format.
[[reply]]
text = "Let me look at Friday's trains."
[[reply.tool_calls]]
tool = "search_trains"
args = { to = "Leeds", day = "Friday" }
options = Options.plain("You are Pip.", tools=[search], mock="pip.mock.toml", binary="/path/to/harness")
const options = plain("You are Pip.", { tools: [search], mock: "pip.mock.toml", binary: "/path/to/harness" });
A sub-agent call in a mock conversation ends with the error “sub-agents are not available in a mock conversation”, so a test can never reach a provider. A resumed mock session answers from the start of the file you give it; tool call ids continue from the log.
Replay, and what it does not undo
Client::id() names the session; the session id is on every result too. Three commands work on an
SDK session today:
harness session <id> --transcript # the card, or the scrollback
harness replay <id> # re-run the log with no model and no program; checks it reproduces
harness export <id> # Markdown, HTML or JSONL
Your tools’ calls and results are in the journal, so a replay needs neither the model nor your program: it serves the recorded result. Cases built from SDK sessions, and flagging a session from the program, are a later slice.
What the journal does not do:
- A client tool runs in your program, outside harness’s sandbox, judge and modes.
- Cancelling a call does not undo it.
- A replay serves the recorded result and never calls your program again.
- A crash during a call can leave the action done (a payment made) with no result recorded. Recovery reports such a call as unknown, not as failed.
Resuming
Client::resume(session_id, options) sends only what a resume may change: model, thinking,
max_iterations, approval_timeout, allow_escapes, and your client tools. For a session started with the
SDK, the mode, builtin_tools, system prompt, isolation switches and sandbox come from the log, and giving
them is refused. Client tools must equal the log’s, name for name and schema for schema; none given when the
log has some is refused. MCP servers come from the recorded baseline, never from config.toml, so
Client::resume never sends the options’ MCP servers. Refused fields exit with status 2 and name the field.
client = await Client.resume(session_id, Options(tools=[search], max_iterations=40))
const client = await Client.resume(sessionId, { tools: [search], maxIterations: 40 });
A session not started with the SDK can be resumed with the five changeable fields, and nothing else: an existing session is never turned into an isolated one. A resume in conversation form needs the session id.
Versions
The wire carries v: 1 and harness_version, and only ever gains lines, commands and fields. An SDK of
version X.Y.Z (Rust, Python or TypeScript) requires a harness of X.Y.* and raises Handshake
(HandshakeError in Python and TypeScript) otherwise; a binary it downloaded always matches. The wire is
written down in
protocol/stream-json.schema.json.
Getting the binary
In order: the binary option, the MEGACODE_AGENT_SDK_BINARY environment variable, the verified cache, a
download. The first two are trusted as given (the version check still applies).
The download is of v<version>/harness-<target>.tar.gz from the release site, over https only, checked
against a SHA-256 digest compiled into the package (of the archive, and again of the executable inside). The
pins were verified against the release’s signatures when the package was built, and the crate, the wheel and the
npm package carry the same three. Only x86_64-unknown-linux-musl,
aarch64-apple-darwin and x86_64-apple-darwin exist; any other platform (Linux on ARM, Windows) gets an
error naming the three and the binary option. The error text calls it Options::binary in all three
languages. MEGACODE_AGENT_SDK_DOWNLOAD_BASE points the download elsewhere, for a mirror.
The cache is <cache>/megacode-agent-sdk/<version>/<target>/harness, with <cache> $XDG_CACHE_HOME or
~/.cache (Linux), ~/Library/Caches (macOS). Every start hashes the file before running it, and a wrong
digest is replaced by a verified download, never run. The SDK refuses a cache path or file that another user
could change, naming the folder; there is no fallback, so set binary.
The Python download’s 120 s bound covers the DNS lookup too: the wait ends at the deadline, though the resolver’s
thread may finish later, its answer unused. The TypeScript download
follows HTTPS_PROXY and NO_PROXY only on Node 22.21 or newer, or 24.5 or newer (its proxyEnv setting);
older Node connects directly, so behind a mandatory proxy name binary.
Without the default download feature the Rust crate never touches the network: the binary comes from binary or
the environment variable only.
The trust note
The options file has the authority of a command line. It can turn the sandbox off, allow escapes and
launch MCP servers, as flags and config.toml can, and it is not checked against the rule that refuses
[sandbox], [mcp.servers] and [[hooks]] in a config found inside a project. That is because your
program, not a repository, wrote it. Never build it from model output or from anything a user types.
Your tools are your responsibility: harness cannot sandbox what runs in your process, and a tool that pays
is only as safe as the schema, the ask flag and the code behind it.
Connecting to a remote MCP server
An MCP server gives the agent extra tools. A remote one is reached over HTTPS with a URL and a token, and nothing is installed locally. Keys: config.md.
Before you start
- The URL of the server’s MCP endpoint, such as
https://api.githubcopilot.com/mcp/. - A fixed token the server accepts (a personal access token, an API key). OAuth isn’t supported.
- Harness 0.15.0 or later.
Setup
1. Put the token in an environment variable, never in the config file. Type this, don’t paste it:
read -rs GITHUB_PAT; export GITHUB_PAT; echo "length: ${#GITHUB_PAT}"
Tokens shorter than 8 characters are refused.
2. Add the server to your own config.toml (default ~/.config/harness/config.toml; harness paths shows
yours). A project’s config can’t add servers.
[mcp.servers.github]
url = "https://api.githubcopilot.com/mcp/"
token_env = "GITHUB_PAT" # the variable's name, not the token
tools = ["search_issues", "issue_read"] # optional: offer only these
allow = ["search_issues"] # optional: run without asking
githubis any name you choose.- The token is sent as
Authorization: Bearer …;token_header = "X-API-Key"uses another header. - Without
toolsyou get every tool, and each costs model context.
3. Run harness run in the same terminal and type /mcp. Expect a line like:
github http · api.githubcopilot.com · 2026-07-28 · 2 of 46 tools · ≈N tokens
Using it
Ask in plain language; the agent picks the tool. Every call asks first unless the tool is in allow: y allows
it, n denies it and stops the turn, 4 denies it with a message to the agent. A server’s read-only label is
informational only.
--approve-allskips asking all session; use it only with servers you trust.- Plan mode offers no MCP tools.
/awayrefuses any call that would ask.
/mcp shows servers, tools, calls and failures.
What the harness does with your token
- It goes only to that server’s address; redirects are never followed.
- Calls are logged, the token never is; if the server echoes it, the log shows
[redacted]. harness replayreplays the session with no server and no token.
Good to know
- Tools are fixed at session start; new ones appear next session.
- Resuming reloads tools from the log and reconnects at the first call. If the server’s config changed, its calls are refused; start a new session.
- Results are text only, up to 64 KiB; an image becomes a placeholder like
[image: image/png, 1234 bytes]. - The URL must be
https://, orhttp://for a loopback address such as127.0.0.1.
If something goes wrong
| You see | Likely cause |
|---|---|
mcp: github failed to start: GITHUB_PAT is not set | Not exported in this terminal. Do step 1 here, then start a new session. |
failed to start: … 401 or 403 | Token wrong, expired or lacking access. |
failed to start: timed out after 30 s | Server unreachable or slow; if slow, set startup_timeout = 60. |
/mcp lists no servers | Another config file was read; check harness paths. |
| A load error naming a key | A typo in [mcp.servers.…]. |
Changelog
[0.31.0] — 2026-11-04
Added
- The SDK in Python.
pip install megacode-agent-sdkgives you themegacode_agent_sdkmodule: the Rust crate’s shapes in asyncio —query,Client.start,Client.resume, your own tools asasyncfunctions marked@tool, approvals, testing on a mock file with no key. It needs Python 3.10 or newer and uses the standard library alone. A python.org Python on macOS needs itsInstall Certificates.commandrun once before the binary’s download works. - The SDK in TypeScript.
npm install megacode-agent-sdkgives you the same shapes for Node 20 or newer, as an ES module with no runtime dependencies and its own type declarations. Alwaysawait client.close()(or declare the client withawait using, Node 20.4 or newer): a client left open keeps Node running. The download followsHTTPS_PROXYandNO_PROXYonly on Node 22.21 or newer, or 24.5 or newer; on older Node behind a proxy, name thebinaryoption. - Examples you can run with no key.
sdk/python/examples/ticket_fix.py(a ticket-to-fix agent with a tool of its own and harness’s file tools) andsdk/typescript/examples/minimal.ts(one prompt, one tool), each with a mock file beside it.
Both packages fetch and verify the harness binary of their own version as the Rust crate does, and every session
they start is recorded like any other, so harness session, replay and export work on it. Their differences
from the Rust crate, such as tool arguments handed to your handler unconverted, are described in
docs/sdk.md; ADR-0097 lists them all.
[0.30.0] — 2026-11-03
Added
- An SDK: build your own agent on harness from Rust. The
megacode-agent-sdkcrate starts a harness session from your program, gives the model tools you wrote, and streams back what the session does. Use it for an agent that is not a coding agent: a text-message assistant, a support bot, a nightly job. Each session is one harness process, and every session is recorded like any other, soharness session,replayandexportwork on it. On its first run on a machine the SDK downloads the harness release of its own version, checks it against digests built into the crate, and keeps it in a cache it checks again at every start; a program can name its own binary instead. Linux on x86-64 and macOS are supported. Python and TypeScript packages, with the same shapes, come in the next release. See docs/sdk.md. --options <file>for a conversation.harness run --input-format stream-json --options <file>takes the session’s setup from one JSON file: model, mode, sandbox, which built-in tools are offered (a ceiling for the whole session, mode switches included), your own tools, the system prompt (replaced or appended to), the approval timeout, and which personal setup is read. With--options, nothing personal is read unless the file asks for it: noconfig.toml, noAGENTS.md, no saved model or account, no skills, agent definitions or hooks. A resume, on any surface, keeps the setup the session was started with. The file has the authority of the command line; never build it from a model’s output (docs/headless.md).- Client tools. A conversation can offer the model tools that run in your program: harness writes
client_tool_call, your program answersclient_tool_resultwith text and at most one image, and a call left unanswered ends at its timeout (5 minutes by default) withclient_tool_cancel. Each call is recorded, so a replay needs neither the model nor your program. A tool markedaskasks through the usualapproval_request(kindclient_tool); client tools are never judged, no earlier approval covers them, and they run outside harness’s sandbox. A session resumed in the panel shows them, and each call fails with “no program is attached”. --mockin a conversation.harness run --input-format stream-json --mock <file>runs a live conversation whose model answers from a mock file, with no key and no network, so a program’s own agent can be tested against the real binary. Sub-agents are refused in a mock conversation.- The conversation protocol is written down.
docs/protocol/stream-json.schema.jsondescribes every line in both directions, checked in CI against transcripts recorded from the real binary (sdk/conformance/). The protocol stays atv: 1; everything new is additive. - The idle
compactcommand says what it compacted. Itsdoneanswer now carriesdata.compacted(trigger,pre_tokens) when that attempt wrote a compaction.
Changed
- The update notice is amber. A new release’s note on the notice line, in
/notices, and in the plain prompt’s start line is painted in the warning colour, so it stands out from the other notes. The status-bar item is unchanged. - A spinner while sub-agents work. When the status bar reads
N sub-agents running · esc stops them, it now leads with the spinner (or the static⏳with animations off), so a session waiting on its sub-agents no longer looks stuck. abortacts at once. In a conversation, anabortinterrupts at once, ahead of any command sent after it; with aprompt_idit takes a still-queued prompt back (dequeued) or stops the turn that prompt opened. The SDK’sabort()takes back its own queued prompt and ends that prompt’s stream, so the nextsendruns.
Fixed
- Turning away off during an away wait takes effect. A
/awayoff that arrived at one moment of the wait was held until something else was sent, so the turn kept waiting. - A prompt sent while a queued compaction runs waits for it. In a conversation, a
compactqueued during a turn runs once the turn ends; a prompt sent in that window used to skip ahead of it, and anabortof that prompt stopped the compaction instead. The prompt now waits, and itsabortstops the prompt. - An MCP server’s earlier processes are checked on a busy machine. If any process on the machine was exiting at the
moment harness checked a left-over MCP server’s processes, the check failed with “its earlier processes could not be
checked:
/proc/<pid>/stat: not understood” and the server’s record stayed. Exiting processes are now skipped.
[0.29.2] — 2026-11-02
Fixed
- An answer typed the moment an approval question appears is no longer lost. The plain prompt drops what was typed before a question (an approval, the auto question after a sign-in), but it did so just after printing the question, so a key pressed right then could be dropped too and the question waited. It now drops the type-ahead before printing.
[0.29.1] — 2026-11-02
Fixed
- The transcript stays in scrollback on xterm.js terminals (fix branch
fix-scrollback-xtermjs, note). In xterm.js-based terminals such as Orca, every transcript row that scrolled off the top of the screen was deleted instead of kept, so scrolling up after a long answer showed only the last screenful. Rows now leave the screen by line feed, which xterm.js archives. Checked against Orca’s xterm.js build and tmux; VS Code uses the same engine but was not run.
[0.29.0] — 2026-11-01
Added
- A quiet start, and
/notices. A start prints the splash, the hints and the signed-in line, plus any question it must ask; everything else it used to print (a key not set, the home folder, a hook, the sandbox off, a broken config) is now a note. In the panel, one line below the splash types the notes one at a time until you send a message. A fact you have already seen in this project is not typed again for 90 days; a warning (⚠) is typed at every start while it holds./noticeslists them all in four sections: needs your attention, about this start, privacy, status. After your first message the status bar counts⚠ Nwhile a warning holds andM notesuntil you open/notices. The plain prompt prints one line, the first note and(+N more · /notices);-pand stream-json print what they printed before. What you have seen is kept per project in$STATE/notices-seen.json(docs/config.md). /updateand/restart. A newer release shows as one status-bar item,0.29.1 available · /update, typed once on the notice line; nothing goes into the transcript./updateinstalls it without leaving the session: the same signed, checked install asharness update, each step shown; Esc cancels before the swap, and a failure changes nothing. The item then reads0.29.1 installed · /restart./restartwaits for the session to be idle, closes it as/quitdoes and starts the installed binary on the same session, so the conversation, mode and approvals carry over; the new process saysNow on harness 0.29.1 (was 0.29.0). A session learns of an install from the receiptharness updateand/updatewrite ($CACHE/install-receipts.json), never by running a binary (docs/config.md).exit,quitandharness updatetyped at the prompt do what was meant. Typed alone at an idle prompt,exitorquit(any case) quits as/quitdoes, andharness updateoffers to run/updateinstead[Y/n]. A leading space, more words, or a running turn send the text to the model as before (exitis the escape)./exitis now the same as/quit(docs/shortcuts.md).- Skills used.
/sessiongains a Skills row: each skill the session used, how often, and how many times by you, sub-agents’ after./skillsgains aTHIS SESSIONcolumn with the same counts, and marks a skill added on disk since the startnew on disk: the model sees it next session.
Changed
- A new spinner. The working indicator is three braille cells turning in red and blue, ramped to the theme’s
background; a turn that ends normally closes with a short green pulse.
[ui] animations = false,NO_COLOR,TERM=dumbor a pipe show the static⏳, and the plain prompt draws the same frames without colour. - The usage-counts disclosure moves to
/notices. Signing in prints a one-line form,harness sends daily usage counts (never prompts, files or commands) · /notices;/noticeslists the full sentence, with its switch (telemetry = falseorDO_NOT_TRACK), at every start. A start that finds you already signed in no longer prints it. A judging mode’s disclosure is listed there too; choosing a judging mode in a session still prints it once. - The signed-in line no longer names the model; the status bar does. With no key and no sign-in, the status bar
says
no modelinstead of naming a model that cannot answer. [ui] quiet_startuphides what it hid before. For an update it hides only the note on the notice line; the status-bar item and/noticesstay.
Fixed
- Plan mode at start.
harness run --mode plan(with or without-p) told the model to write its plan to the literal path{plan_file}, a file that never existed. It now gets a real plan file, as/mode plandoes, and resuming such a session fixes it. A plan file’s number no longer counts resumes, and a number used before is never reused. - The approved retry of a credential read checks the file itself. After you approve reading a protected file
such as
.env, the read now takes exactly the file you were asked about, by its identity, whichever of its names leads there. On macOS a file with several hard links was now and then refused right after you approved it; that is gone, and a file swapped in between the question and the read is refused. - A resumed conversation’s
resultnames the model.harness resume <id> --input-format stream-jsonprinted"model": nullon every turn’sresult, since a resume never sees the session’s start record; it now names the session’s model, asinitdoes. - A conversation that fails to start prints no
init. When the driver failed before it was ready (a log it could not append to, say), stdout could still carryinitbefore the process exited 1 — a resume’s{"type":"init",…,"resumed":true}always, a new session’s whenever the failure came after its start record — so a client read a session that never started. Now stdout stays empty and stderr carries the driver’s own reason, exit 1.
[0.28.0] — 2026-10-30
Added
- A program can hold a session.
harness run --input-format stream-json(andharness resume <id> --input-format stream-json) keeps the session open: stdin takes one JSON command per line —prompt,steer,abort,approval,compact,get_state,away,quit— each answered by oneresponseline; stdout prints thestream-jsonlines-pprints, every gate as anapproval_requestthe program answers with the panel’s choices (allow_once,allow_for_turn,denywith a reason the model reads,deny_and_stop), a sub-agent’s gates included, and oneresultper turn with the prompt’s own id. An unanswered request times out at[away] timeoutwith the panel’s consequences. A full escape is never asked of a program: a conversation runs underescape = "network_only"as--approve-alldoes, unless--allow-escapessays a person answers; a crash resume keeps the rule its log recorded, and refuses the flag or its absence when that rule disagrees. The log of such a session is a panel session’s:harness replayreproduces it. See docs/headless.md, “A conversation”. - Closing stdin means “no more input”, not “stop”.
printf '<prompt line>' | harness run --input-format stream-jsonruns the prompt to its answer: the running turn finishes, the prompts still queued run in order, then the session ends, exit 0. A gate opened once stdin is closed is refused at once with-p’s reason, since nobody can answer it.quitand Ctrl-C stop. - SIGTERM is Ctrl-C for a conversation: the turn’s
resultprints, exit 0; a second signal exits 1. A supervisor’skillwhose grace period outlasts the effect in flight ends the session cleanly instead of leaving a crash close for the nextresumeto report; a SIGKILL inside the grace period still does. result.coston a conversation’sresult: the model calls since the lastresult(the turn’s, and a compaction between turns in the next one’s) priced asharness sessionprices a session (usd,priced,unpriced,free,bound,basis).abort {"prompt_id": …}takes a queued prompt back (dequeued) or interrupts the turn it opened.compactedcarriestrigger(automatic/manual) andpre_tokens;get_state.data.contextreportslast_prompt_tokens,window,percentandauto_compact_at_percent. Aresumestarts both from the log.- A client example, and the conversation on a real model.
examples/stream-json-client/driver.pyholds a session over stdin (standard library only) and drives eight scenarios — prompts and steers, approvals, abort and compact, the away timeout, EOF,quit, SIGTERM,resume— against a real provider, checking what the protocol promises. The five whose turns all completed are committed as live cassettes,crates/agent/tests/fixtures/sessions/anthropic-conversation-*.jsonl, replayed offline byfixture_replay;scripts/rerecord-cassettes.sh conversationre-cuts them. A real terminal on stdin is refused before anything starts, onrunandresumealike (the pty suite).
Changed
-pends on SIGTERM as on Ctrl-C. The turn endsinterrupted(ormodel_failedwitherror: "interrupted: "when the signal lands on the model call), itsresultprints, exit 1; before, the process died and the nextresumereported a crash close.-p --output-format stream-jsonlines gain fields:user.kind(messageorsteer),result.turn_idandresult.prompt_id. Additive; a reader that ignores unknown keys is unaffected.- Protocol lines escape U+2028 and U+2029 (
\u2028,\u2029) in every JSON output — a conversation’s lines,-p --output-format stream-jsonandjson— so a reader that splits on Unicode line separators (Python’ssplitlines(), Node’sreadline) sees one line per object. The text format’s answer is unchanged.
[0.27.1] — 2026-10-30
Fixed
- A notice sent once stays in its place. The away and back notices, the turn-budget notice, the empty-reply
nudge and the continue notice were folded into one request’s last message and derived again on the next, so the
next request no longer carried them. Anthropic binds a
thinkingblock’s signature to the content that preceded it, so the first request after such a notice left — the turn after an away task ended, the call after a budget notice — was refused:400 … Invalid signature in thinking block … Content that preceded this block when it was created is missing from this request. Every request — a turn’s, and the summarize call a compaction makes — now begins with the messages of the last turn request the model answered, as they were sent (read back from the log’s recorded pieces,driver::with_sent_notices), notices included, and the new notices follow; a compaction or a model-family switch, which change the start, restore nothing, and a request whose call failed is not copied. One case remains: the empty-reply nudge after a reply that was all thinking is not kept, so the request after that can still be refused. Each segment’s start or resume record now states that it keeps sent notices (keeps_sent_notices, ADR-0090), and the restore follows the segment’s own record: a log recorded by 0.27.0 or earlier replays in the shape it was recorded in, so every committed fixture replays byte-identical; a session started on 0.27.0 or earlier gets the fix from its first resume here; a segment an older harness resumed replays without it. Found onclaude-haiku-5-5with thinking on, after/away; reported and fixed by Jingtao Xu (PR #100).
[0.27.0] — 2026-10-27
Added
- Intel Macs, and every Mac from macOS 15.4. Releases now include
x86_64-apple-darwin, and the Mac floor drops from macOS 26 to 15.4 on both chips, the sandbox included.harness updatekeeps an Intel Mac current. The one-line installer offers the Intel build once the site’s installer is updated, after this release is live; it refuses a Mac below 15.4 withneeds macOS 15.4 or later (this Mac runs <version>), and aHARNESS_VERSIONpin the Mac cannot run: below 0.27.0 there is no Intel build, and on Apple Silicon a release before 0.27.0 needs macOS 26 (docs/sandbox.md). - Windows through WSL2. Run the harness inside a WSL2 distribution, as a Linux install
(docs/sandbox.md). There the sandbox hides each Windows drive’s
Usersfolder (/mnt/c/Users: every account’s.ssh, AppData and Documents) under every spelling that names a folder, since the drive ignores letter case; the rest of the drive stays readable, read-only. A project, or its git folder, inside aUsersfolder makes the sandbox unavailable: move it to the Linux file system (~/code) or elsewhere on the drive. A project elsewhere on a Windows drive keeps the sandbox, and its names compare without case, as on a Mac’s default disk, sowrite_file claude.mdcannot replace a protectedCLAUDE.md. Windows programs (cmd.exe) do not start from a sandboxed command. WSL1 and Windows on ARM are not supported, and this is untested on real Windows.
Changed
- The sandbox’s network warning covers every macOS below 27. Apple fixed CVE-2026-84551 (“an app may be able to bypass network restrictions”) in macOS 27 only, so below 27 doubt the network rules. An Apple Silicon Mac clears it by updating to 27; an Intel Mac cannot, since 27 does not run on Intel (docs/sandbox.md).
- A macOS missing one of the sandbox’s private system functions makes the sandbox unavailable instead of stopping
the harness at start,
replayincluded. The start names it:this macOS lacks `<name>`, a system function the sandbox needs;[sandbox] mode = "external"or"off"runs commands without it. The start-up self-test also fails when its process query fails, where it passed before.
[0.26.0] — 2026-10-26
Changed
- A hook on the host runs from
/. A hook withsandbox = false, or any hook in a session with no sandbox, now starts in/, not the project, so nothing it or its interpreter loads by a relative name comes from the project. Its input’scwdstill names the project:cd "$(jq -r .cwd)"first if it needs it. In a sandboxed session itsPATHdrops the folders the agent can write. The start check now also checks the path in a--flag=pathargument (docs/hooks.md). - The file tools read only regular files, up to 16 MiB.
read_file,edit_fileandwrite_filerefuse a FIFO, a device, a socket or a folder at once, and any file in/procor/sys;read_fileandedit_filerefuse a file over 16 MiB, whilewrite_filestill replaces one.searchskips files over 16 MiB and says how many (docs/sandbox.md). - Provider, judge and catalog clients follow no redirect, and a plain-
http://endpoint never goes through a proxy. A gateway that answers with a redirect now fails visibly.
Fixed
- A command that writes without end holds only the first 1 MiB of its output in memory, the part the model always saw;
searchno longer holds every match. harness export -owith..in the path goes through the same link checks as any other path in the project.- The sign-in link must be
https, and the sign-in lines escape every invisible formatting character the server sends.
[0.25.0] — 2026-10-25
Added
- A reply cut at the output cap continues. When a reply stops at the output cap without a tool call, the harness
asks the model to pick up where it stopped, up to 3 tries in all, and shows
↻ The reply hit the output limit. Continuing (2/3).; when the second try was all thinking, the third runs one thinking level lower. Esc stops it; after the third try the turn endstruncated, as before. Each try is a recorded model call and counts againstmax_iterations. In-pandharness evalthe answer joins the tries, so a case that used to endtruncatedmay now finish. - Update checks at start and every hour. An interactive session asks the install site for the newest release at
start and every hour while it is open, sending nothing about you, and says
harness X is available. Run harness update, then restart.between turns (docs/config.md).
Changed
mega:fullsends 65,536 output tokens: amega:model’s cap is the server’s figure. A cap you set still wins.- The output-cap warning keeps its measurement; its advice moved to the truncated turn’s footer.
- Older versions refuse a
config.tomlthat hastelemetry; remove it before downgrading.
Fixed
- The resume hint prints
harness resume <id>whenharnesson yourPATHis this binary, not its full path.
[0.24.0] — 2026-10-24
Added
- Hooks. Your own commands, listed as
[[hooks]]inconfig.toml, can run before a tool call (PreToolUse) and block it, with your reason going to the model, or run when you send a prompt (UserPromptSubmit) and add context beside it or block it. The input and answers are Claude Code’s shapes, so a script ports with small changes (tool names here are lower case:bash,edit_file). Only your own config can define hooks, never a repository. A hook runs in the session’s sandbox unless it setssandbox = false, and a failing hook blocks unless it setson_failure = "continue". The session refuses to start if a hook’s files lie where the agent could change them. The banner lists the hooks, every block is recorded and replays, and--no-hooksruns a session without them. Hooks are a guardrail, not a security boundary: a hook sees one call at a time and can be routed around; the sandbox is the boundary. See docs/hooks.md. harness updatedownloads the newest release through the install site, checks its SHA-256, tries the new binary, and only then replaces the old one; a failed step installs nothing. It sends nothing to our server. Sessions already running keep the binary they started with; restart them.- The update notice. After the daily check finds a newer release, the next interactive start shows
harness X is available. Run `harness update`under the banner, and hides it with the banner (quiet_startup). - A thinking level per sub-agent. The agent can give each sub-agent its own thinking level, lower for a quick
lookup and higher for hard reasoning. A definition’s own
effort:still wins, and the sub-agent’s header and its approval show the level. - Sub-agent rows say what each one is doing. Under the status line, each running sub-agent’s row shows its
prompt, its status (
waiting for youin amber when it needs your approval), its seconds, its tokens and, when it differs from the main agent’s, its model, in aligned columns. The side reply’s row looks the same. --no-mcp, onrunandresume, starts no MCP servers for that session and records it, so aharness -prun from inside an MCP server does not start that server again.- When a hook blocks the prompt,
harness -pexits 1 withoutcomeprompt_blocked;harness sessions --jsonand the/sessioncard report it, and an eval case can expect it.
Changed
- Auto loads skills without asking. Loading a skill adds its text and its list of files and grants nothing;
manual and plan still ask. A session resumed from before 0.24.0 keeps asking. To keep the question in new
sessions, add
skillto[modes.auto] ask(docs/config.md). - Older versions refuse a
config.tomlthat has[[hooks]]orcheck_for_updates; remove them before downgrading.
Fixed
- A skill whose
namediffers from its folder was skipped without a word; a start-up warning now names it. - A sub-agent definition whose
effort:is not a level was ignored without a word; a start-up warning now names it. - Resuming a session from another folder prints a
cd … && harness resume …line to paste; it dropped--sandbox, and now keeps it.
[0.23.1] — 2026-10-23
Fixed
- Each model gets its own output cap. With no
--max-tokensand nomax_tokensinconfig.toml, a reply’s cap is now its model’s own output limit, not 32,768 for every model: Claude Opus 5 and Sonnet 5 get 128,000, and a model with a lower limit (GPT-4o, 16,384) is no longer asked for more than it allows. On those models, a long high-thinking reply that used to spend the whole 32,768 on thinking now has room to answer. Gemini 4 Argon’s own limit is 32,768, so on it (and onmega:full, which serves it) nothing changes: lower the thinking level. The limit comes from the provider’s own model list, else the catalog, else 32,768. It follows the model through a/modelswitch,resume, sub-agents and side replies. A cap you set yourself still applies to every model. - The warning for an all-thinking reply names your settings: “Ask more narrowly, lower the thinking level, or raise the cap (–max-tokens, or max_tokens in config.toml)”. It used to name a source-code constant.
[0.23.0] — 2026-10-22
Added
- Ask while a long tool call runs. In the panel, once a call has run 2 s, Enter sends your message to a side
reply that answers at once — same model and thinking, reading files only, never asking — in a muted block under the
call. The message still joins after the call, so the agent sees it without answering again. Esc stops the side
reply first, then the turn. Transcripts,
harness resumeand exports show both where they joined, andharness replay <side id>replays a side session beside its main log. - The docs as a website, at
/docs/on the install site: getting started, modes and the judge, the sandbox, checkpoints and/undo, shortcuts,config.toml,harness -pand remote MCP servers, with search.
Changed
- Every block is set apart by a blank row — each tool call, text going into a tool call or thinking, whatever
follows a footer — in the panel and the line client. A streamed answer’s line no longer moves when it commits.
harness -poutput gains the same blank rows and drops an answer’s leading blank lines.
Fixed
- The model picker’s provider page says
←/Esc back;check-invariants.shchecks a source folder namedtarget. - A resize that arrived with a keystroke could leave the panel laid out for the old size, losing or doubling rows.
- rustls 0.23.46, which fixes four advisories (RUSTSEC-2026-0346 to 0349); none applied to this build’s use of it.
[0.22.0] — 2026-10-21
Added
- A resumed session shows what happened in it. Resuming draws the session’s recent turns — tool calls, thinking,
approvals, answers, footers — into the terminal’s scrollback, so you can scroll up through them, then a dim
── resumed · <time> ──line before the prompt. Long sessions show their newest turns that fit 2,000 rows, with one line saying how many earlier turns are left out and how to see them (harness session <id> --transcript).[resume] replay_rowssets the budget;0turns the replay off (docs/config.md). harness session <id> --transcriptshows a background sub-agent’s↳ <name> finishedrow.
Changed
- Turns are easier to tell apart. Your message has a blank row above and below it, and a blank row follows each thinking block and tool block, in the panel and the line client alike. The turn’s footer stays right under its answer. In the line REPL, one blank row separates turns.
Fixed
- Two sub-agents started together showed the same short id. The id now runs 14 characters, which reaches the random part, and still opens the child’s session as a prefix.
harness judge fitcrashed on score files from older question sets (u1/u2); it now counts them under “scored on another question set”.
[0.21.0] — 2026-10-21
Added
- In the panel, sub-agents run in the background and you can keep talking to the agent. An
agentcall returns as soon as its children start, and their rows stay under the transcript. Each result comes back on its own: an idle agent picks it up and answers; a busy one takes it after the tool calls already running.[agents] background = falsekeeps the old behaviour (docs/config.md). - Stopping and leaving with children running. Esc while the agent is idle stops them. Quitting stops them and
waits up to 10 s for their results; Ctrl-C after the first half second leaves at once. Work done while you are away
waits for its sub-agents, and
/awayalso reaches the ones already running. /model thinking <level>changes only the thinking level. In the plain prompt,/model <id>asks for the level before it switches.
Changed
/iterations <n>reaches the running task before its next model call, not from the next task on.- A resumed session follows
config.toml’smax_iterationsunless you chose a cap with--max-iterationsor/iterations; a cap you chose still carries over. /thinkingreplaces/reasoning: it shows, previews or hides the model’s thinking, and thinking levels are picked in/model./reasoningprints a pointer to/thinkingthroughout 0.21.x. The config key is[ui] thinking; the old[ui] reasoningstill loads./helpgroups the commands under six headings, in coloured columns./modelto a model whose key is not set warns at once, and a turn that fails there ends with an amber footer.- Signed out, the start-up line names
/loginfor the free models. - Messages print the config lines to add instead of pointing at a doc in this repository.
- Auto mode’s judge is now
typesafe:jev-1.14.0, with a cutoff of 0.20 (was 0.30). Auto mode asks somewhat more often and lets fewer risky commands through: on the 300-command test set, false alarms go from 5 to 11 of 186 safe commands and misses from 63 to 49 of 114 risky ones. The away cutoff, 0.20, now equals the ordinary one. Every session records the judge it ran under, so an older session replays under its own.
Fixed
- On OpenAI-compatible (Chat Completions) providers, a sub-agent’s result is separated from the next message by a blank line, so the model does not read your words as the end of the result.
- Esc pressed just as the model answers now stops the turn. Before, the Esc could be lost: the turn went on and could run a command from that answer that needed no approval, or wait at an approval nobody was asked for. The same holds during an automatic compaction. Such a turn ends interrupted, like any other Esc.
Security
- The harness no longer trusts the emSign ECC Root CA - G3 certificate authority. Upgrade from 0.20.x or earlier. In October 2026 the private keys of eight eMudhra intermediates under that root turned out to be recoverable, so anyone in your network path could pose as a model provider and read your API key and conversations. 0.21.0 drops the root and refuses the eight intermediates even when they are cross-signed by another root (RUSTSEC-2026-0381, RUSTSEC-2026-0382).
For operators
- Rebuild and redeploy
mega-serverfrom 0.21.0 as well: it carries the same certificate fix. mega-serveraccepts bothjev-1.13.0andjev-1.14.0for the judge; redeploy it before clients move to 0.21.0.
[0.20.0] — 2026-10-18
Added
- Two free models after a sign-in, with no key of your own. Type
/loginin a session, or runharness login: the harness prints a link and a code, you open the link on any device (over SSH too) and sign in with Google, and the harness notices on its own. You then havemega:flashandmega:full, listed in/modelright after the current model, with how fast each uses your allowance. Signed in, with no saved pick and no configmodel, a session starts onmega:fulland sub-agents usemega:flash(docs/config.md). - A daily allowance, shown as a percentage. On a
megamodel the status bar, and the line REPL’s per-turn line, endN% of today; a notice comes at 80%, and at 100% a turn stops with when the allowance resets (00:00 UTC) and how to keep going on your own key. No dollar figure is shown:/sessionsaysfreefor these calls and names the model that answered, as inmega:full (gemini-4-argon). /logoutandharness logoutsign out and end the sign-in on the server. A sign-in also ends after 7 days without use;/loginagain starts a new one.[agents] modeland[agents] thinkingset the model and thinking level a sub-agent gets when its definition and the spawn call name none, before the parent’s. The session log records them, so replay rebuilds the choice.- Auto mode’s judge works signed in, without a TypeSafe key: judged commands reach TypeSafe through the mega server, and a one-time notice says so.
Changed
- A start with no usable model says how to get one:
No model yet. Type /login for free models, or set your own key.With-p:No model yet. Run harness login for free models, or set your own key., and exit 1. - Your own keys work as before. A model you picked or configured still wins at start whenever it can answer, and the harness never moves you onto your own key by itself.
Security
- The sign-in token lives in
$CONFIG/.secrets/accounts.json(0600, in a 0700 folder). The file tools refuse that folder outright,--approve-allincluded; the sandbox hides it; a.secretsfolder, account file or lock that is a link is refused; andbundleandexportrefuse a log that holds a token.
For operators
- The account server,
mega-server, is inserver/, with its setup wizard (server/setup-gcp.sh) and deploy script (server/deploy.sh).
[0.19.0] — 2026-10-17
Added
read_filenow returns PNG, JPEG, GIF, and WebP images visually on vision-capable models. When the active model accepts image input (resolved frommodels.dev’smodalities.inputand provider/v1/modelslists, and recorded on the session log so replay stays byte-identical),read_fileadvertises image support in its tool description and returns validimage/png,image/jpeg,image/gif, andimage/webpfiles up to 3.75 MiB raw (5 MiB base64) and 8,000 × 8,000 pixels as native visual content across Anthropic, OpenAI Responses, Gemini (PNG, JPEG, and WebP), and OpenAI Chat Completions / OpenRouter.- Image validation, request budget, and model-switch protection. Oversized (> 3.75 MiB), > 8,000 px, or
header/trailer-corrupt images return descriptive text notes (
[image too large: …],[image dimensions too large: …],[invalid image: …]) instead of poisoning the session with a provider 400. Requests keep up to the newest 16 images and 15 MiB of base64 image data, and switching/modelto a text-only model (or to Gemini after reading a GIF) omits earlier image blocks with an explanatory note. Insidescript,tools.read_fileon a supported image returns a short note instructing the model to callread_filedirectly. - Transcript, export, and secret-scan support for image reads. The panel and transcript show
image · <mime> · <N> bytes,image too large · <mime> · <N> bytes, orinvalid image · <mime> · <N> bytes;harness export --format md|htmlelides the base64 payload while--format jsonland-p --output-format json|stream-jsonpreserve the full record; andexport/bundleskip verified base64 image payloads at the tworead_filelocations so random key-prefix collisions (AIza,AKIA,ASIA) in image bytes do not trigger false refusals.
[0.18.1] — 2026-10-17
Fixed
- File tools (
read_file,write_file,edit_file,glob,search) now accept an absolute path inside the project root as the project-relative path; paths outside the project,..traversal, and symlinks leading outside stay refused. Prefix stripping always matches the folder the session started in, even afterresumecontinues it elsewhere: there, absolute paths naming the new folder stay refused and paths naming the original folder resolve in the new one. (Sessions recorded before 0.18.1 that captured a refused in-project absolute path will also replay differently under 0.18.1.) read_fileon a recognised binary file (PNG, JPEG, GIF, WebP, BMP, TIFF, ICO, PDF, ZIP/JAR/Office/EPUB, gzip, zstd, xz, bzip2, 7z, tar, ELF, Mach-O, Java.class, WebAssembly, SQLite 3) now returns[binary file: <mime>, <N> bytes — read_file returns text only]instead of raw bytes, and the transcript result row showsbinary · <mime> · <N> bytes(with<N>thousands-grouped). (Sessions recorded before 0.18.1 whoseread_filereturned raw bytes for a recognised binary file will also replay differently under 0.18.1.)
Changed
- The busy status spinner is now the D5 3-cell red and blue taegeuk swirl. The three spinner cells use D5’s fixed
red and blue swatches;
[ui.colors] warningcontinues to style thethinking…text.
[0.18.0] — 2026-10-16
Added
- A provider you add under
[providers.<name>]is described by models.dev. The table is matched to the models.dev provider of the same name, unless models.dev places that provider on another host than yourbase_url;models_dev = "<id>"names one outright ([providers.fireworks] models_dev = "fireworks-ai"), andmodels_dev = falseturns matching off (docs/config.md). Its models then get names, context windows, prices and featured rows, in/model, in the window a session uses and in the price/sessionshows. Where the provider’s own model list states a window, output limit, date or price, the list wins over models.dev. A new or changed table is picked up by the next interactive start’s refresh, even when the catalog was refreshed earlier that day.[models.context]still wins, andevalstill reads no cache. - Featured rows for your own providers. A gateway whose model ids look like
lab/model, such as Vercel’s AI Gateway, features the same models as the direct providers, as OpenRouter does. Any other provider, and a gateway that carries none of those models, features its newest model of each line. /model <word>says how to add a provider you have not set up. When the word names a models.dev provider that neither a built-in nor one of your tables reaches (/model fireworks), a hint above the search’s rows names the table to add. For the six keyed providersdocs/config.mddocuments (Fireworks, Groq, Cerebras, Moonshot, Hugging Face and, new, Vercel’s AI Gateway) it is a block ready to paste. The hint prints only text the binary carries, never a URL from models.dev, and works offline.
Changed
- An OpenAI-compatible provider’s own model list now supplies names, windows, output limits, release dates and
prices where it states them, as Vercel’s does.
/model <text>finds a model by the list’s name; a model the catalog does not know yet shows the list’s window; each model’s release date, not one date for the whole list, decides itsnewtag and its place; and an id the list types as embedding, image, video, speech or another non-chat kind sinks to the bottom as(not a chat model). A price from the list shows only on a model models.dev also describes, so/modeland/sessionshow the same price.
Known limits
- A gateway may advertise a window your account is not served. Vercel’s list gives Claude Sonnet 4.5 a
1,000,000-token window, a tier not every account has. Such a session compacts late, and a turn past the real limit
fails with the provider’s “context too long” error, which the harness does not retry. Set
[models.context]for that model (docs/config.md).
[0.17.0] — 2026-10-15
Added
- The model catalog refreshes itself from models.dev. Once a day, an interactive session refreshes it in the
background from models.dev, so
/modellists a new model, with its name, window and price, the day models.dev does. Nothing waits for it and nothing is printed. It runs only when a provider with a key is set up off this machine, never under-p,--mock,evalor the other one-shot commands, and the download is compressed. Turn it off with[models] refresh_catalog = false;HARNESS_MODELS_DEV_URLreads a mirror instead (docs/config.md). The/modelfooter says when the catalog was last refreshed. - Before models.dev lists it, a new model still shows its provider’s name and window, as the first row under
its provider after the chat models, marked
(not in the catalog yet). - Recent models say
new: anything released in the last 21 days.
Changed
- Featured models are computed: the newest of each model line, at most four per provider, among the models the provider’s list serves. Nobody keeps a hand list, so a flagship is featured the day the catalog learns of it. OpenRouter features the same models as the direct providers, and orders the rest of its list by weekly use.
- Inside a provider, chat models come first, newest first. Ids the catalog does not know come next. TTS, image
and embedding ids the catalog knows sink to the bottom with a reason,
(not a chat model)or(deprecated); a brand-new one it does not know yet sits with the other unrecognised ids, below the chat models. - The Gemini list is read in full, not only its first 50 ids.
- A new model’s context window comes from the refreshed catalog, or from its provider’s list before that,
instead of falling to 200,000. A model this binary already knows keeps the window the binary carries, and
[models.context]still wins over both.evalreads no cache, so an eval’s window is the same on every machine; its report prints the window it used. /sessionandharness session --jsoncan price a model from the refreshed catalog, the same price/modelshows, so two machines may now report a different cost for the same session.- Rows from a provider the catalog never covers (a local server, your own
[providers.*]table) no longer say(new since the catalog); they carry no suffix. - The direct
claude-sonnet-4-5ids lost their hand-entered window: the catalog now says 200k itself.anthropic/claude-sonnet-4.5on OpenRouter keeps its cap.
[0.16.3] — 2026-10-14
Fixed
harness -pprints the answer when a tool call failed on the way. A single failed call (aread_fileon a missing file, a refused command) used to throw away the model’s whole final answer and exit 1. Now the answer is printed and stderr names the failed calls after it:harness: 2 tool calls failed in this turn: read_file, bash.- An empty reply no longer ends a turn silently. When the model answers with no text and no tool call (or with whitespace only), the harness asks it once more to continue. A second empty reply ends the turn as a model failure, “the model returned an empty response twice (no text and no tool call)”, instead of a blank answer.
- A slow model is no longer cut off. A provider’s connection may now stay silent for 600 s (was 120 s) before
the call fails, so a model writing a large tool call without sending a byte (seen with Gemini) finishes. A stream
that does break says how far it got:
gemini stream interrupted after 92 bytes, 600.0 s silent: …. - The status row stays visible while you type during a turn. The running tool, the thinking timer or
compacting…keeps its place, and the hintenter steers · tab after turn · esc stopsfollows it. On a row too narrow for both, the hint is dropped whole. With nothing running, the hint still replacesready. - An MCP server that lists only older protocol revisions connects (ADR-0064). A server that answered the 2026-07-28 probe with a list of older revisions the harness speaks (2025-11-25, 2025-06-18, 2025-03-26, and 2024-11-05 over stdio), either as its discovery result or in an “unsupported protocol version” error, used to be refused. The official Go SDK’s default HTTP mode and Datadog’s hosted server answer this way. Now the harness starts the server at the newest revision both sides name. A list holding only 2024-11-05 over HTTP is still refused, and now says why: that revision uses the HTTP+SSE transport, which the harness does not support.
Added
/sessionshows the last model failure: a row under Turns,model failures N · last: <error>, absent when there were none. The Turns row already countedmodel_failedturns; what is new is the error itself, on one line. A turn you stopped with Esc is not counted there.harness session --json’sturns.outcomes.model_failedis unchanged and still counts stopped turns. The same row shows inharness session <id>.
Changed — for scripts
These change what a script driving the harness sees.
-pexits 0 and JSON’sis_erroris false when the turn answered, even after failed calls (was exit 1 andis_errortrue).outcomestill readstool_failed. A call refused under--awayis treated the same way: the answer after it is printed, exit 0. Exit 1 now means the turn ended without an answer; see docs/headless.md.- The failed-calls line goes to stderr in every output format,
jsonandstream-jsonincluded, so a script reading stderr in a JSON format sees one more line. - A response head that never arrives within 600 s is not retried. The call fails at once with
<provider> timed out waiting for the response, instead of waiting up to five times. A connect timeout, a refused connection and a timeout the operating system reports still retry. - Logs recorded before 0.16.3 whose final response was empty or whitespace-only no longer replay: this build asks the model once more at that point, where the log holds no second call. Replay fixtures are version-scoped (ADR-0010); no committed recording has such a response.
Known limits
- A reply of thinking only, followed by the harness’s request to continue, is unverified against Anthropic’s API.
The request then carries an assistant message holding only thinking blocks before the nudge. If Anthropic refuses
that shape, the turn fails with its error, visible only in a live run. Chat-completions servers get no empty
assistant message: one left with no text, no tool call and no
reasoning_detailsis left out of the request.
[0.16.2] — 2026-10-14
Fixed
- The startup splash no longer disappears after a resize, a tmux session switch or a return from the external editor. A redraw used to clear the art and keep only the version line, with blank rows above it. Now the splash is redrawn as it was drawn, in its colours. In a pane narrower than the splash, the art is cut at the pane’s edge (never wrapped), and text that no longer fits beside the art is stacked under it.
- A start in a narrow pane wraps the splash’s text the same way a redraw does, so a redraw at the same width draws the same rows.
Known limits
- A resize can still leave copies of the screen in tmux scrollback, now including the splash. This is unchanged from 0.16.1: tmux moves rows into scrollback as the pane resizes, before the harness redraws them. The note explains why, and the alternative that was not taken.
[0.16.1] — 2026-10-14
Fixed
- A box table no longer breaks apart in scrollback after the pane is widened and narrowed again (in tmux: a split closed and reopened, or a switch to a session of a different size). The harness redrew the visible reply at the wide width, and tmux re-wrapped those wide rows into scrollback when the pane narrowed, leaving a second, shattered copy of the table. Now a repaint never draws a reply wider than it was first drawn. Two limits remain: tmux can still leave a few whole rows duplicated in scrollback, and a reply no longer spreads out when the pane widens.
Changed
- A turn whose only failed calls were ones you denied ends
(1 call denied)or(N calls denied)instead of(tool failed), in the panel, the line REPL and playback. A turn with any other failure still ends(tool failed).
[0.16.0] — 2026-10-13
Added
[skills] extra_rootsfolders are searched (docs/config.md). Until now they were recorded and ignored. Their skills reach/skills, the slash menu and the model’s catalog, ranked below every default skills folder, so a name clash goes to the project’s,~/.agents/skillsor the harness’s own.- Each entry must be
~/…or absolute, with no.., at most 16; anything else fails the config load. At start, an entry that repeats another, names a default skills folder or sits in the project is dropped with a notice; a missing folder is kept with one. - A session keeps the roots it started with: a resume uses them, not today’s config, and says so when the config has
changed. The log records them as
~/…, never with your home folder’s path.
- Each entry must be
/skillsmarks an extra root’s skills “extra folder N”, with a line naming the folder, and gains a Not loaded section listing every skill that did not load and why. A session start with any says⚠ 1 skill not loaded — /skills says why.- Markdown tables are drawn as tables in the panel, boxed and fitted to the pane, re-fitted when it narrows; too
narrow for columns, each row is drawn as a record. While one streams the live row reads
table · N rows. The line REPL,-p, export and the session log keep the raw text. /sessionis coloured: the session id in the accent blue, the cache hit rate green, allowed counts green, denied and flagged red.- Gemini 4 Argon (
gemini:gemini-4-argon) is known: its 1M-token context window is used without a[models.context]entry.
Changed
- A skills folder is read only inside itself, for every skills root (
~/.agents/skills, the harness’s own and extra roots). The folder itself may be a link (a dotfiles setup works), but aSKILL.mdor file that links out of its skills folder, or any absolute link inside it, now stops loading, and/skillssays what links where and what to do (copy the file in, link the whole folder, or recreate the link withln -sr). - A skills folder that overlaps a protected place is not used: one inside, or containing, a folder the command
sandbox hides (
~/.ssh,~/.config/ghand the like), the harness’s session store, or (for an extra root) the project, so~,~/.configor/is refused.~/.claude/skillsitself is allowed. The message names the folder and why. If such a place cannot be checked (a permissions problem, say), no skills folder is used until it can, and the message names the folder that could not be checked.
Fixed
- A reply keeps its colours and its code indentation after a resize or a tmux session switch. A repaint used to redraw it plain and squeeze code blocks; a long line was squeezed even when first printed.
/agents’s undefined-nickname row keeps its amber on a repaint.- A
~~~fence is drawn as code; a code fence closes only on a bare marker of its own kind (one followed by a language name no longer closes it); and an interrupted answer no longer leaves the next one drawn as code. - Folder and skill names holding control characters are shown escaped in
/skills,harness pathsand start notices.
Notes
- Do not resume a 0.16 session with 0.15.x. Nothing stops it, but its segment does not search the extra roots, and 0.16 can no longer replay the log (AF-R8). 0.16 reads, replays and resumes every older log; a log written before 0.16 keeps its extra roots unsearched.
[0.15.1] — 2026-10-12
Fixed
- The harness now works behind a proxy that inspects HTTPS. A corporate gateway or a sandbox’s egress re-signs each
connection with its own certificate authority, and every model call failed with
transport error: error sending request, because the harness trusted only the authorities compiled into it. SetSSL_CERT_FILEto a PEM file holding the proxy’s certificate (your system’s bundle is fine): it is trusted as well as the compiled-in ones, for every provider, the judge and remote MCP servers (docs/config.md). Unset, nothing changes. A file the harness cannot use (unreadable, not a regular file, over 16 MiB, or holding no usable certificate) stopsrun,-p,eval,resumeandjudge scorebefore they start, naming the file.
[0.15.0] — 2026-10-12
Added
- MCP servers, configured in
config.tomlunder[mcp.servers.<name>](docs/config.md). A session with servers offers the model their tools through two tools,mcpandmcp_schema, and a catalog in its system prompt; a session without servers sends exactly what it sent before. A server that fails to start does not stop the session: one line says why, and its tools are absent.- Local servers (
command) are started by the session and supervised: a harness that dies, even bykill -9, leaves no server running. Each runs in its own sandbox box, read-only on the project, with no home folder, a stripped environment plus the variablesenvnames, and no network unlessnetwork = true.readbinds a folder read-only into the box, for a program under your home folder (node under~/.nvm, say).sandbox = falseruns a server outside the box (a Docker-run server needs it); that is recorded and shown assandbox off. - Remote servers (
url, Streamable HTTP) take a token from the variabletoken_envnames, sent only to that server’s own address; redirects are never followed. No OAuth. - Both protocol eras: the stateless 2026-07-28 revision and the
initializerevisions before it.
- Local servers (
- Every MCP call asks, once (
yallow once ·ndeny ·4tell the agent), in manual and in auto (the judge is not consulted), unless the server’sallownames the tool, or the session runs with--approve-all. No turn or session grant covers an MCP call. Plan mode does not offer MCP; under/awayan MCP call that would ask is refused. The gate shows the server’s own read-only hint, which changes nothing. /mcplists the servers this session’s start listed: transport, command or address, sandbox, protocol version, tool count, an estimate of the catalog’s tokens, theallowlist, and the calls and failures since. The/sessioncard gains an MCP row, andharness session --jsonanmcpobject.- Every MCP call is recorded in the session log, with credentials redacted. The values of
envvariables and the token never enter the log, and any of them a server echoes back is replaced by[redacted]; a value shorter than 8 characters is refused at start, since it could not be redacted safely. Secrets go inenvortoken_env, never incommandorurl, which are recorded as written; aurlholding a user name or password is a load error.harness replayreplays an MCP session with no server, no config and no credential. - Resume reads the catalog from the log and lists nothing; a server starts at its first call. A server whose config
differs from the session’s start is refused (
configured differently; start a new session to use it). - Results are text, capped at 64 KiB; an image or audio block reads as a placeholder (
[image: image/png, 1234 bytes]).
Changed
- Typing
/tokensno longer points to/session. The one-release hint (0.14) is over;/tokensis refused like any unknown command. - Every sandboxed session hides
$STATE/mcp(the servers’ private homes) from its commands, MCP or not, so a new session’s record names one more hidden folder than under 0.14. Recorded sessions replay unchanged. - A config found through
HARNESS_HOMEorXDG_CONFIG_HOMEinside the project cannot define servers (a load error): a cloned repository must not be able to start a process.
Notes
- A session that used MCP is read by 0.14.x as holding unknown records: it lists and shows its card, and
resumeandreplayrefuse it, naming the effect. 0.13.x and older cannot read it. An older harness also refuses aconfig.tomlthat holds[mcp.servers](it rejects unknown keys).
[0.14.0] — 2026-10-12
Added
/sessionshows what this session has done, in the panel and the line client (/statusworks too). One card, read from the session log when you run it: the id, its resume command and log file; where the time went (in turns, the model, tools, waiting on you); turns and model calls; tool calls that succeeded, failed, never ran or are still pending; approvals; the judge’s verdicts; files changed; tokens and the cache; an estimated cost; how full the context is; and the model, thinking, mode, sandbox and checkpoints in force now. Child agents’ figures follow their parent’s on the same rows (+ 2 children: …).harness session <id>prints the same card for any session, finished, crashed or still running, from a full id or a unique prefix asresumetakes them.--jsonprints the figures as one JSON object ("v": 1; later versions only add keys).--transcriptprints the session’s scrollback as the line client showed it, paged on a terminal like git ($PAGER, elseless);--no-pagerprints it straight out.- A summary line above the resume line when you quit:
5 turns · 43m in turns · 2.49M in / 21.6k out · ≤ $1.95. It is left out after a forced quit (Ctrl-C while quitting), when the log may still be growing.
Changed
/tokensis gone. Its figures are on/session’s Tokens and Cache rows; typing/tokenssaysuse /session.harness sessionsand the resume picker list a session ended by a store failure asfailed. Before, a session stopped because its log could not be written or read could list ascompleted.- The cost is an estimate at list price. It is marked
≤, an upper bound, only when every model call is priced and none sent a prompt over 200K input tokens, where some providers charge long-context rates the catalog does not hold (the card then sayslong-context rates not priced). When some calls have no price it reads$X for N of M calls. A cost the provider reports (OpenRouter) is taken as is; judge calls are not priced.
Fixed
- A session log whose last line was cut inside a multibyte character (a crash mid-write) no longer reads as
corrupt: it no longer stops the next write to the log, or hides the session from
harness sessionsand the picker. - A log holding an effect this version does not know (0.15.0’s MCP calls, for one) now lists and shows its card
instead of reading as unreadable;
resumeandreplayrefuse it, naming the effect.
[0.13.1] — 2026-10-11
Fixed
- A decimal number in a tool call no longer ends the session. A model calling a tool with, say,
limit: 100.0ended the session at its next model call (session failed: store append failed: … floating point is not permitted in a digested payload), and resuming failed the same way. Such a session now carries on, resumes and replays. A session 0.13.0 ended this way can now be resumed;harness replaystill cannot replay it.read_filestill readslimit: 100.0as no limit (whole numbers only, as before). - Cases and bundles can be made from OpenRouter sessions, whose recorded usage holds a decimal cost.
0.13.0 and older cannot resume or replay a session that holds a decimal: they stop where they stopped before.
[0.13.0] — 2026-10-11
Added
- A message typed during a turn now reaches the agent while it works. Press Enter while the agent is busy and your
message is sent at once: it waits in a
↳row above the box and the model reads it before its next call, right after the tool calls it has already asked for have run. It then appears in the transcript where it landed. If the turn ends before that point (the agent answered without another tool call), it is sent as your next message. The status row saysenter steers · tab after turn · esc stopswhile you type, and an approval that opens while a message waits says when it goes in. Each delivered message is recorded in the session log as a new record,user.steered.v1, and replays exactly. - Tab keeps the old queue. Tab instead of Enter during a turn holds the message until the turn ends, as Enter did
in 0.12 (
⧗ … · after the turn · ↑ edit; ↑ takes it back). /skill:<name> <request>sends the skill’s instructions and your request as one message (/skill:show-me make a diagram of the store)./skill:<name>alone works as before.- Auto by default, asked once. If
TYPESAFE_API_KEYis set andconfig.tomlhas nomode, the first interactive start asksStart in auto by default? … [y/N]and saves your answer asmode = "auto"ormode = "manual". It never asks again.--modestill overrides it for one run, and Shift+Tab still switches within a session. - A top-level
modekey inconfig.tomlnames the mode new sessions start in (manual,plan,auto, or a[modes.<name>]table).harness evalalways starts in manual.
Changed
- Enter during a turn steers instead of queueing. Use Tab for the old behaviour. Esc still stops the turn and puts everything that had not gone in back in the box. Quitting while a message waits stops the turn first; Ctrl-C during that leaves at once.
- Headings follow your accent colour. With
[ui.colors] accentset and nomd_heading, markdown headings use the accent, bold. The dark theme’s default accent is now a pale blue,#a6d3fd(256-colour 153).--helpkeeps the previous teal, which reads on light and dark backgrounds alike. - A stop that arrives just before the agent’s next model call now ends the turn as interrupted before the call is made, rather than cutting the call short.
Notes
- An older harness refuses a
config.tomlthat holds the newmodekey (it rejects unknown keys), and refuses to resume or replay a session that holds a steered message (it names the record it does not know).
[0.12.1] — 2026-10-11
Fixed
export --format mdno longer ends each reasoning block with empty>lines. Gemini’s recorded reasoning ends with blank lines, and every one was quoted. Blank lines before and after each reasoning text are now left out, in md and html alike; blank lines between paragraphs stay.
[0.12.0] — 2026-10-10
Added
harness -p "question"runs one turn and exits, for scripts and other agents.harness run -pis the same,--promptand--printare other names for it, and the root takes everyrunflag with it (harness -p "q" --model openai:gpt-6). Piped input joins the prompt, data first and your question last:cat log | harness -p "why does this fail?";git diff | harness -ptakes the whole prompt from stdin. In a terminal you see the whole turn live, asrun --promptshowed it before. When stdout is a pipe or a file it gets only the final answer, and only when the turn succeeded: exit 0 means stdout is the answer, 1 means it failed and says why on stderr, 2 means the arguments or stdin were refused.--verboseputs the live transcript on stderr. The contract is docs/headless.md.--output-format jsonandstream-json.jsonprints one result object when the run ends (result,is_error,session_id,usagewhere Claude Code’s callers look for them, plusoutcomeand the calls that weredenied).stream-jsonprints one line per finished step (the prompt, reasoning, answer text, each tool call, its approval and its result), then that result. Both carry"v": 1; fields and types are only ever added.- When nobody can answer an approval, the call is refused and the turn goes on. Under
-pwith piped input, or with no terminal to ask on, a call that needs approval does not run; the model is told nobody was there to approve it and finishes with what it can, and stderr names the refused call. A turn whose only failures are these refusals still succeeds. In a terminal you are asked, as before. - Reasoning from every provider, in the transcript. OpenAI (Responses) and Gemini now send their reasoning
summaries too. The panel writes reasoning into the transcript as it streams, under a
✻ Thinkingheader with the seconds it took, instead of one flickering line on the status row, and keeps it in the history.[ui] reasoning = "full" | "preview" | "off"chooses all of it, the first four lines, or the timer only;/reasoningswitches it for the session. The plain line interface shows reasoning only on a terminal, so piped output is unchanged. - A message typed while the agent works is queued, not sent. It shows as a
⧗row above the message box and is sent when the turn ends, one turn per message, in order. ↑ on an empty box takes the newest one back to edit. Esc stops the turn and puts every queued message back in the box, unsent. Quitting sends none of them; they are in your input history. export --format mdandhtmlinclude the model’s reasoning before each reply, whatever[ui] reasoningsays. In html it is a closed “Thinking” section.
Changed
- The approval menu no longer takes keys meant for the message box. A gate that opens within a second of a key
you typed, or of sending a message, waits for ↑ or ↓ before it answers; until then your keys keep going to the box,
and the menu says
you were typing — ↑/↓ to answer. Esc still denies and stops at once. run --promptinto a pipe or a file prints the answer, not the transcript. A script that read the transcript from stdout now adds--verboseand reads stderr.- Piped input no longer answers approvals for
run --prompt: it joins the prompt. To answer approvals from a pipe, pipe the conversation into plainharness run. - OpenAI’s reasoning summaries need a verified organisation. Where OpenAI refuses them, the harness retries the call once without them and stops asking for the rest of the session; that session shows no OpenAI reasoning, and says nothing about why.
Known limits
-pcannot continue a session (resume -p), takes no slash commands, andstream-jsoncarries no live text deltas.- A stdout that fails to be written fails a
-prun only when it is a pipe or a file; a terminal that goes away mid-run is not reported that way. - Reasoning already on screen re-wraps raggedly, and loses its bold, when the panel is resized.
- A call refused because nobody could answer shows in the transcript as
✗ denied by you: ….
[0.11.0] — 2026-10-09
Changed
- New session logs are about ten times smaller on long sessions. A log used to copy the whole conversation into
every model call’s record, so it grew with the square of the session’s length. Now each message, the system prompt
and the tool list are stored once, and each call lists them by hash. On the three largest logs measured, a 125 MiB
log comes to about 8 MiB.
resume,replay,check,export,flagandbundleread correspondingly less. - A file the agent reads or writes is stored as its text when it is valid UTF-8, instead of one number per byte.
- Old logs read and replay exactly as before. Nothing rewrites a stored log. Resuming an old session cleanly switches it to the new form from that point; what it already holds stays the size it was. A session resumed after a crash keeps the form it was writing.
flagpins with fingerprint scheme 2 ("fingerprint_scheme": 2in the case file): the fingerprint leaves the stored text out.checkverifies each stored piece a pinned turn relies on against its hash, whatever the case’s scheme, naming any that is missing or altered. A case pinned before 0.11.0 over an old log checks as it did, and the turn it pins is still found when flagged again. A version pinned by a newer harness’s scheme is an error naming the scheme.bundleships the stored pieces a pinned turn lists from earlier in the session, once each, and its manifest names them (added_pieces,bundle_format: 2). A bundle of a case pinned before 0.11.0 over an old log is unchanged, byte for byte.exportandbundlenow see file contents. Their key scan could not match a file stored as numbers. A new session that read a file holding key material now refuses to export or bundle without--allow-keys, where 0.10 passed it.replayrefuses a session whose model call was cut between storing its text and starting the call, as it refuses any step a crash cut short.resumecontinues such a session.run --mockprints the stored pieces among its records.
Known limits
- 0.10 and a 0.11 log: 0.10 lists the session with its new records counted as unknown and exports them, but refuses to resume or replay it once the session has made a model call. A session 0.10 crash-resumes before its first model call cannot then be replayed by 0.11.
- 0.10 and a 0.11 case: 0.10 ignores the scheme. Its
checkandbundlereport a fingerprint mismatch for a turn whose log stores pieces, and agree over an old log. Itsflag --casedrops the scheme from every version it rewrites, which breaks them for 0.11: do not re-pin a 0.11 case with 0.10. Itsexport --format jsonlof a new log drops the format field, so the copy reads but does not replay under 0.11. - Logs written before 0.11.0 keep their size, and the key scan still cannot see the file contents they hold.
[0.10.1] — 2026-10-09
Fixed
- Ctrl-C is no longer lost on the plain line interface (
runandresumewithout the panel, piped input, andrun --prompt). A press that landed while the harness was printing could be dropped, so a turn kept running, or an approval kept waiting, until a second press. Now it is kept and handled at the next chance, with the same meaning as before: it interrupts the turn, denies and stops at an approval, and quits at the idle prompt. Presses that arrive before the harness reads them count as one, and a second press that lands after an interrupt has ended the turn quits. An approval still queued when Ctrl-C interrupts the turn no longer opens a prompt you would have to answer or cancel again: the call shows as denied. The panel was not affected. - File-change checks in evals see edits made through the shell. The committed review case, the code-mode adoption
case and the check
scripts/adlc-demo.shadds now countfile.mutated.v1orturn.files.v1, so a run that edits withsedor a script no longer fails them, while checkpoints are on. A turn-end list with no changed, new or deleted file in it (only a note, or only paths it could not check) does not count as a change.
Added
- An eval check can count any of several event kinds. In a case’s
expect.event_shape,kindmay be a list:["file.mutated.v1", "turn.files.v1"]counts events of either kind. A case written with a single kind reads and writes back unchanged.
[0.10.0] — 2026-10-08
Added
- A new session starts on a model you have a key for. The order:
--model; then the model you saved in/modelor config.toml’smodel, whichever you changed last, if its key is set; then the other of the two; then the first provider with a key, in/model’s order (anthropic, openai, gemini, openrouter, together, deepseek, zai, xai), on its default model. When the harness had to choose, a line under the banner says why (stderr for--prompt).resumekeeps the log’s model, andharness evaluses--modeland config.toml only, as before. - With no key at all, the panel opens with a warning naming the variables to set;
run --promptand piped input print it and exit 1. /modelremembers your pick. Enter switches and saves the model and its thinking level as your default for new sessions; Tab switches for this session only. A typed/model <id>or/thinking <level>saves too, and the/thinkingpicker takes the same two keys. The pick is kept in the harness’s state folder, never in config.toml; edit config.toml’smodelorthinkingafter a save and config.toml wins.- When the sandbox cannot start on Linux, the message says what the harness seems to be running inside (Docker,
Podman, gVisor, a Kubernetes pod, WSL, …), with the evidence, and offers
mode = "external"first. It is a guess: it changes nothing and is not recorded. - Default models for openrouter, together, deepseek, zai and xai.
Changed
- The gatekeeper is now the judge. Config takes
[judge]and a mode’sjudge = […];[gatekeeper]andgatekeepare still read, and a file using both spellings is refused.harness gatekeeper score|fitis nowharness judge score|fit, with no alias. Every message says “judge”, and the first-run notice shows once more. A session log recorded before 0.10.0 whose judge gave a reason no longer replays. /keysis now/shortcuts, with no alias;?on an empty input still opens the table, and its page is docs/shortcuts.md. Side by side the table needs 83 columns; narrower panes page it.- Switching models brings that model’s thinking level (its saved one, else config.toml’s
thinking, else medium), where a typed/modelused to keep the session’s. - The sandbox hides its secret folders inside
HARNESS_HOMEtoo (.secrets,.ssh,.awsand the rest of its list), as it does in your home folder. - Any file inside a
.secretsfolder of your project is a credential file: reading or editing it asks, search skips it, checkpoints leave it out, and commands allowed the network cannot see the folder. - docs/sandbox.md now says that a credential read asks in every mode except under
--approve-alland inrun --mock, where nothing can ask. The behaviour is unchanged.
Fixed
/modelsearch finds a provider by name: “open” lists everyopenai:andopenrouter:model, where it used to missopenai:ones./model openaistill lists openai’s own models.
Known limits
- On macOS, a file matching
.cargo/credentials*that is created after a session starts stays visible to commands until the next session; on Linux, every hidden name created after the start does.
[0.9.1] — 2026-10-07
Fixed
- A session killed again after its recovery notice, before the turn closed, now shows at the next resume what that resume’s leftover check found, or that the check was cut short, instead of only repeating the earlier notice.
- A notice shown again says “this resume checked again” only when the resume actually checks leftovers.
- 0.9.0’s notes said two things too broadly, and are corrected in place: the model gets the notice only when the killed step was a tool call, and the stop for a session resumed from another folder needs a killed sandboxed command.
[0.9.0] — 2026-10-07
Added
- A killed session can be resumed. If the harness is killed while a command, a file write, a picture, an
/undorestore, a model request or a child agent runs,harness resumenow picks it up instead of refusing.- The killed step is never run again. Its outcome is unknown. You get a notice saying what was killed and what
changed in the project since the picture before it, with the
/undofor each loss; when the killed step was a tool call, the model’s result for it carries the same notice. (Corrected in 0.9.1.) - The killed turn gets its turn-end list, as an interrupted turn does.
- The notice is shown again at each later resume until you send a message, marked as from an earlier resume.
- The killed step is never run again. Its outcome is unknown. You get a notice saying what was killed and what
changed in the project since the picture before it, with the
- Leftover commands are checked at that resume. While a command runs, the harness keeps a small owner-only record
of it under its state folder, and removes it once the command has settled.
- On macOS, a sandboxed command still running from before the kill is stopped. On Linux, the sandbox already ends with the harness.
- Processes left by a command run outside the sandbox are reported, never stopped, since one may be a server you want.
- The protected-file check runs again against what was recorded before the kill, and moves aside a planted file.
- If cleanup cannot be confirmed, sandboxed commands are held, and the notice says why and what to do. A record that cannot be read holds them until you remove it; the notice gives the command.
- A session folder of records that cannot be listed, or a check that fails, stops the resume with what to do; resuming again checks again.
- A session whose killed command ran in the sandbox, resumed from another project’s folder, stops and prints the
cd … && … resume …line to run there; other killed steps resumed from the wrong folder are not caught (see Known limits). (Corrected in 0.9.1.)
- docs/checkpoints.md “Resuming a killed session” and docs/sandbox.md “A killed command’s leftovers” have the details.
Changed
- A sandboxed command is refused if its record cannot be written (for example, the state folder is not writable). The refusal names the folder and how to fix it.
- A turn closed by a crash now gets its turn-end list, and a resumed line session no longer shows each turn one input late after a crash.
harness replayrefuses a session that holds an abandoned step, naming it;harness exportshows the abandoned step and the recovery notice.- The refusal to resume a sandbox-off session without the sandbox names
--sandbox offas well as the config setting.
Known limits
- A session held at resume, then closed while idle, is neither held nor checked at its next resume.
- A killed command’s change to the git files the gatekeeper watches is not re-checked at resume.
- A refusal or other tool failure longer than the terminal is cut on screen;
harness exportshows it whole. - A killed file write, or a command run outside the sandbox, resumed from another project’s folder is compared against that folder, so its change list may be wrong. (Added in 0.9.1.)
[0.8.0] — 2026-10-06
Added
-
Checkpoints. Before every
bash,execandagentcall, and before a turn’s first file write, the harness takes a picture of the project: each covered file’s type, size, mode and times, with a copy of its content in the harness’s own store. Later writes in the turn picture only the path they touch, and a picture copies only what changed since the last one. It never touches your.gitand never reads git to decide what to copy.- Not copied: what
.gitignoreexcludes (in a project without git, a built-in list:node_modules/,.venv/,target/and similar), your[checkpoints] skiplist, and the harness’s own folders. Credential files (.env,*.pem,*.key, …) and files over the size cap are recorded but never copied. A folder whose.gitignorethe harness cannot read exactly as git does is shown as “not checked” rather than copied past its rules. - The store is owner-only, under the data folder (
checkpoints/<project key>/), and shares unchanged copies between pictures. Its limits are in[checkpoints]: 100 MiB per file, 5 GiB and 14 days per project, and per picture 10,000 files, 500 MiB or 10 seconds. - When a picture stops early, it says so and names what it skipped. The tool call always runs, unless you interrupted it.
- No pictures are taken when the project is your home folder,
/, or inside the harness’s own folders. A notice at start says so.
docs/checkpoints.md has the whole story.
- Not copied: what
-
The turn-end list. After a turn that changed files, a short list shows
files: N changed, N new, N deleted, up to ten lines, with a folder whose files were all deleted shown as one line.- Losses are flagged, each with the
/undothat brings it back:not in git,not checked against git(when the harness cannot tell),20+ deletedandprotected. - What the harness could not check is stated, not hidden.
- Losses are flagged, each with the
-
/undoand/undo <path>put files back from a picture.- The flow: pick a picture, see a preview of what will be written back, recreated, left alone and kept, then confirm. The result lists each path the same way.
/undonever deletes. Files created since the picture are listed as kept, for you to remove.- It takes a picture of what it overwrites first, so an
/undocan itself be undone. - The model is told what was restored, and that stays true after
/compact. - In the plain line client you pick by number and confirm with
y.
-
/checkpoints on|offand[checkpoints] enabled(on by default)./undostill works on pictures taken before you turned them off. -
/modelasks for a thinking level for the model you pick, with the level in force highlighted. Enter switches both; Esc goes back to the list. -
Opus 5.5 is in the bundled model list, and replaces Opus 5 on the shortlist (Opus 5 is still reachable by browsing).
Changed
- Sandboxed commands can no longer read the harness’s session logs or its checkpoint store. If the project holds
either folder (a harness home inside the project, or the project is your home folder), the agent’s file tools
refuse them too. As a result,
harness exportand other commands that read session logs do not work from inside a sandboxed command. - A failed write to the session’s journal now ends the session, as a failed log append already did, instead of carrying on with an incomplete record.
- The Markdown export shows thinking-level, iteration-cap and
/checkpointschanges, not only model changes.
Known limits
/undocovers project files only. It cannot undo what a command did outside the project or sent over the network.- Commands approved to run outside the sandbox can still reach the store and the session logs.
- Resuming a session that was killed while a command ran is part 2 (0.9.0).
[0.7.2] — 2026-10-06
Fixed
- The static Linux binary builds again. The
x86_64-unknown-linux-muslrelease build stopped linking at 0.4.0 (renameat2undefined), so no static Linux binary could be built since then. It links again, and behaves the same.
[0.7.1] — 2026-10-04
Fixed
@path completion after editing back into a path. Once the cursor had touched the@(Home, or ← past it), the menu closed and nothing reopened it, so Tab did nothing. Tab now completes the path the cursor is in./modewithout the sandbox (off,external, or unavailable) no longer says it asks before a command uses the internet or runs outside the sandbox: those questions are only asked while the sandbox is on. The two rows read “no extra question: commands have it” and “no extra question: commands run outside”.
Changed
- The resume line’s command (
Resume this session with: harness resume …) is amber, the colour a command has in the model’s answers, instead of teal.
[0.7.0] — 2026-10-03
Added
- The sandbox runs on macOS. On macOS 26 or later every
bashandexeccommand runs under Seatbelt, Apple’s kernel sandbox, through/usr/bin/sandbox-exec, enforcing the policy bubblewrap enforces on Linux. It is on by default, so auto,--approve-all,run --mode auto,harness evaland away work on a Mac with the sandbox on; 0.6.0’s “no sandbox for commands on macOS yet” is gone. docs/sandbox.md, “On macOS”, sets the two systems side by side. - What a command can do on a Mac: write the project,
$TMPDIR(a private folder for the session), a linked worktree’s git folder, your[sandbox] writableentries, and your user temp folder except Apple’scom.apple.*folders in it. A plain command has no network at all,127.0.0.1included, so a test against a local fake server needsnetwork: true, which gives the whole network and still hides the project’s credential files. The machine’s/tmpis out of reach (/tmp/xfails; use$TMPDIR); other processes are invisible and cannot be signalled; disks, the console and your other terminals are refused, and so areopen, Apple Events to other apps, launchd jobs, the keychain and the pasteboard. The hidden list gains the keychains, cookies, accounts, mail, messages and app data under~/Library, the browsers’ profiles and~/.zsh_sessions. - A command’s processes end with it, normally, on a timeout or on Ctrl-C. If the harness cannot confirm they all have, the command’s result is withheld with an error naming them (at most eight, then how many more) and how to end them, and the session runs no command and no file tool, a child session’s included, until a later command checks clean.
- The sandbox is checked at every session start, under its full rules, in about 0.2 s. It is unavailable, with
the cause named, when a check fails, when
/usr/bin/sandbox-execis missing, or when the harness runs inside another sandbox; that last notice suggestsmode = "external"when the outer sandbox is the boundary.
Changed
- On a Mac the model is told that temp files go in
$TMPDIR, that/tmpis not writable, and that a command that listens on or connects to any port, local ones included, needsnetwork. Linux’s texts are unchanged. - Costs on a Mac: a sandboxed command takes about 30 ms longer than an unsandboxed one, and a session start about
0.2 s longer. A command’s profile is capped at 1,000 path filters, so a very long
[sandbox] hidelist slows every command and, past the cap, is refused; the command line with its environment must fit in 1 MiB; and a project path of more than about 1,000 bytes cannot run sandboxed commands. - What you carry on a Mac: a command cannot rename or remove a folder that holds a hidden or read-only path, nor
the project itself, and cloning a whole folder in one call is refused (copy tools fall back to copying file by
file). It cannot create the first missing folder above a hidden path or a read-only git path that does not exist
yet: while
.git/hookslinks totools/githooks, a command cannot createtools/. A command is refused, naming the folder and the fix, when a folder on the way to a hidden or read-only path, inside the project or another writable folder, cannot be searched. The keychain is refused inside, so keychain-backed credentials do not work there; forgit’sosxkeychainandghthat is expected, not tested. Tools that sandbox themselves fail inside (swift build --disable-sandboxturns SwiftPM’s off). Your user temp folder is shared with your other programs. - A Mac session’s record names the backend (
seatbelt macOS 27.0 (26A428)), the network asnone, the temp folder as$TMPDIRand$USER_TEMPamong the writable folders. A log a Mac session records with the sandbox on cannot be replayed by a 0.6.x binary: replay it with 0.7.0 or later. Linux records are unchanged.
[0.6.0] — 2026-09-30
Added
- The harness builds and runs on Apple Silicon Macs with macOS 26 (Tahoe) or later. An older macOS refuses to start
the binary,
replayincluded, and Intel Macs are not supported. The README has the Mac setup. The file tools keep their confinement there: a link out of the project is refused, a credential-file read asks, a protected-file write asks. Commands keep their hygiene: stdin from/dev/null, a new session, no inherited descriptor past stderr. - macOS has no sandbox for commands yet (the Seatbelt backend is the next slice). A Mac starts with the sandbox
unavailable and says so at every start: manual works, and auto,
--approve-all,run --mode auto,run --awayandharness evalneed[sandbox] mode = "off"(all but away, unconfined) ormode = "external"(all of them). [sandbox] mode = "on" | "off" | "external", and--sandbox <mode>onrun,resumeandevalfor one session. The flag beats the config, and cannot be combined witheval --target.enabled = falsestill meansoff.- External mode, for a dedicated VM, a container or a CI runner that is itself the boundary, on Linux and macOS.
The unattended modes work, away included;
--approve-allrefuses only the hard-deny list; the gatekeeper, the local asks and the file tools’ asks apply as they do without a sandbox. Commands see the machine as your user does: no hidden folders, no read-only protected files, no private/tmp, no network rule. Only you can set it, in your own config or with the flag; there is no environment variable. The session record saysexternal, which switch set it, and where the config came from. docs/sandbox.md, “External mode”, says what the machine must do instead: keep keys and long-lived tokens off it, limit its network, and snapshot or discard it after a run. - Under
external, credential variables stay out of commands’ environment — the credential-shaped names, a URL with a password, and yourenv_hide— except exact names you list in[sandbox] env_pass. Provider keys are always removed, and naming one inenv_passis a config error in every mode. The record lists the names removed and passed, never values, and the model is told which variables are missing. This is not a credential boundary: a command running as your user can still read the harness’s own environment. harness pathsnames where the config came from:default,XDG_CONFIG_HOMEorHARNESS_HOME.- On macOS the model’s prompt says the machine runs macOS with the BSD command-line tools (
sed -i '',stat -f).
Changed
- On a disk that ignores letter case (a Mac’s default), protected files, credential names,
.git(the hard-deny rule too), path grants, the plan file and git’s index ignore ASCII case:write_file claude.mdasks as a write toCLAUDE.md, andread_file .ENVasks as a credential read. The disk’s answer is recorded when a session starts, and a session whose disk cannot be asked refuses to start. - On macOS a credential-file read through a link to a file with several hard links asks, since its other names are unknown.
- A
[sandbox]table in a config found throughHARNESS_HOMEorXDG_CONFIG_HOMEis refused when the file lies inside the project, by any spelling of the project’s path, or when its real path cannot be established; the rest of that config still loads. Your default~/.config/harness, aHARNESS_HOMEoutside the project, and anXDG_CONFIG_HOMEthat is~/.configspelled out are never refused. enabledandmodethat disagree, either way, are a config error.- Start-up notices name the switch that set the mode (
set by --sandbox,set by $HARNESS_HOME/config/config.toml). The unavailable notice’s fix namesmode = "off"rather thanenabled = false; withenabled = falsethe notice is unchanged. - A crash resume under
externalcontinues only whileexternalis both what was recorded and what is set now, and the recorded environment names, platform and case fact still hold; otherwise it exits 2, naming the first difference. Moving a session to or fromexternaltakes/quitand a cleanresume. Other recorded states resume as before. - Git’s system config is also read where macOS keeps it: under the active developer directory, as
xcode-selectfinds it, the Command Line Tools and Homebrew. - A log that records
externalcannot be read by a 0.5.x binary: replay or resume it with 0.6.0 or later.
Fixed
cargo build -p cli --releaseworks on macOS 27, whose Command Line Tools’stripleft the build-time proc macros unloadable; the shipped binary is still stripped.
[0.5.1] — 2026-09-30
Changed
- On a prompt for a delete git cannot undo, decoded text being run, a protected-file write or a credential read, the
panel’s highlight now starts on deny, so an
Enterpressed out of habit refuses; pressy(or move up) to allow. Other prompts are unchanged, and the plain REPL already treated a bareEnteras no.
[0.5.0] — 2026-09-29
Added
- A
bashorexeccommand that empties or deletes a file git cannot bring back asks first, in manual and in auto, whatever the gatekeeper would say:truncate, an empty redirect (: > f,> f,true > f,cat /dev/null > f, on any descriptor),cp /dev/null f,rm f,git rm -f f, orgit checkout -- f/git restore f, when the file is untracked and not ignored, changed since its last commit, or in no git repository. The prompt readsAsked because it empties or deletes notes.md, which git cannot bring back (untracked). A committed, unchanged file and an ignored file go on to the gatekeeper as before. The git state is read from git’s own index, never by running git. - A command that decodes hidden text and runs it (
… | base64 -d | sh,eval "$(… base64 -d)",xxd -r -p … | bash,python3 -c "exec(…b64decode…)") asks first:Asked because it decodes hidden text and runs it. Decoding into a file without running it does not ask. - A command that empties or deletes files past what the harness reads — more than 1,024 of them, or behind more
directory changes or nesting than it follows — asks too:
Asked because the harness cannot tell which files it empties or deletes. - None of these asks can be granted for the turn or the session; all are refused while you’re away, and
--approve-allskips them. They see only the files a command names: a glob, a variable,find -delete,xargs rmor a script still goes to the gatekeeper. docs/modes.md, “Local asks”, lists the forms. harness gatekeeper scoreandfitapply these asks before the gatekeeper: a row they catch counts asaskat every cutoff, andfitprints a line naming every catch.
Changed
- Protected files that git doesn’t track, at their fixed places, are read-only inside the sandbox: a local
.envrc,.claude/settings.local.json, an untracked file in.vscode/,.claude/or.github/workflows/, and your own[protected] addentries. No command can change one, however it spells the write; the file tools still ask. A protected folder holding nothing git tracks is read-only whole. - A protected path a sandboxed command creates, such as a new
.envrcor a new file in.claude/, is moved aside to<name>.harness-quarantined-<16 hex digits>, never deleted, and the result says where. That holds even when the command stages it or a checkout brings it in, and for a tracked one turned into a directory, a link or a FIFO; putting it back takes one approval. One that can’t be moved stops every later sandboxed command, naming it, until it moves or you move it away. - Tracked protected files (a committed
CLAUDE.md, a workflow) and everybuild.rsstay writable to commands, so a sandboxedgit checkoutorgit switchcompletes. Review what a task changed against the commit it started from, with the steps in docs/sandbox.md: note the commit and the ignored files before it starts, then diff and list from a fresh index built from that commit (GIT_INDEX_FILE=<tmp> git --no-replace-objects read-tree <commit>), the ignored list included. A command can write.git— the index, a replace ref, an ignore rule — to hide a change fromgit diff <commit>andgit status; the steps resist the tricks known today and are not a proof. Code planted in a build file runs the next time you build or test on the host, so review first. - A protected path that is a link to the project or a folder holding it (
.claude -> .), or to a target so deep that binding it needs more mounts than bubblewrap allows, stops every sandboxed command, naming the link, until you replace it. - Under
harness run --approve-all, a command asking to run outside the sandbox (outside_sandbox: true) is refused before it runs, and the agent is told to carry on with the work that needs no approval and name the step in its final message;network: truestill runs unasked.harness eval,run --mockand child sessions follow the same rule. To leave the sandbox, run with a person present. - A session recorded before 0.5.0 gains these asks at its next clean resume. A session started with
--yesbefore 0.5.0 that crashed, and is crash-resumed, still lets a full escape run unasked until you resume it cleanly. - A log written by 0.5.0 under
--approve-all,evalorrun --mockrecords thenetwork_onlyescape rule, which a 0.4.x binary cannot read: replay or resume it with 0.5.0 or later. - On a prompt whose ask no turn grant covers (a protected file write, a credential read, and the local asks), the
“this turn” choice now says
except this kind: another call of the same kind asks again.
Fixed
- A git config with a very long section header and many keys no longer takes memory far out of proportion to its size when a session starts.
Removed
harness run --yesis gone: use--approve-all(the rename shipped in 0.4.1). A script still passing--yesnow fails at argument parsing, before anything runs.
[0.4.1] — 2026-09-28
Changed
harness run --approve-allreplacesharness run --yesand does what it did: a tool call that would ask you goes ahead instead.--yesstill works in this release, but is no longer listed in--helpand prints a one-line note; a later release removes it. Messages and docs that named--yesnow say--approve-all.
[0.4.0] — 2026-09-27
Added
- Every
bashandexeccommand runs in a sandbox, in every mode,--yesincluded. It can write only in the project and a private/tmp, cannot read your key folders (~/.ssh,~/.awsand the rest of the hidden list) or credential variables, has no network but localhost, cannot reach local sockets or see other processes, and cannot change what git runs later: hooks, git config, and what they name. Linux only, through bubblewrap; docs/sandbox.md has the requirements and what the sandbox does not stop. - Two ways out, each with the model’s reason shown to you:
network: trueadds the internet and keeps the rest of the sandbox, hiding the project’s own credential files;outside_sandbox: trueruns the command as you and always asks. - A command the sandbox blocked says so in its result, and how to ask for more.
/away: while you’re away, anything that would ask you is refused instead, and the agent carries on with the work that needs no approval. A breaker goes back to asking after 3 refusals in a row or 20 in all.harness run --away --prompt …does the same for a scripted run.- A question left unanswered for
[away] timeout(default 15 minutes) is refused, and the rest of the task runs as if you had typed/away. This applies only where/awayworks, such as auto with the sandbox on: in manual, a question waits for you as long as it takes, as in 0.3.0, with no countdown. harness run --mode <name>./iterations <n>raises the per-task limit on model calls from inside a session.[sandbox]and[away]config tables, and[gatekeeper] away_cutoff(default 0.2, stricter than the ordinary 0.3)./modeshows where commands run, and whether a mode may use the internet or leave the sandbox.
Changed
- Where the sandbox cannot run, auto is not available: choosing it prints the cause and the fix, a session resumed or
configured in auto starts in manual, and
--yes,run --mode auto,run --away,run --mockandharness evalexit 2. In manual a command you approve still runs unconfined there, as in 0.3.0.[sandbox] enabled = falseruns without the sandbox on purpose, for example inside a container that is already your sandbox. - The file tools follow no link out of the project: a
CLAUDE.md,AGENTS.mdor.agents/entry linked outside it no longer loads, an absolute link is refused even when it points back inside, andharness export -othrough a linked folder is refused. - A local ask says why it asked (
Asked because …), instead of saying the gatekeeper flagged it. - Resuming a session keeps the model-call limit its log last stated, over
config.toml. - A
--yesor--mocksession does not resume where the sandbox cannot run; a session from before 0.4.0 resumes unconfined, in manual. - Every session start runs a quick check that the sandbox holds.
Fixed
- A process a command leaves running in the background no longer holds the call open: the call returns about two seconds after the command exits. Outside the sandbox, a process that detached itself keeps running, and the result ends with a line saying its output was cut.
- A command no longer inherits the harness’s stdin, terminal session or open files, including on kernels where
close_rangefails. - Model text, child rows, the panel’s repaint and the echo of your message draw control characters escaped, never raw.
Security
- Reading a credential file (
.env,*.pem,*.key,*.p12,*.pfx) with the file tools asks, in every mode, and is refused while you’re away;searchskips such files and says how many. rmwith recursive and force flags on anything with a.gitpath component is refused in every mode.- A command cannot point git elsewhere: a planted
.git/commondir, or a changed link to git’s config or hooks, is moved aside with a note. A hooks folder, an include or another file git would run or read, created where none existed at session start, is flagged in the result. - Git’s hooks and config stay read-only inside the sandbox, your global and system git config included, even when
tracked. A sandboxed git command that would change one, such as a
git checkoutof a branch with different hooks, stops half-done, and the agent is told so: which files are left and, after a checkout, thegit checkout -- <files>that finishes it outside the sandbox, which asks you first.
[0.3.0] — 2026-09-25
Added
- Auto mode now asks only when a command looks risky, instead of asking before every
bash/execcall: a second model, the gatekeeper, reads the command beside your request and decides run or ask. /modeshows a table of what each mode does, and marks the mode actually in force.- A
[gatekeeper]config section, including its ask/run cutoff. - Readline-style editing keys in the message composer: Ctrl-A / Ctrl-E to the start / end of the line; Alt-← / Alt-→ (also Ctrl-←/→, Alt-b / Alt-f) a word left / right; Ctrl-W deletes the word before the cursor; Ctrl-U / Ctrl-K delete to the start / end of the line. Terminals that send Cmd-←/→ as Ctrl-A/E (Ghostty and iTerm2 on macOS) get those too. docs/keys.md lists every key.
?on an empty input, or/keys, shows the keys; docs/keys.md lists them with terminal notes.
Changed
- The composer keeps its last column free, so the cursor always has a cell of its own at the end of a row. Its text is one column narrower.
Fixed
- The composer drew a run of spaces as one space, so a space typed between two words seemed to do nothing and ←/→ sometimes seemed not to move. Every space now draws where it was typed, and every ←/→ press moves the cursor, including at the edge of a wrapped row.
- A tab or another whitespace control pasted into the composer now draws as one space instead of shifting the line.
Security
- A control character pasted into the composer, such as ESC, is drawn in caret notation (
^[) and never reaches the terminal raw. - Protected files always ask for approval, in every mode.
- A short list of commands is refused outright, in every mode, gatekeeper or not.
- Provider API keys never reach the environment of a command the harness runs.
Earlier
0.1.0 (slices A–N)
- Slice A — the walking skeleton: a single-owner agent loop over an append-only event log, replayed byte-identical.
- Slice B — a real, minimal harness: Anthropic and Gemini providers, five tools, streaming, an interactive REPL.
- Slice C — hardened for real work: log compaction, prompt caching, a
searchtool, timeouts, cancellation, an approval gate. - Slice D — CI in front of the tree, an
exectool besidebash, honest exit statuses, diff previews in the approval gate. - Slice E — the daily driver: output-cap recovery, a live status line, a config file, an iteration cap, OpenAI Responses and Chat Completions as two more providers.
- Slice F — the surface: color, a spinner, a startup identity line, context-% in the footer, streaming markdown, bounded approval previews.
- Slice G — the protocol spine and the panel: a typed stream, correlated approvals, Build/Plan/Auto modes, the ratatui bottom panel.
- Slice I — the daily driver, round two:
@file references, bracketed paste, layeredAGENTS.md/CLAUDE.mdcontext with/init, the resume picker, the Ctrl-G external editor. - Slice J — skills:
SKILL.mddiscovery and invocation through the approval gate, plus picker row expansion, session export, composer vertical navigation. - Slice K — subagents as replayable child sessions,
/modelmid-session, the startup splash, thebuildmode renamedmanual. - Slice L — the first ADLC loop end to end:
flag,check,bundle,eval, cases as repo citizens;/modelbecomes a picker with a per-spawn child model. - Slice M — the first target that isn’t this harness:
harness importover OpenTelemetry GenAI traces,eval --targetagainst a real external agent. - Slice N — the fix slice: a log holding a record of a kind this build doesn’t know is carried, not corrupt; key-material scanning on export; tightened file permissions on the harness’s own data.
0.2.0 (slices O–T; the bump is cd6900d, 2026-09-10)
- Slice O — code mode: the
scripttool runs in an embedded QuickJS-NG isolate, every inner call journalled and replayed byte-identical. - Slice P — code-mode adoption: a steering block in the system prompt when a mode offers
script, and the adoption matrix that measured what moved it. - Slice Q — a resumed session rebuilds its status line, history and context-% from its own log; every provider’s token counts are whole; a project is named by its folder.
- Slice R — a thinking level you can change mid-session, a deny that can steer instead of only stopping, OpenRouter as a provider, color on the splash.
- Slice S — a slash-command menu with typo refusal, and a tool call rendered once, legibly, with its output.
- Slice T — any OpenAI-compatible provider through
provider:modelids, a built-in list of compatible providers plus three lines of config for any other, nicknames for helper agents, and file tools that report their own truncation honestly.