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.