Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. --model. A missing key then stops the harness, naming the variable; a signed-out mega: model stops it with You are not signed in to mega. Type /login.

  2. Whichever of the saved pick (model.json in the state folder) and this file’s model changed last, if it can answer: its key is set or, for mega:, you are signed in.

  3. The other of the two, if it can answer.

  4. If signed in to mega: mega:full.

  5. The first provider below with a key, on its default model. Your own [providers.*], ollama and lmstudio count only in steps 2 and 3 (the last two need no key).

    providerdefault model
    anthropicclaude-opus-5
    openaigpt-5.6-sol
    geminigemini-3.7-flash
    openrouteranthropic/claude-opus-5
    togetherzai-org/GLM-5.3
    deepseekdeepseek-v4-pro
    zaiglm-5.3
    xaigrok-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:

providerkey variablebase URLdialectneeds a key
anthropicANTHROPIC_API_KEYhttps://api.anthropic.com/v1Anthropicyes
openaiOPENAI_API_KEYhttps://api.openai.com/v1Responses (default)yes
geminiGEMINI_API_KEYhttps://generativelanguage.googleapis.com/v1betaGeminiyes
openrouterOPENROUTER_API_KEYhttps://openrouter.ai/api/v1Chat Completionsyes
togetherTOGETHER_API_KEYhttps://api.together.ai/v1Chat Completionsyes
deepseekDEEPSEEK_API_KEYhttps://api.deepseek.comChat Completionsyes
zaiZAI_API_KEYhttps://api.z.ai/api/paas/v4Chat Completionsyes
xaiXAI_API_KEYhttps://api.x.ai/v1Chat Completionsyes
ollama(none)http://localhost:11434/v1Chat Completionsno
lmstudio(none)http://127.0.0.1:1234/v1Chat Completionsno

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:

  1. A bad name: [providers.<name>]: a provider name is lowercase letters, digits and hyphens (<path>).
  2. A new name with no base_url: [providers.<name>]: base_url is required for a provider outside the built-in list (<path>).
  3. A key an original cannot use: [providers.<name>]: base_url is not used by <name> — remove it (<path>), or the same for api.
  4. models_dev on a built-in: [providers.<name>]: models_dev is not used by <name> — its models.dev link ships in the binary; remove it (<path>); on typesafe or a system_one table: … — it answers the judge, not chat; remove it (<path>).
  5. Any other models_dev, true included: [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.

ModelWeight against your daily allowance
mega:flash1×
mega:fullabout 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 says Signed in as <email> · today N% used, resets 00:00 UTC (no model: the status bar has it), or Signed in as <email> before the first usage is known.
  • At 80%, one notice per session and day; on mega:full it adds that mega:flash lasts 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. and To keep going now, use your own key: e.g. set GEMINI_API_KEY, then /model to select the model.
  • /session shows free and 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 .secrets is a link, nothing is saved and you read as signed out. A token works only with the endpoint that issued it.
  • /logout (or harness 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://…, or http:// on localhost, 127.0.0.1 or ::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). -p and stream-json print what they printed before.
  • /notices prints all of them in four sections: Needs your attention, About this start, Privacy, Status (the update, the signed-in facts, and after a /restart what changed). Notes seen at an earlier start say seen. Opening it marks them seen. The status bar shows ⚠ N while warnings hold and M notes until 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; /notices always 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:

CountWhat it is
sessions, resumessessions you started and resumed
turnsturns that ended, by how: completed, interrupted, hit the output cap, failed
continuationstries made after a reply hit the output cap
tokensinput and output, per provider and model
limit hitsmodel calls refused because the free allowance was used up
errorsby kind: rate limit, server, timeout, other
crash recoveriescrashes found when a session was resumed
approvalsasked, allowed, denied and timed out, per mode (manual, plan, auto, custom)
tool callsper built-in tool; MCP calls and skills as one count each; any other name as other
featuressub-agents, side questions, hooks run, /undos, compactions
sandboxsessions started with it on, off, unavailable or external
active minuteswhole 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
KeyTypeDefaultMeaning
commandarray of strings—stdio: program and arguments (exactly one of command, url)
urlstring—http: the endpoint (https, or http to loopback)
envarray of names[]variables passed to the server (not HOME, TMPDIR, PATH)
readarray of paths[]stdio, sandboxed: read-only paths in the box (~ expanded; each must exist)
token_envname—http: the variable holding the token
token_headerstringAuthorizationhttp: the token’s header (Bearer prefix only for Authorization; never a protocol header)
networkboolfalsestdio: the box gets the network
sandboxbooltruestdio: run inside the box
toolsarray of namesallonly these tools are offered
allowarray of names[]these tools never ask
enabledbooltruefalse keeps the table but starts nothing
startup_timeoutseconds30spawn, probe and first listing together
call_timeoutseconds300one 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 env or token_env, which log only the variable’s name. command and url are logged verbatim, so a url with a user name or password (https://user:pass@…) is a load error.
  • env does nothing on an http server; only token_env reaches it.
  • A sandboxed server sees the project read-only, with no home folder, a stripped environment plus env, and no network unless network = true. To write the project it needs sandbox = 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_HOME or XDG_CONFIG_HOME inside 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); /mcp says why. A sandbox = true server fails the same way when the sandbox is unavailable, never running unboxed.
  • /mcp shows 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, on run and resume, starts no MCP server for that session: the mcp and mcp_schema tools are not offered and the banner reads mcp: off (--no-mcp) when your config defines a server. A resume keeps the mode its log recorded, so the model may still try mcp; the call fails with mcp: 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"
KeyTypeDefaultMeaning
namestring—required; unique; 1–32 of a-z, 0-9, -, _
eventstring—required; "PreToolUse" or "UserPromptSubmit"
commandarray of strings—required; the program and arguments, run without a shell
toolsarray of namesevery toolPreToolUse only: the harness tools the hook covers (mcp for an MCP server’s tools)
timeoutwhole seconds301–600; one run, start to finish
sandboxbooltruefalse 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_HOME or XDG_CONFIG_HOME inside 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, on run and resume, runs that session with no hooks. The banner reads hooks: 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. /away also 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 an agent call 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 --version fails: ⚠ 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 update only 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.sh checks the same signature when the machine’s ssh-keygen can verify one (OpenSSH’s -Y verify): always for the newest release, and for a pinned HARNESS_VERSION of 0.26.0 or later. Without a capable ssh-keygen it says so and installs on the checksum. HARNESS_REQUIRE_SIGNATURE=1 makes it install only a release whose signature it checked: it stops instead, and it refuses a pinned version before 0.26.0.
  • The latest pointer itself is not signed: someone who can write the bucket or the site can hold harness update on an older signed release, but cannot make it install an unsigned one. install.sh comes 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 = false stops 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 eval or --mock the same holds.
  • telemetry = false stops 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. A DO_NOT_TRACK in the environment that is not empty or 0 counts as telemetry = 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.

FieldExampleMeaning
version0.24.0the running version
platformx86_64-unknown-linux-muslthe build’s target: that, aarch64-apple-darwin or x86_64-apple-darwin
install32 hex charactersa random id made on the first report and kept in $DATA/install-id (harness paths)
sessions12interactive 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.

FieldExampleMeaning
install32 hex charactersthe install id, once per message
kindupgradedinstalled or upgraded
from0.26.0the version before; absent on installed
to0.26.1the version now
platformx86_64-unknown-linux-muslthe build’s target
at1761480000the 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.

WhoSeesKeeps
our serverthe four fields or an install or upgrade event, and your IP address as the connection’sdaily 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 linean upgrade event with your sign-in token, and your IP addressa record of each upgrade under your account id (from, to, platform, time), with no deletion date
our server’s own request logtime, route, status, duration, and on a signed-in route your account idin Google Cloud Logging, under its retention
Google Cloud Run’s request logIP, user agent, path, timein 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 releaseIP, user agent, timeunder 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

tablekeyswhat 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, postureoverrides 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, removeedits 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_failurecommands that run before a tool call or a prompt and can block it; user config only (above, hooks.md)
[script]enabledturns 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.