Skip to main content

Configuration

Harness reads a single TOML file. The default location is ~/.config/harness/harness.toml (honoring $XDG_CONFIG_HOME), overridable on every verb with --config. A repo can also carry its own project-scoped harness.toml — see Projects.

A copy of the example lives in the repo as harness.toml.example.

Harnesses​

Each [harness.<name>] table defines one supervised process.

[harness.my-agent]
harness = "claude-code"
args = ["--foo", "bar"]
workdir = "~/src/my-project"
description = "long-running agent in ~/src/my-project"
enabled = false
FieldMeaning
harnessrequired — the harness kind, an enum: crush, claude-code, codex, pi, omp, opencode, generic, command. There is no default; every harness says what it runs. It selects the adapter, which owns the executable a long-running harness runs — args are appended after it. generic runs sh, so its args are sh's args. To run any other program, use command with an argv — see The command kind. generic takes no prompt or prompt_file — see Agent adapters
argsargument list appended after the adapter's executable. Not accepted on command, which takes argv instead
argvcommand only: the whole process, argv[0] first, exec'd without a shell. See The command kind
transcriptscommand only: observe the harness's sessions as those of claude-code, crush, codex, pi, omp or opencode. See Binding transcripts
workdirworking directory (required for most commands)
env_fileoptional KEY=VALUE file sourced before launch (secrets stay here, out of the config). Also accepts a list of files loaded in order, a later file winning a key collision — env_file = ["claude.env", "reviewer.env"] — so a shared credential file and a per-persona one compose without copying. A missing file is tolerated, exactly as a missing string is; an empty list is a load error
descriptionfree-text shown in the dashboard
enabledautostart on daemon boot / after a daemon restart (true)
restartrestart policy on exit — see Restart policy
restart_delayseconds to wait before restarting a harness that exited
health_cmda probe program the daemon runs every health_interval while the harness is up; exit 0 is healthy. See Health probes
health_intervalgap between probe runs (duration string, default 5m). Requires health_cmd
health_failuresconsecutive failed probes before health_action (default 3). Requires health_cmd
health_actionwhat trips it — restart (default) or notify. Requires health_cmd
stall_timeoutidle with no successful model call before the stall watchdog acts; "0" or omitted means off. Refused on a harness with schedule or triggers
stall_actionwhat the watchdog does: "notify" (default), "restart" or "none". Requires a non-zero stall_timeout
backendhosting strategy: native (default) or tmux
tmux_sockettmux socket name; only used when backend = "tmux"
export_telemetrypublish this harness's agent activity to the telemetry destinations: true opts in, false opts out (and beats export_all), unset follows [telemetry] export_all. A project file may set only false. Applies on reload

An agent one-shot replaces args with a prompt (or a prompt_file) — see the next section.

A bare [name] table is accepted for backward compatibility, but the [harness.<name>] form is preferred (ADR-0006).

Agent one-shot harnesses​

Give a harness a prompt, and the daemon synthesizes the agent invocation at spawn time from the harness's adapter (see the table below). This turns a harness into a one-shot agent run:

[harness.deploy-check]
harness = "claude-code"
prompt = "check the deployments and report anything unhealthy"
model = "claude-opus-5" # optional model (requires prompt)
auto_accept = true # optional: unattended/yolo mode (requires prompt)
max_turns = 20 # optional: turn budget, 0 = unlimited (requires prompt)
# the one-shot is headless by default; set quiet = false to stream output
# quiet = false
workdir = "~/src/my-project"
# one-shot runs default to restart = "no" (a completed run must not respawn);
# set restart explicitly to opt back into supervision
FieldMeaning
promptthe agent instruction. Mutually exclusive with args and with prompt_file, and not accepted on harness = "generic"; stored verbatim (never placeholder-expanded) and synthesized into the agent argv at spawn from the same harness adapter
prompt_filepath to a file whose contents are the instruction — the alternative to an inline prompt for anything too long for one TOML line. See below
prompt_templatethe agent instruction as a SPEC-0017 template: {{run.id}}-style placeholders render against the run context at spawn. Mutually exclusive with prompt, prompt_file, prompt_template_file and args; the four context paths and the grammar are in Templated prompts
prompt_template_fileprompt_template's file form — the path resolves, is checked and is parsed at load; the file is read and re-parsed at each spawn. See below
modelwhich model the agent runs, e.g. claude-opus-5. Requires prompt; folded into the synthesized argv
auto_acceptrun unattended, bypassing the agent's permission prompts (the vendor's yolo flag). Requires prompt; fold into the synthesized argv
max_turnscap on how many iterations the agent may run before stopping. Requires prompt; 0 or omitted means unlimited
system_prompt_fileclaude-code one-shots only. Path to a persona file appended to Claude Code's system prompt — it appends, it never replaces. Requires prompt/prompt_file; the path resolves against the declaring file, and a missing file fails the load
mcp_configclaude-code one-shots only. Path to an MCP servers file, emitted as --mcp-config <path> --strict-mcp-config, so the run sees exactly the servers the persona names and nothing from your own configuration
allowed_toolsclaude-code one-shots only. List of tool permissions, each entry its own argv element after --allowedTools — ["Read", "Bash(git log:*)"]. An entry that is blank or starts with - is a config error
quietrun headless (suppress the agent's interactive output). Defaults to true for a prompt one-shot; set false to stream output to whoever attaches

These agent fields are config truth only — they are never written into args (that would corrupt the edit-form round-trip). The daemon folds them into the synthesized agent argv at spawn time, and they require prompt: there is no vendor-agnostic place to inject a flag into an arbitrary argv, so a long-running harness passes its tool's flags through args itself.

Each adapter maps those fields onto its own CLI. A field the CLI has no flag for is dropped, not emulated:

harnessSynthesized commandIgnored fields
crushcrush [--yolo] run [--quiet] [--model M] <prompt>max_turns (Crush has no turn cap)
claude-codeclaude -p [--dangerously-skip-permissions] [--model M] [--max-turns N] [--append-system-prompt-file F] [--mcp-config F --strict-mcp-config] [--allowedTools T…] --verbose --output-format stream-json <prompt>quiet (-p is already headless)
codexcodex exec [--model M] [--full-auto] <prompt>quiet, max_turns
pipi --print [--model M] <prompt>auto_accept (Pi has no permission prompts), max_turns, quiet (--print is already headless)
ompomp --print [--model M] <prompt>auto_accept, max_turns, quiet, as for pi
opencodeopencode run [--auto] [--model M] <prompt>max_turns, quiet
genericnone — a prompt or prompt_file on generic is a config error—

⚠️ auto_accept bypasses ALL of the agent's permission prompts. Only enable it on trusted, headless runs. OpenCode's is the narrow exception: --auto approves the permissions that are not explicitly denied, so explicit denials in OpenCode's own permission config still hold.

Stream-json one-shots run without a terminal​

A claude-code one-shot prints stream-json, one JSON object per line, so the daemon runs it on pipes rather than under a PTY (ADR-0033). Every other kind keeps its PTY: crush, codex, pi, omp and opencode one-shots, every resident harness, and every command harness, whatever its argv.

  • No terminal. The process has no controlling terminal and its stdin is /dev/null. The prompt is on the argv, so nothing waits on input. Stop, timeout, on_overlap = "replace" and the budgets reach its whole process group, as they do for a PTY harness.
  • stdout is kept per run as jobs/<name>/<run_id>.stream.jsonl (see Runs), masked line by line. A prompt harness with no schedule or triggers has no per-run files, so its stdout goes to its durable log (logs/<name>.log).
  • stderr goes to the run's log and the durable log, masked, with escape sequences stripped.
  • No emulator. Neither the log sanitizer nor the attach plane builds a terminal emulator for these runs, so the daemon's memory no longer grows with how much a run prints, and the lines reach the logs as the agent wrote them. There is no screen and no viewport: harness attach and the TUI preview show the recent output lines, then new ones as they arrive; see CLI → Attach.

Prompts that live in a file​

A TOML basic string carries no raw newline, so a prompt of any real length ends up as one unreadable line. prompt_file names a file instead:

[harness.blog-sweep]
harness = "claude-code"
prompt_file = "~/.config/prompts/blog-sweep.md"
model = "claude-opus-5"
schedule = "0 9 * * 1"
  • Mutually exclusive with prompt, and either one satisfies the "requires prompt" rule for model, auto_accept, max_turns, quiet and schedule.
  • The path is resolved at load; the file is read at spawn. harness.toml stores the path, so editing the prompt takes effect on the next run with no reload — and config writers (the TUI edit form) round-trip the path rather than inlining the document.
  • A leading ~ expands, and a relative path resolves against the file that declared it — the config file's own directory, the harness_d file's directory, or the project root in a project harness.toml. This is stricter than workdir/env_file on purpose: the daemon runs with an arbitrary working directory, so a cwd-relative prompt would mean different things to the CLI and the daemon.
  • A missing, unreadable, or empty file fails the config load with a located error, and is re-checked at spawn. Unlike env_file, it is not optional: a harness with no instruction has nothing to run, and a scheduled one firing into an empty prompt would look like a successful no-op.

Templated prompts​

prompt_template (a string) and prompt_template_file (a path) are the templated counterparts of the verbatim keys. Where prompt reaches the agent exactly as written, a template's {{path}} placeholders render against the run's context at spawn:

[harness.pr-triage]
harness = "claude-code"
prompt_template = "Triage run {{run.id}} on {{harness.name}} ({{run.trigger}})"
schedule = "0 */4 * * *"
  • Mutually exclusive with prompt, prompt_file, prompt_template_file and args — one prompt source per harness — and either one satisfies the "requires prompt" rules for model, auto_accept, max_turns, quiet and schedule.
  • The context is the run, not the world: harness.name, harness.workdir, model, the installed package's package.dir (for a harness with source), the run record's run.id, run.trigger, run.source, run.started_at, run.date, and, for a run a trigger fired, the event context: event.file, event.id, event.kind, event.source, event.received_at, and the event's validated event.name, event.action, event.repo, event.number, event.url, event.sha and event.meta.<key>. prompt and prompt_file are never in it — a template referencing them fails the load.
  • Required vs optional: {{run.id}} fails the run when the path is absent; {{run.id?}} renders empty. A required path no firing of the harness can ever supply (say {{run.source}} on a scheduled harness, or {{run.id}} outside any schedule or trigger) fails the config load, so every firing cannot fail one by one. The same holds for event.*: a clock firing carries no event, so a required {{event.repo}} on a harness with a schedule fails the load — write {{event.repo?}}.
  • Grammar: the only special sequences are {{path}}, {{path?}} and {{literal_open}} (the literal two characters {{). No functions, filters or loops. A malformed placeholder fails the load with the line and column — for a file, the file's own line.
  • prompt_template_file follows prompt_file's rules — resolved path stored, readable and non-empty checked at load, read again at each spawn — plus one: the file is parsed at load, and re-parsed at spawn, because the file can change. A template edited into a grammar error after load fails that run's start and spawns nothing.

Scheduled one-shots​

Give an agent one-shot a schedule (a standard cron expression, validated at config load) and the daemon fires it on that cadence — ADR-0013's replacement for the original SPEC-0008 timer design:

[harness.fleet-sweep]
harness = "crush"
prompt = "check all services and report anything unhealthy"
auto_accept = true
schedule = "CRON_TZ=UTC 0 */6 * * *" # every 6 hours
description = "fleet health sweep"

For a full walkthrough — prompt design, run history, and reading outcomes — see the Scheduled sweeps guide.

Rules:

  • Requires prompt or prompt_file, or harness = "command" — only one-shots can be scheduled, and a command harness's argv is its whole job (see The command kind).
  • At each firing the harness starts if no run is in flight; a firing that lands mid-run follows on_overlap (below), and never stacks a second process.
  • The run exiting is terminal for that firing; the restart policy applies only to abnormal exit, so only restart = "no" (the prompt default) and "on-failure" are accepted here.
  • Mutually exclusive with enabled = true and with profile membership.
  • Global config only — project files reject the key.
  • harness stop <name> pauses the schedule until an explicit harness start (a harness trigger runs once without re-arming); the pause survives a daemon restart, and windows that pass while stopped are recorded as skipped runs (CLI → Scheduled jobs).

Time zones​

By default an expression runs in the daemon's local zone. Prefix it with CRON_TZ=<zone> (or TZ=<zone>) to pin it to an IANA zone regardless of where the daemon runs:

schedule = "CRON_TZ=UTC 0 9 * * *" # 09:00 UTC on every host

Across daylight-saving changes, a schedule that names a time of day runs once per day: a time the clock skips (02:30 on spring-forward day) runs just after the jump, and a time the clock repeats runs only the first time. A schedule that fires every hour, or an @every interval, keeps running once per real hour.

Sleep, outages, and catch_up​

catch_up is never periodic: however many firings were missed, it starts at most one run. It is accepted where something can miss a firing — schedule, a channel trigger, or operating_hours on a harness with triggers — and is rejected on presence anywhere else. The three cases:

Schedule. The daemon checks the wall clock every second rather than setting a timer, so a laptop that sleeps through a window notices the moment it wakes, and a daemon that starts after an outage notices at boot. A window noticed more than a minute late is missed, and catch_up decides what happens:

[harness.nightly-sweep]
harness = "crush"
prompt = "…"
schedule = "CRON_TZ=UTC 0 3 * * *"
catch_up = true # default false
  • catch_up = true — run once on wake or boot, however many windows were missed.
  • catch_up = false (default) — run nothing and log a scheduled run MISSED warning in the daemon log naming the harness, the first and last missed windows, and how many there were. The next window fires normally.

Channel trigger. On a harness with a channel.… trigger, one run when the channel connects for the first time since the daemon started, and one on every reconnect after the link was down for more than a minute. A flapping link re-fires about once per backoff ceiling (its backoff resets only after five open minutes, so a link that drops sooner every time climbs past the minute within a few flaps); blips shorter than a minute do not, because Switchboard re-rings unclaimed todos within minutes. The run carries no event file — it is the agent's cue to go and look (push events).

Operating hours. On a triggered harness with operating_hours, one run at the first in-hours check after the window reopens, if anything was skipped while it was closed (on a triggered harness).

The daemon records the last window it decided in state.json, so a restart never runs the same window twice. A missed window is also a missed entry in the harness's run history.

Runs: history, logs, timeout, overlap​

Every run of a scheduled harness gets a numbered record and a log of its own:

[harness.nightly-sweep]
harness = "crush"
prompt = "…"
schedule = "CRON_TZ=UTC 0 3 * * *"
timeout = "45m" # default "1h"; "0" = no limit
on_overlap = "queue" # default "skip"; or "replace"
keep_runs = 30 # default 20
  • History lives in the run ledger, $XDG_STATE_HOME/harness/ledger/: one append-only JSONL file per UTC day, private to your user (ADR-0028). Each run records its id, trigger (schedule, manual, catch_up, channel, webhook), start, end, exit code, and an outcome: success, failed, timed_out, skipped, replaced, missed, cancelled, interrupted, or stalled — a resident worker the stall watchdog ended because it had stopped making progress. Firings that start nothing (skipped, missed) are recorded too. Run ids never repeat. A run the daemon crashed under reads interrupted (reason daemon_crash) on the next boot; one a clean shutdown stopped reads interrupted (reason shutdown).
  • Logs are at $XDG_STATE_HOME/harness/jobs/<name>/<run_id>.log — the run's output history and lifecycle lines, alongside the usual harness log. Once the run closes, its log is compressed to <run_id>.log.zst (unless compress_logs = false); harness logs <name> --run N reads either. keep_runs bounds these log files only: the oldest logs are deleted, and their records stay in the ledger, marked log_pruned.
  • Streams. A stream-json one-shot also writes jobs/<name>/<run_id>.stream.jsonl: its stdout, one masked JSON line per line, private to your user. Its <run_id>.log then holds the lifecycle lines and the agent's stderr. The stream is compressed to <run_id>.stream.jsonl.zst when the run closes, like the log. keep_runs deletes a run's stream with its log. harness logs <name> --run N renders the stream as readable entries (its raw masked tail when it parses to nothing), then the run log once the run has ended — see CLI → Logs.

:::note Upgrading from a release before the ledger The first daemon that has the ledger copies each harness's run history out of state.json into ledger/ once, then drops it from state.json, which keeps only each harness's last run id. Downgrading past that release loses the view of run history. :::

  • timeout stops a run that goes on too long: SIGTERM, then SIGKILL after the stop grace. The run is timed_out and the harness shows failed.
  • on_overlap decides a firing that lands while a run is still going: skip records it and does nothing; queue runs it once the current run finishes (holding at most one); replace stops the current run and starts the new one.

All three keys require schedule. Read history and per-run logs from the CLI with harness jobs, harness runs <name> and harness logs <name> --run N, and run a job now with harness trigger <name> — see CLI → Scheduled jobs.

harness list gives the schedule its own columns — SCHEDULE (the cadence) and NEXT (the countdown) — and reads the state as ⏱ armed, since a cron job between firings is waiting rather than switched off. An unscheduled harness shows an em dash in both columns. harness describe adds an armed row, the cron spec and the absolute next-run time. A zone-prefixed schedule's cadence carries the zone (daily 09:00 UTC). The cockpit tags the row (scheduled) with the same countdown, and carries the cadence on the row's sub-line.

Operating hours​

operating_hours gives a resident harness a weekly window it is allowed to run in — ADR-0019's daemon-owned alternative to an external launchd/cron timer calling harness start/harness stop. Outside its windows the gate holds the harness down (stops the process, enabled untouched) and starts it again when a window opens:

[harness.night-owl]
harness = "claude-code"
args = ["--remote-control", "--continue"]
enabled = true
operating_hours = "TZ=America/Los_Angeles Mon-Fri 09:00-13:00"
hours_shutdown = "graceful" # default; or "immediate"
hours_shutdown_timeout = "15m" # default; the most a graceful close may overrun

Rules:

  • Global config only — project files reject the key, like schedule.
  • Mutually exclusive with schedule: a scheduled one-shot is already time-gated by its own cron expression, and two time gates on one harness would disagree.
  • enabled still means "the operator wants this running". Hours sit beside it, never overwrite it: a held harness stays enabled = true, and harness stop always stops the harness and clears enabled, whatever state it is in.
  • The gate only ever starts what it held. A harness with enabled = false, or one you harness stopped, stays down when the next window opens (harness doctor warns about the first case).
  • Outside its hours a harness shows off-hours, never stopped or failed, so a closed window never reads as a fault (CLI → Operating hours).
  • operating_hours on a harness with triggers loads, but gating its firings is coming (SPEC-0014); today a trigger fires it at any hour.

On a triggered harness: hours gate firings​

On a harness that sets triggers (and no schedule), operating_hours gates firings, not the process:

# Assumes [webhook.gitea-pr] and [channel.switchboard] tables are declared
# too (see Webhook listener below); a triggers entry naming an undeclared
# source fails to load.
[harness.pr-review]
harness = "claude-code"
prompt_file = "~/.config/harness/prompts/pr-review.md"
triggers = ["webhook.gitea-pr", "channel.switchboard"]
operating_hours = "TZ=America/Los_Angeles Mon-Fri 09:00-18:00"
catch_up = true # one run when hours open, if anything was skipped
  • A doorbell or webhook delivery that arrives outside the window starts nothing. The run history gains a skipped record with reason outside_hours, and a burst of them coalesces into one record with a coalesced count. The webhook's 202 reports that harness as skipped.
  • The window is judged at the moment the daemon received the event, and it is end-exclusive: a delivery at exactly 18:00:00 is out of hours.
  • A run already going when the window closes keeps going. Its timeout bounds it, not the hours.
  • With catch_up = true, the first check after the window opens starts one run with trigger catch_up if anything was skipped while it was closed, however many deliveries that was. The run carries no event file; it is the agent's cue to go and look. The daemon remembers the owed catch-up across a restart.
  • harness trigger <name> is never gated.
  • hours_shutdown and hours_shutdown_timeout do not apply here and are rejected: there is no resident session to close.

Grammar and time zones​

operating_hours is one string: an optional TZ=<zone> or CRON_TZ=<zone> prefix (same resolution as schedule's — defaults to the daemon's own zone when omitted), then one or more ;-separated windows of [day-spec] HH:MM-HH:MM:

WrittenMeans
09:00-13:00every day, 09:00 up to (not including) 13:00
Mon-Fri 09:00-13:00weekdays
Mon-Fri 09:00-12:00; Mon-Fri 13:00-17:00a lunch break
Sat,Sun 10:00-12:00a day list
Sun-Thu 22:00-02:00overnight — an end at or before its start runs into the next day
Fri-Mon 18:00-23:00a day range wraps the week: Fri, Sat, Sun and Mon
CRON_TZ=Europe/Berlin Mon-Fri 08:30-18:00pinned to a zone other than the daemon's
Mon-Sun 00:00-24:00always in hours (valid, and harness doctor warns that it gates nothing)

24:00 is accepted only as an end, meaning the end of the day.

Validation errors​

A bad value fails config load with the file, line, harness and key, so harness doctor (or the daemon's reload) shows exactly what to fix:

MistakeError
operating_hours = " ""operating_hours" must not be blank
Mon-Fry 09:00-17:00window "Mon-Fry 09:00-17:00": unknown day "Fry" (want Mon, Tue, Wed, Thu, Fri, Sat, or Sun)
9-5window "9-5": start time "9": malformed (want "HH:MM")
09:00-09:00window "09:00-09:00": start and end must not be equal
TZ=Mars/Olympus 09:00-17:00unknown time zone "Mars/Olympus"
operating_hours with schedule"schedule" and "operating_hours" are mutually exclusive (a scheduled one-shot is already time-gated by its cron expression)
hours_shutdown without operating_hours"hours_shutdown" requires "operating_hours" (a shutdown mode with no hours to close does nothing)
hours_shutdown = "later"invalid "hours_shutdown" "later" (want "graceful" or "immediate")
hours_shutdown_timeout = "0s"invalid "hours_shutdown_timeout" "0s" (want a positive duration such as "15m")

Closing: graceful by default​

hours_shutdown = "graceful" (the default) lets the harness finish its current turn before stopping it, capped by hours_shutdown_timeout (default 15m); "immediate" stops it at the close without waiting. Either way the stop is not a crash: no restart, no restart-count increment, no flap, and enabled survives. Graceful shutdown depends on the daemon reading turn-end markers from the harness's own agent-trace; a harness with nothing attributable to it (a generic or command adapter, or no workdir) always closes immediately, and harness doctor warns when hours_shutdown = "graceful" is set on one anyway.

Overrides: an after-hours lease​

harness start <name> outside a gated harness's hours starts it under a bounded after-hours lease — one hour by default, harness start <name> --for 3h for a chosen length. The gate stops it at the lease's end exactly as it would at a close; if hours open first, the lease simply ends and the harness keeps running as an ordinary in-hours one. See CLI → Operating hours for how a gated harness's state, lease and close in flight show on every listing surface (off-hours, closing, lease until …) — no extra column, the existing STATE/SCHEDULE/NEXT columns carry it.

Run budgets​

:::note Partly enforced

max_runs_per_day and day_starts are enforced: every start passes admission, and a spent run budget refuses it (below). So is quota parking: quota_group, quota_backoff and quota_backoff_max act as described under When a provider's quota runs out. The other keys load, are checked, and survive a TUI edit, but nothing acts on them yet: no cost is metered, and max_concurrent queues nothing. Concurrency and the cost caps land in later releases (SPEC-0021, ADR-0027). Setting them now is safe, and a bad value fails today with the same located error it will fail with then.

:::

Budgets bound what an unattended agent may spend: runs per day, tokens and dollars per run, dollars per day, and how long a harness waits when its provider says the quota is exhausted. Each harness may carry these keys:

[harness.pr-review]
harness = "claude-code"
prompt_file = "~/.config/harness/prompts/pr-review.md"
schedule = "*/15 * * * *"
max_runs_per_day = 40 # admissions per budget day
max_tokens = 400000 # per run: input + output + cache writes
max_cost_usd = 2.00 # per run
daily_cost_usd = 20.00 # this harness, per budget day
quota_group = "claude-max" # harnesses on one allowance park together
quota_backoff = "15m" # default; first park when no reset time is given
quota_backoff_max = "6h" # default; the longest backoff park
KeyValueOn
max_runs_per_daywhole number, at least 1any harness; on a resident, every process start counts, restarts included
max_tokenswhole number, at least 1one-shots only (prompt or prompt_file set)
max_cost_usddollars, above 0one-shots only
daily_cost_usddollars, above 0any harness
quota_group1–64 of a-z, 0-9, ., _, -any harness
quota_backoffduration, at least 1m (default 15m)any harness
quota_backoff_maxduration, at least quota_backoff, at most 24h (default 6h)any harness

A [budget] table sets the daemon-wide limits and the prices used to cost a run whose agent records tokens but no dollars:

[budget]
day_starts = "TZ=America/Los_Angeles 00:00" # default 00:00 in the daemon's zone
max_concurrent = 4 # triggered one-shot runs in flight at once
daily_cost_usd = 50.00 # every harness together, per budget day

[budget.prices."claude-sonnet-4-6"] # per million tokens, by served model name
input_per_mtok = 3.00 # required
output_per_mtok = 15.00 # required
cache_write_per_mtok = 3.75 # optional, default 0
cache_read_per_mtok = 0.30 # optional, default 0

[budget.group.claude-max] # harnesses with quota_group = "claude-max"
max_concurrent = 2
  • Global only. A project harness.toml or a harness_d drop-in that sets any of these keys, or contains [budget], is refused with the key and the line: a cloned repository, or a unit dropped in beside the config, does not get to set what your agents spend.
  • Per-run caps need a one-shot. A resident harness's "run" is a process that can live for days, so max_tokens and max_cost_usd there are refused; bound it with max_runs_per_day and daily_cost_usd.
  • day_starts takes the same TZ=/CRON_TZ= prefix as schedule and operating_hours, then one HH:MM (00:00–23:59).
  • Dollars may be written as integers (daily_cost_usd = 20); inf and nan are refused.
  • A [budget.group.<name>] that no harness's quota_group names still loads.

When a run budget is spent​

Every process start passes admission first: a firing, harness trigger, harness start, autostart, a reload, a lease, a hold's release, and the supervisor restarting a harness after it exits. Admission counts the start in the run ledger as it admits it, so two firings racing for the last run of the day cannot both get it. With max_runs_per_day = N, the start after the Nth of the budget day is refused:

  • A one-shot firing is recorded skipped with reason budget in harness runs, and a burst of them is one coalesced record. With catch_up = true, the first tick of the next budget day starts one catch_up run, which counts toward the new day.
  • A resident is held over-budget, enabled unchanged, until the day rolls over. A crash loop therefore spends its budget and stops there, held rather than failed.
  • harness start, harness restart and harness trigger on a spent harness fail with over budget: 40/40 runs today (error code over_budget). A restart is admitted before the running process is stopped, so a refused one leaves that process running.

The budget day runs from one day_starts to the next, as long as the wall clock makes it across a DST change, and rolls over on the scheduler's tick: a laptop that slept through it starts a fresh day on the first tick after it wakes. "Runs today" is read from the run ledger, so a daemon restart does not refund it. A reload applies on the next tick: raising a spent cap releases the hold, lowering one below today's count holds a running resident, and a new day_starts takes effect at the next rollover.

If the run ledger cannot be written, a harness with any budget key is refused with ledger_unavailable rather than run uncounted, and one with none starts anyway; harness doctor reports both, and any records boot found missing from the ledger.

When a provider's quota runs out​

A provider that refuses every call for an exhausted quota (a rate limit, a spent weekly allowance, an empty credit balance) is not fixed by restarting the agent. The daemon parks the harness instead: no process starts until the quota is expected back, and nothing about the refusal counts toward failed. It reads the refusal from the agent's own transcript, the same error marks the harness_model_call_errors_total metric counts, never from the run's output.

  • When. A quota error that names a reset time (resets 3pm (America/Los_Angeles), try again in 2h13m, |1790000000, a relayed Retry-After) parks until then, clamped to 8 days. Without one, a one-shot run that exits non-zero on a quota error parks, and so does a resident that meets three quota errors within ten minutes with no successful call between them. A run that exits 0 never parks.
  • How long, with no reset time. quota_backoff (default 15m), doubled for each park in a row without a successful call between, up to quota_backoff_max (default 6h). A successful call starts it over.
  • A one-shot. The run that hit the quota reads quota_parked in harness runs, the harness is left stopped (never failed), and every firing until the reset is recorded skipped with reason quota_parked, coalesced, without starting the agent. With catch_up = true, one catch_up run starts when the park clears.
  • A resident. It stops at once, with no restart, enabled unchanged, and starts again by itself at the reset, through admission (so a harness out of hours by then waits for its window).
  • quota_group. A park on one member parks every harness with the same quota_group until the same instant: one account-level limit stops the whole pipeline at once, and it all comes back together.
  • Restarts. A park is kept in state.json (the reset, the matched rule's name, never the error text), so a daemon restarted mid-park boots the harness parked. The daemon watches for quota errors from before it starts anything, so a run that hits its quota at boot parks too.
  • Every park ends. A one-shot whose park ended while the daemon was down, or a failed member of a parked quota_group (a park does not hold a failed harness, but its firings are still skipped), is released on the first tick after the reset: its skipped firings are settled, and with catch_up = true it gets its one catch_up run.

Each park writes a parked line to the harness's durable log naming the rule, the reset and that no restart happens, and harness list --json carries hold_reasons: ["quota"] while it lasts. harness start on a parked harness fails with parked until … (error code parked).

A bad value fails the load (or a reload, which keeps the previous config) with the file, line, harness and key:

MistakeError
max_tokens on a resident harness"max_tokens" is a per-run cap, and per-run caps apply only to one-shot harnesses (…)
max_runs_per_day = 0"max_runs_per_day" must be a whole number of at least 1 (got 0)
max_cost_usd = -1"max_cost_usd" must be a number of dollars greater than 0 (got -1)
quota_backoff_max = "48h""quota_backoff_max" must be a duration no longer than 24h, such as "6h" (got "48h")
quota_backoff = "8h" alone"quota_backoff" must not exceed quota_backoff_max, which defaults to 6h; set quota_backoff_max too (got "8h")
a budget key in a project file"max_runs_per_day" is not accepted in a project file (budgets are only accepted in the global config: …)
day_starts = "TZ=Mars/Olympus 00:00"[budget] "day_starts": invalid "TZ=Mars/Olympus 00:00": unknown time zone "Mars/Olympus"
a price without input_per_mtok[budget.prices."gpt-5"]: missing required "input_per_mtok" (…)

Agent adapters​

The harness key is a required enum selecting the adapter (ADR-0011, SPEC-0006): crush, claude-code (what it runs), codex, pi, omp, opencode, generic, command. It has no default — what a harness runs is the most consequential thing it declares, so a table that omits the key is a config error rather than an agent nobody asked for:

harness "web": missing required key "harness" (want one of: crush, claude-code,
codex, pi, omp, opencode, generic, command — use "command" with argv = ["…"] for an
arbitrary program)

The adapter owns both the tool-specific behaviour (trajectory discovery) and the executable a long-running harness runs; it also synthesizes the CLI-specific argv for prompt one-shots. generic means "none of the above" — its executable is sh, so an arbitrary command is expressed as args = ["-c", "<command line>"], and it reports no native trajectory (scrollback-only). Note that args are handed to sh itself: a bare args = ["/usr/local/bin/thing"] asks sh to interpret that file as a shell script, which fails on a compiled binary.

generic takes no prompt. It runs sh and has no prompt synthesis, so a generic harness that sets prompt or prompt_file fails to load, in the global config, a harness_d drop-in or a project file, and project up, a scratchpad and the edit form refuse it too:

harness "triage": "generic" runs sh and has no prompt synthesis, so it takes no
"prompt"; use harness = "crush"|"claude-code"|"codex"|"pi"|"omp"|"opencode" for a
prompt one-shot, or
harness = "command" with argv to run another program without a shell

(It used to run crush run <prompt> instead, whether or not you had Crush.)

The command kind​

harness = "command" runs a program you name, directly. Its argv is the whole process: argv[0] is the executable and every later element is one argument, handed to it byte for byte. Nothing runs a shell and nothing builds a command string, so an element holding spaces, $(…), ; or quotes is exactly one argument with exactly that text (ADR-0023, SPEC-0017 REQ-2).

[harness.report-server]
harness = "command"
argv = ["/usr/local/bin/report", "--listen", ":8080", "--title", "Nightly report"]
workdir = "~/src/report"
enabled = true
  • argv is required and must be non-empty. argv[0] must not be blank.
  • A bare argv[0] ("report") is looked up on PATH, like every other harness's executable. An absolute one is used as is. A relative one with a / ("./bin/report") resolves against the harness's workdir. Without a workdir it resolves against the daemon's own working directory, which depends on how the daemon was started — so set workdir, or use an absolute path.
  • Elements are not expanded: {workdir} and ~ in argv stay literal. argv[0] can never be a {{…}} placeholder, so nothing substituted at run time can choose what runs. The other elements may be templates; see Argv templates.
  • args is rejected on a command harness (put everything in argv), and argv is rejected on every other kind.
  • auto_accept, max_turns and quiet are rejected: the harness owns its argv, so put the program's own flags there. model is accepted only when an argv element references {{model}}; otherwise it does nothing and is rejected.
  • With no schedule and no triggers it is a resident harness. It takes enabled, restart, restart_delay and operating_hours with the same defaults as generic (restart = "always").
  • With schedule or triggers it is a one-shot, and needs no prompt: the argv is the whole job. Each firing produces a run record, a per-run log, timeout, on_overlap and keep_runs, exactly as for an agent one-shot, and it defaults to restart = "no". Every other one-shot exclusion still applies (enabled = true, restart = "always", operating_hours with a schedule, and so on).
  • prompt and prompt_file are refused for now: nothing delivers a prompt to a command harness's argv yet (issue #500).
  • Like generic, it reports no native trajectory (scrollback only), unless it binds one with transcripts (below).
  • It works in a project harness.toml, through harness up, and in the TUI edit form, where argv is edited as the same TOML array. harness describe shows the kind and the argv exactly as written, templates included, never a rendering.
[harness.nightly-report]
harness = "command"
argv = ["/usr/local/bin/report", "--run", "{{run.id}}", "--date", "{{run.date}}"]
schedule = "CRON_TZ=Europe/Berlin 0 6 * * *"
workdir = "~/src/report"

Any agent CLI not listed above runs this way: harness = "command", argv = ["…", …], resident, on a schedule, or on triggers. Pi, OMP and OpenCode have adapters of their own (below).

Argv templates​

An argv element after argv[0] may contain placeholders. Each element is rendered once per spawn into exactly one argument: nothing is split, trimmed, globbed or re-quoted, and an element that renders empty is passed as an empty argument, so the program always receives as many arguments as argv has elements (SPEC-0017 REQ-6, REQ-11).

FormMeaning
{{path}}a required value: if the run does not have it, nothing runs
{{path?}}an optional value: empty when the run does not have it
{{literal_open}}the two characters {{

A lone }} is literal text. There are no functions, filters, pipelines, conditionals or loops; {{printf …}} is a config error.

argv may reference these paths, and nothing else:

PathValuePresent when
harness.namethe harness's namealways
harness.workdirthe expanded working directoryalways
modelthe harness's modelmodel is set
package.dirthe directory of the installed package pinthe harness has source
run.idthe run's IDthe harness has schedule or triggers
run.triggerschedule, catch_up, manual, channel or webhookthe harness has schedule or triggers
run.sourcethe trigger source, e.g. webhook.cia source caused the run
run.started_atthe run's start, RFC 3339 UTCalways
run.dateYYYY-MM-DD in the schedule's CRON_TZ/TZ zone, else the daemon's local zonealways
event.filethe event file's absolute paththe run carries an event
event.idthe event's ID (the sender's delivery ID, or a daemon-derived evt-…)the run carries an event
event.kindchannel or webhookthe run carries an event
event.sourcethe trigger source, e.g. webhook.cithe run carries an event
event.received_atwhen the daemon received the event, RFC 3339 UTCthe run carries an event
event.name, event.action, event.repo, event.number, event.url, event.shathe event's validated typed valueextraction yielded it for the scheme
event.meta.<key>a validated channel meta valuethe channel event's meta carried <key> with an ID-shaped value

Every template is checked when the config loads, and each failure names the element, line and column:

  • a malformed placeholder, or an unknown path;
  • event text in argv in any form ({{event.title}}, {{untrusted event.body}}, …): text an outside party wrote never becomes an argument. Give the program {{event.file}} to read instead;
  • {{prompt}} and {{prompt_file}} (a command harness has no prompt delivery yet), and {{event.actor}} (trusted_actors is not implemented);
  • a required {{run.id}}, {{run.trigger}} or {{run.source}} on a harness with neither schedule nor triggers (it has no run records, so it could never render);
  • a required {{run.source}} on a harness with a schedule (a clock firing has no source, so every scheduled run would be skipped). Write {{run.source?}};
  • a required event.* on a harness with a schedule (a clock firing carries no event, so every scheduled run would be skipped), or on one with neither schedule nor triggers (it never has an event at all). Write {{event.repo?}};
  • a required {{model}} with no model set.

If a required value is still absent when a run spawns — a manual run of a triggered harness has no run.source, say — nothing is exec'd. The run is recorded skipped with reason template_unresolved and the missing path's name, which harness runs prints under the table and --json carries as reason and missing_path. A start with no run record fails instead, with a start failed line naming the path in the harness log. Either way harness_template_render_failures_total counts it.

Rendered values are never written anywhere: not to state.json, not to run records, not to protocol frames, not to logs. They exist only in the child's argv, which, like any argv, other local users can read with ps. Do not template secrets into it. Pi and OMP have adapters of their own (below). Any other agent CLI runs this way, as a resident harness, with transcripts if it writes one of the formats Harness reads.

Binding transcripts​

A command harness that runs an agent CLI by hand can declare whose transcripts it writes, so the daemon discovers its sessions, correlates them to its runs, attributes its tool calls and counts them in the model-call metrics exactly as it does for that adapter's own harnesses (SPEC-0017 REQ-4):

[harness.claude-by-hand]
harness = "command"
argv = ["claude", "--remote-control", "--continue"]
transcripts = "claude-code"
workdir = "~/src/app"
enabled = true
  • The value is one of claude-code, crush, codex, pi, omp or opencode; anything else fails to load, listing those.
  • It is accepted on command only. An adapter kind already binds its own transcripts, so transcripts on crush (even transcripts = "crush") is a config error.
  • It changes what is observed, never what runs: the argv is exec'd exactly as written. Store relocation follows the named adapter's rules, read from the harness's env_file (CLAUDE_CONFIG_DIR, CRUSH_GLOBAL_DATA, CODEX_HOME, PI_CODING_AGENT_DIR), and crush's --data-dir in argv is not read.
  • It is the fallback when an adapter's flags drift from the CLI: run the CLI with the flags it now takes, and keep the observation.
  • harness describe shows it next to the argv.

Pi and OMP​

pi runs the Pi coding agent and omp runs OMP (oh-my-pi), a Pi fork. They are ordinary adapters: a resident harness runs pi or omp with args appended, and a prompt one-shot runs the print mode in the table above, with model passed as --model in the CLI's provider/id form. auto_accept and max_turns are accepted and add nothing, since neither CLI prompts for tool permission or has a turn budget.

[harness.omp-review]
harness = "omp"
model = "openrouter/z-ai/glm-5.3-flash"
prompt = "review the open pull requests"
schedule = "0 7 * * 1-5"
workdir = "~/src/app"

The flags were checked against the source of Pi v0.87.1 and OMP v18.3.0.

Observation. Both are observed. A pi or omp harness's sessions are read from $PI_CODING_AGENT_DIR/sessions, or ~/.pi/agent/sessions / ~/.omp/agent/sessions when the variable is unset, resolved from the harness's own env_file. They are attributed to it and counted in the model-call metrics. OMP's session files open with a fixed-width title line before the session header; the session reader accepts it, and labels OMP sessions omp so an omp harness claims exactly its own.

[harness.my-agent]
harness = "claude-code"

OpenCode​

opencode runs OpenCode, the SST coding agent. A resident harness runs opencode with args appended, and a prompt one-shot runs opencode run with model passed as --model in the CLI's provider/model form. auto_accept, max_turns and quiet are accepted and add nothing: OpenCode declares tool permissions in its own configuration rather than through a skip-permissions flag, and has no turn budget or quiet switch.

[harness.oc-triage]
harness = "opencode"
model = "anthropic/claude-opus-5"
prompt = "triage the open issues"
schedule = "0 8 * * *"
workdir = "~/src/app"

⚠️ The argv is not verified against a real binary. It was written from the adapter template and the CLI's documented run mode, because no opencode was installed where this was built (issue #919). Check opencode run --help on the host: if the flags have drifted, run the CLI as harness = "command" with your own argv and transcripts = "opencode" until the adapter is updated.

Observation. Observed through the same session reader agent-trace has used for OpenCode since before v0.4.0 (ADR-0033). OpenCode keeps every session in one SQLite database, ~/.opencode/opencode.db, so a harness's sessions are the rows recorded with its own working directory, and the database follows the HOME its env_file gives it. Its sessions are attributed to it and counted in the model-call metrics. A database the CLI has not written yet is no sessions, not an error.

Agent packages and stables​

A harness table can take its values from an installed agent package instead of spelling them out (ADR-0044, SPEC-0026). harness agent install writes both tables below; edit the local keys by hand, but leave [stable.*] and the source line to harness agent. The walkthrough is Install shared agents from a stable.

[stable.stump-wtf]
remote = "https://github.com/stump-wtf/harness-stable.git"
public = true # optional; unset means true

[harness.pr-reviewer]
source = "stump-wtf/pr-reviewer@0123456789abcdef0123456789abcdef01234567"
schedule = "CRON_TZ=UTC 30 9 * * *"
workdir = "~/sweeps/pr-reviewer"
KeyWhereMeaning
remote[stable.<name>]the stable's git URL; <name> matches ^[a-z][a-z0-9-]*$
public[stable.<name>]informational; defaults to true
source[harness.<name>]<stable>/<package>@<40-hex sha>: an installed pin

Global only. [stable.*] is refused in a project harness.toml and on the project-up wire, like [adapter.*] and [skill_repo.*]: a cloned repository must not widen what a machine trusts.

Resolution at load. The daemon reads the pinned package.toml from $XDG_STATE_HOME/harness/agents/installed/ and never fetches. A missing pin fails the load, keeping the daemon on its last good config, and names the harness agent install to run.

Merging. The package's [harness] values fill keys the table leaves unset, and a key set on the table wins:

  • Prompt sources merge as one group. prompt, prompt_file, prompt_template and prompt_template_file exclude one another. A table that sets any of them drops the package's prompt entirely.
  • Paths resolve inside the pin. A package's prompt_file, prompt_template_file, system_prompt_file, mcp_config and skill_paths resolve against the pin's directory, never against the harness's workdir.
  • Bundled skills. A package's skills/ joins the ordered skill merge only when its [requests].skill_paths is true, at the lowest precedence. An adapter default, global or project skill with the same name shadows it.

What a package may set.

  • Allowed: harness, args, argv, model, auto_accept, max_turns, quiet, one prompt source, system_prompt_file, mcp_config, allowed_tools and skill_paths.
  • Rejected by name: schedule, triggers, enabled, restart, restart_delay, operating_hours, workdir, env_file and secrets_env. The firing, the supervision and the secrets are always the table's.
  • Also refused: a string containing ${, and a path that is absolute, starts with ~, or climbs out of the package.

Model routing and provider failover​

When you pin a harness to a single model on a single provider, a quota wall or provider outage stops every harness sharing it — simultaneously. The restart policy does not help: the process is healthy, and the API is refusing every call.

Where it shows up​

Not in harness list — a harness whose provider has hit a quota limit shows running. The failure is visible in:

  • harness logs <name> — repeated provider errors in the output stream
  • The provider's dashboard — quota exhaustion or rate limits

Supervision cannot see upstream refusals; it only knows whether the process restarted.

Mitigation: an OpenAI-compatible gateway​

A gateway in front of your providers (LiteLLM is one example) lets multiple deployments share a single model_name, with failover and retries between them:

# Example: LiteLLM proxy config
model_list:
- model_name: glm-5.3-balanced # deployment 1
litellm_params:
model: openai/glm-5.3
api_base: https://provider-a.example.com/v1
api_key: os.environ/A_KEY

- model_name: glm-5.3-balanced # deployment 2
litellm_params:
model: openai/glm-5.3
api_base: https://provider-b.example.com/v1
api_key: os.environ/B_KEY

router_settings:
routing_strategy: simple-shuffle
num_retries: 2
allowed_fails: 2
cooldown_time: 300 # re-probe after 5 minutes
fallbacks:
- glm-5.3-balanced: ["<cheap-tier-model>"]

The harness pins model = "litellm/glm-5.3-balanced" and knows nothing about providers. Failover happens inside each request, and cooldown_time automatically retries the benched provider after its quota resets.

Per-request vs per-worker failover​

Running one worker per provider is not equivalent:

  • Per-worker failover burns the restart budget on each dead worker until it hits the failed state (terminal, needs a human).
  • Capacity halves while one worker recovers.
  • A gateway retries the sibling deployment inside the same request and keeps every worker productive.

The trade​

A gateway is a dependency on the hot path. Failing over from a subscription-metered provider to a pay-per-token one removes a fail-closed spend cap. That is the right trade for an always-on agent and the wrong one for an unattended cron job that nobody is watching.

Cost and observability​

One gateway also means one set of spend and latency metrics instead of per-provider guesswork — useful even when quota exhaustion is not your primary risk.

Model pins​

The opposite of failover: a [harness.<name>.model_pin] sub-table pins the harness to one model on one route, with an explicit list of the providers allowed to serve it, and no fallbacks. The pin is fail-closed by design — a call the pin does not approve stops the harness instead of proceeding — and in this release the table loads, validates and round-trips; the runtime enforcement (rendering, scrubbing, attestation, the mismatch stop) ships with the adapter renderer work, as SPEC-0020 specifies.

[harness.implementer.model_pin]
route = "openrouter"
model = "z-ai/glm-5.3-flash"
providers = ["deepinfra"]
data_policy = "zdr"

The pin is valid on resident and one-shot harnesses alike, in the global file, a drop-in, or a project harness.toml, and it is mutually exclusive with model (the pin supplies the model). Validation runs at config load — allow_fallbacks = true, an auto-router or routing variant in model, providers on the anthropic route, a litellm pin missing gateway_model/gateway_url, or attest = "full" on a client whose transcripts evidence only the model are all located load errors. The key table, the per-client route offers, and how the pin surfaces in the daemon protocol and the TUI are in Model pins; the decision is ADR-0026 and the behaviour is SPEC-0020.

Trajectory harvesting & facade scope​

:::note Reserved, not yet active

The daemon does not run the MCP facade (ADR-0010) today. These keys are validated and kept in config — the TUI edit form round-trips them — but setting them changes nothing at runtime yet. They are not the telemetry opt-in: exporting to a collector is a different audience, gated by export_telemetry (see Telemetry export).

:::

  • harvest_trajectory = true (default false) is the opt-in for exposing this harness's session transcripts read-only through the planned MCP facade (list_trajectories / get_trajectory). Opt-in because a transcript may contain secrets the harnessed program printed itself (ADR-0008).
  • mcp_allow (default ["read"]) lists the operations this harness will be permitted to invoke through that facade; "write" would permit harness_start/stop/restart. Global config only — project files already reject the key, so a cloned repository cannot grant itself write authority over the fleet.

Daemon settings ([daemon])​

[daemon]
watch_config = true # auto-reload on config file changes (default true)
scrollback_bytes = "1MiB" # per-harness scrollback ring storage (64KiB–1GiB)
scrollback = 10000 # and at most this many lines of it
log_level = "info" # debug, info, warn, error
log_file = "/var/log/harness.log" # absent = stderr
socket = "/run/harness/harness.sock" # absent = $XDG_RUNTIME_DIR/harness.sock
compress_logs = true # zstd-compress sealed logs (default true)
memory_limit = "2GiB" # Go soft memory limit; absent = GOMEMLIMIT or off
pprof_addr = "127.0.0.1:6060" # net/http/pprof, loopback only; absent = off

Every key is optional. Apart from watch_config, each has a matching flag and HARNESS_* variable (see Environment variables), and either one beats the file. Setting them here is how you tune a daemon you do not launch yourself — a Homebrew brew services daemon, a launchd agent, a container entrypoint — without editing the service definition.

  • scrollback_bytes is the memory each running harness's scrollback ring may use: its recent raw output, which an attach replays after the screen snapshot. It is the lever for daemon memory use. Write a size ("512KiB", "4MiB", "1GiB"; every unit is 1024-based) or a whole number of bytes. It must be between 64 KiB and 1 GiB. The default, 1 MiB, is about 13,000 lines of ordinary terminal output. Raising it makes every attach, and every dashboard preview, replay more before going live.
  • scrollback must be at least 1. It caps the lines the ring keeps, whatever their size; the byte budget usually binds first.
  • A line longer than the per-line cap (64 KiB, or a quarter of scrollback_bytes if that is smaller) keeps only its head, followed by …[harness: truncated N bytes], where N counts what was cut. Only the replay is truncated: live output and harness logs are not.
  • In-TUI scroll and search read the durable log, not this ring, so neither setting changes how far back they reach.
  • socket and log_file must be absolute paths; ~ is not expanded. The CLI reads socket from this file too, so harness ls finds a daemon on a non-default socket without a --socket flag.
  • compress_logs compresses a log once nothing will write to it again: each rotated backup of a harness's log, and each closed run's log and raw stream. See Supervision → Logs on disk. Set it to false to keep every log plain; files already compressed stay readable.
  • scrollback_bytes, scrollback, log_level, log_file, socket, compress_logs, memory_limit and pprof_addr are read when the daemon starts. Changing them needs a daemon restart, not a reload. Until you restart, a changed socket points the CLI at a socket the running daemon is not on.
  • harness doctor shows which source supplied each value.

Memory limit and profiler​

Both are off by default. They are guardrails for the daemon's own memory, not for the agents it runs.

memory_limit sets the Go runtime's soft memory limit (--memory-limit, HARNESS_MEMORY_LIMIT). As the heap nears the limit, the GC runs harder so the daemon stays under it, instead of growing to about twice its live heap, which is the default (GOGC=100).

  • Write a size, such as "2GiB" or "1536MiB" (see sizes; the units are 1024-based, as in systemd's MemoryMax=). A bare number, or a TOML integer, is bytes, so memory_limit = 2048 is 2 KiB and the daemon warns. "0" or 0 means no limit.
  • It cannot free memory the daemon is still holding. A leak still grows, only with less headroom on top of it. Set below the live heap, it keeps the GC running almost continuously. Keep the hard cap in the init system (systemd MemoryMax=, a container limit). That cap usually covers every agent the daemon spawns as well, so size memory_limit for the daemon alone, well under it.
  • GOMEMLIMIT in the daemon's environment is honoured when none of --memory-limit, HARNESS_MEMORY_LIMIT and memory_limit is set. Any of them overrides it, and an explicit 0 removes it. Prefer memory_limit: every harness the daemon spawns inherits GOMEMLIMIT, and Go agents read it too.
  • The daemon logs the limit in effect and where it came from at startup (memory limit limit=2GiB source=file), and /metrics reports it as go_gc_gomemlimit_bytes.

pprof_addr serves Go's profiler (--pprof-addr, HARNESS_PPROF_ADDR) at http://<addr>/debug/pprof/.

  • It binds loopback only: 127.0.0.1, ::1 or localhost. Any other address fails the config load (and any reload) with its line number, or stops harness daemon at startup when it comes from the flag or the variable. No token unlocks a remote bind, as metrics_token_file does for /metrics. Heap profiles and goroutine dumps describe the daemon's internals, so reach them from another host through a tunnel: ssh -L 6060:127.0.0.1:6060 host.
  • A port that is already taken is logged, and the daemon runs without the profiler.

See Diagnosing daemon memory for how to use both.

otel_endpoint has been removed: it was accepted but never exported anything. A config that still sets it fails to load, with an error pointing here. To export agent telemetry, add a [telemetry] table with traces = true (and/or logs = true) and set endpoint there or OTEL_EXPORTER_OTLP_ENDPOINT in the daemon's environment. It is deliberately not an alias: an inert line in an old config must not start publishing agent transcripts on upgrade.

Telemetry export ([telemetry])​

The daemon can export what its supervised agents do — every tool call and every mark (user message, compaction, subagent launch, provider error) — as OTLP logs, OTLP traces, and a local JSONL events file. The production observability guide shows collector, Grafana (Loki/Tempo), Honeycomb and Vector/Fluent Bit setups; this is the key reference (ADR-0022, SPEC-0015).

Nothing is exported without two consents: a destination in this table, and a harness that opted in (export_telemetry = true on the harness, or export_all = true here). Environment variables alone never enable export — an OTEL_EXPORTER_OTLP_ENDPOINT inherited from your shell is only mentioned in the daemon log. With no destination configured the daemon opens no socket, creates no file and subscribes to nothing.

[telemetry]
logs = true # OTLP logs: one record per tool call or mark
traces = true # OTLP traces: one trace per agent session
events_file = "~/.local/state/harness/events.jsonl" # local JSONL; empty = off

export_all = false # every harness without export_telemetry contributes
omit_prompts = false # replace prompt text with "[prompt omitted]"

endpoint = "http://127.0.0.1:4318" # OTLP/HTTP base; /v1/logs and /v1/traces are appended
env_file = "/etc/harness/otel.env" # OTEL_EXPORTER_OTLP_* (headers/credentials go here)
compression = "none" # "none" | "gzip"
timeout = "10s" # per request

queue_size = 2048 # per signal (records, spans or lines); oldest dropped when full
batch_size = 512 # max per request; must not exceed queue_size
batch_interval = "5s" # flush a partial batch after this long
idle_flush = "5m" # export a session's trace after this long without items
shutdown_timeout = "5s" # total budget for the shutdown flush
events_file_max_mb = 100 # rotate the events file (by rename) at this size
events_file_keep = 5 # rotated files kept: events.jsonl.1 … .5
KeyDefaultMeaning
logsfalseenable the OTLP logs signal
tracesfalseenable the OTLP traces signal
events_file"" (off)path of the JSONL sink; ~ expands, relative paths resolve against the config file's directory. File 0600, created directories 0700
export_allfalseharnesses with no export_telemetry contribute
omit_promptsfalsereplace user-message text with [prompt omitted] and drop the session title
endpoint""absolute http/https base URL with a host and no userinfo
env_file""file supplying the OTEL_EXPORTER_OTLP_* variables below; only those keys are read, and none are put into the daemon's environment, so supervised harnesses never inherit the credential. Missing or unreadable fails startup when an OTLP signal is on; group/world-readable is a warning
compression"none"none or gzip
timeout"10s"per-request timeout
queue_size2048per-signal bounded queue; at most 16384
batch_size512units per request (or per events-file write); at most 4096 and at most queue_size
batch_interval"5s"partial-batch flush interval
idle_flush"5m"trace export after a quiet session; at least batch_interval
shutdown_timeout"5s"shutdown flush budget
events_file_max_mb100rotation size; at most 10240
events_file_keep5rotated files kept; at most 100

Durations must be positive; integers must be positive and under their ceiling — the queues are allocated at startup, so the ceiling is what stops a typo from exhausting memory before the first item arrives. A headers key is rejected — OTLP headers carry credentials, and harness.toml carries none (ADR-0008). Put them in OTEL_EXPORTER_OTLP_HEADERS, preferably via env_file.

Scope. The table belongs to the daemon's global harness.toml only: a project harness.toml and a harness_d drop-in both reject it. A project file may set export_telemetry = false on its harnesses but not true — a cloned repository does not get to publish its transcripts to your collector.

Changes need a restart. The exporters hold queues, connections and an open file, so an edited [telemetry] table takes effect on the next daemon start; a reload logs a warning saying so. Per-harness export_telemetry changes apply on reload, from the next item.

The OpenTelemetry environment​

The standard OTLP exporter variables are honoured, from the daemon's process environment first and then from env_file:

VariableMeaning
OTEL_EXPORTER_OTLP_ENDPOINTbase URL; /v1/logs and /v1/traces are appended
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, OTEL_EXPORTER_OTLP_TRACES_ENDPOINTfull signal URL, used as-is
OTEL_EXPORTER_OTLP_HEADERS (+ _LOGS_ / _TRACES_)name=value,name2=value2, values percent-decoded; merged, signal-specific wins per name
OTEL_EXPORTER_OTLP_COMPRESSION (+ _LOGS_ / _TRACES_)none or gzip
OTEL_EXPORTER_OTLP_TIMEOUT (+ _LOGS_ / _TRACES_)milliseconds
OTEL_RESOURCE_ATTRIBUTESextra resource attributes (cannot override service.name)
OTEL_SDK_DISABLED=trueturns both OTLP signals off

Precedence, per setting and per signal: signal-specific variable > generic variable > [telemetry] > default. An exported-but-empty variable counts as unset. OTEL_EXPORTER_OTLP_PROTOCOL set to anything but http/json is ignored with a warning: Harness speaks only OTLP/HTTP JSON. An enabled OTLP signal with no endpoint anywhere refuses to start.

At startup the daemon logs one line per signal with the resolved endpoint, the source of each setting (env, env_file, file, default), and each header as its name plus a SHA-256 fingerprint — never a value. harness doctor reports the same under a TELEMETRY section (.telemetry in --json), resolved in the shell you run it from: a daemon started by systemd with its own EnvironmentFile, or as a user who can read an env_file you cannot, may resolve differently, and the daemon's startup line is the authority.

What is exported​

Every string is redacted with the same rules as harness logs before it is queued, and capped (a record body at 4 KiB, a span name at 256 bytes, other attributes at 1 KiB, at most 32 targets). Tool output and file contents are never read, so never exported. A failed tool call is WARN; a provider or model error mark is ERROR, so an alert on ERROR means a turn failed. Log records and spans for the same item share traceId/spanId, and every item carries agent.item.id as a deduplication key.

A dead collector costs counted, dropped telemetry and nothing else: failed requests retry with backoff (429/502/503/504 and network errors, honouring Retry-After) for up to five minutes per batch, queues drop their oldest units when full, and failures reach the daemon log at most once a minute per signal.

Merge train ([mergetrain])​

The merge train lands approved pull requests one at a time. For each one it builds train/<pr> (main plus a squash of the PR), waits for CI on that exact commit, squash-merges, and then verifies that the tree that landed is the tree that was tested. It is off unless you turn it on, and even then it starts in report mode, which builds and tests trains but writes nothing to any pull request. See ADR-0032 and SPEC-0025.

[mergetrain]
enabled = true # default false
mode = "report" # or "merge"; default "report"
repos = ["stump.wtf/harness"] # required when enabled
base_branch = "main" # default "main"
poll_interval = "60s" # default 60s, minimum 5s
ci_timeout = "30m" # default 30m
flake_cool_down = "5m" # default 0 (retry off) until ADR-0056 is accepted; "5m" is the proposed value
forge_base_url = "https://gitea.stump.rocks" # required when enabled
forge_token_env = "HARNESS_MERGETRAIN_TOKEN" # the variable's NAME, required when enabled
  • The token is never in this file. forge_token_env names an environment variable in the daemon's environment, and a token key is refused. The daemon refuses to start if the train is enabled and that variable is empty.
  • A red train whose jobs never reached a runner is retried once after flake_cool_down instead of waiting for main to move; every failed job has to carry that signature, and the retry happens at most once per tree. The retry is off until you set flake_cool_down (the shipped default is 0 until ADR-0056 is accepted); setting it to "0" goes back to waiting (SPEC-0025 REQ-18).
  • Global only. A project harness.toml or a harness_d drop-in that contains [mergetrain] is refused.
  • One train per repo. Each repo's driver holds a lock under $XDG_STATE_HOME/harness/mergetrain/, so a second daemon on the same host skips that repo rather than racing it.
  • Restart to apply. A change to [mergetrain] takes effect at the next daemon restart.
  • batch folds PRs onto one train commit. batch = N (a whole number, at least 1; default 1) is SPEC-0025 REQ-17's PRs-per-train size. Above 1 a train folds up to N heads onto one commit and bisects a red batch by halves. See Merge train.

Notifications ([notify])​

One program the daemon runs when a harness needs a person: it gave up into failed, is crash-looping, was stopped by the loop guard, or had its session rotated. See Notifications for the events, the payload and a minimal hook.

[notify]
command = ["/home/me/.config/harness/notify.sh"] # required; argv[0] absolute, no shell
events = ["failed", "flapping", "loop_stopped", "session_rotated", "intent_lost", "parked", "recovered"] # default
timeout = "15s" # default 15s, 1s–5m
cooldown = "15m" # default 15m, 0s–24h; per harness and event
  • Validated at load. A relative command[0], an unknown event, an empty events list or an out-of-range duration refuses the config with the offending key's line. run_failed is the one event left out of the default set.
  • Global only. A project harness.toml or a harness_d drop-in that contains [notify] is refused: a cloned repository does not get to choose a program the daemon runs.
  • Reload applies it. Adding, changing or removing the table takes effect at the next harness reload (or config-watch reload), no restart needed.

Restart policy​

The restart key mirrors Docker Compose's directive and controls whether a harness is restarted when it exits:

ValueBehavior
"no"never restart automatically
"always"always restart (this is the default when the key is omitted)
"unless-stopped"always restart, unless explicitly stopped
"on-failure"restart only on a non-zero exit code

Retry limits under a crash loop come from the daemon's crash-loop policy, which escalates backoff — see Supervision.

Health probes​

health_cmd is a systemd-WatchdogSec-style liveness probe for the domain knowledge the daemon cannot have — "my work queue has items older than fifteen minutes and the agent hasn't claimed any". You supply the check; the daemon owns the schedule, the consecutive-failure count, the cooldown, the restart, and the alert, so no sidecar sweep has to re-implement that loop (issue #960; ADR-0046, proposed).

[harness.worker]
harness = "crush"
args = ["--yolo"]
health_cmd = "/usr/local/bin/worker-probe.sh"
health_interval = "5m" # default
health_failures = 3 # default
health_action = "restart" # or "notify"

The probe runs only while the harness is up, gets the daemon's environment plus HARNESS_NAME, HARNESS_PID, HARNESS_STATE and HARNESS_LAST_SUCCESSFUL_CALL, and is exec'd directly — not through a shell. It has 30 seconds to answer; a hung probe counts as a failure. After health_failures consecutive failures the daemon takes the action — a restart through the normal verb path, or nothing but the health notify event — and one interval of cooldown passes before the probe may act again; a healthy probe clears the streak.

harness list and describe carry the verdict as health: ok|failing, and describe shows the last ten probe results with their output tails, so a probe that is itself broken (exit 127, say) is readable instead of silent. Like the [notify] hook, the probe is a daemon-side hook, not a supervised harness: no PTY, no restart policy, no run record.

Run lifecycle hooks and per-run work directories​

A one-shot's workdir is a directory every run of that harness shares, which means a run inherits whatever its last run left behind: stale plan files, another run's uncommitted edits, an agent's scratch from three weeks ago (issue #980). Four keys close that.

[harness.sweep]
harness = "crush"
prompt = "sweep the inbox and report"
schedule = "0 6 * * *"
workdir = "~/sweeps/{{harness.name}}/runs/{{run.id}}"
workdir_retain = 10
pre_run = ["/usr/local/bin/prepare-workspace.sh"]
post_run = ["/usr/local/bin/check-workspace.sh", "{{run.outcome}}", "{{run.exit_code}}"]

workdir is a template rendered per run and created 0700 before the spawn, so a run starts clean instead of clean-ish. It may name harness.name, model, package.dir, run.id, run.trigger, run.source, run.date and run.started_at — identifiers and dates, never an event field or untrusted text, because where a process is born is never an outside party's decision. It requires a one-shot: a resident harness is one process, not one directory per run.

workdir_retain keeps the newest N run directories and removes the rest, the run in flight always among them. With a cap set, the template may vary only in its LAST element, and pruning removes only directories that match the template's own shape, are real directories rather than symlinks, and carry the daemon's .harness-run-dir mark — so a cap can never reach a directory that was there before harness created it.

pre_run runs after the run's record is open and before the harness process spawns; post_run runs after it is reaped and before the record closes, so the run's verdict is already known and a failing post_run can still change it. Each element is templated like a command harness's argv and exec'd directly — never through a shell — with ten minutes to finish. A hook sees the daemon's environment plus HARNESS_NAME, HARNESS_WORKDIR, HARNESS_RUN_DATADIR and the run's HARNESS_RUN_* variables, and for post_run also HARNESS_RUN_OUTCOME and HARNESS_RUN_EXIT_CODE. Its own output lands in the run's log.

A failed pre_run means the harness never spawns: the run closes failed with reason pre_run_failed, because a hook that sets a run up is worth nothing if the run proceeds without it having been set up. A failed post_run demotes a success to failed with reason post_run_failed and leaves every other outcome exactly as the process earned it. {{run.datadir}} names a fresh directory inside the run's workdir for whatever the agent leaves behind, exported to hooks as HARNESS_RUN_DATADIR; under a plain shared workdir it is nested by run id and bounded by keep_runs.

Hooks are valid only on a one-shot — a prompt harness, or a command harness with a schedule or triggers. Like [notify] and health_cmd they are daemon-side hooks, not supervised harnesses: no PTY, no run record, no restart policy (ADR-0051, proposed). success_check (#493) is the designed post-run verifier with a report attached; it will build on the same slot.

The stall watchdog​

restart fires when a process exits. A worker that stays alive and stops making progress therefore never gets restarted: it is blocked inside a tool call, on a child that never returns, or on a peer that stopped answering, and harness list keeps saying running (issue #959). The run timeout cannot help either — on a persistent harness it is a wall-clock ceiling that kills healthy agents on a schedule.

[harness.claude-src]
harness = "claude-code"
args = ["--remote-control", "--continue"]
enabled = true
stall_timeout = "45m" # off unless you write it
stall_action = "restart" # notify (default) | restart | none

Progress, not wall time. The clock runs from the later of the current process's start and the last successful model or tool call the daemon observed — the same signal harness_last_successful_call_timestamp exports, so the watchdog and the metric never disagree about whether the agent did anything. A worker that has been up nine hours and called tools for nine hours is not stalled; one that has answered nothing for two hours is. It samples once a minute, so a stall can be up to a minute old before it is reported.

What each action does.

stall_actionBehavior
"notify"the default: log the stall with a process-tree post-mortem and run the notify hook. The process is left alone
"restart"the same, then SIGTERM and SIGKILL to its process group and a fresh start. The run closes stalled, and the respawn happens whatever restart says — asking for restart here is asking to be revived, and restart = "no" answers a different question
"none"one log line, nothing else

Each stall episode is acted on once per process: it is not re-notified every minute, and it is not killed again on the next sample. The episode ends when the process is replaced or when progress resumes.

A recovery loop is bounded. After five consecutive stalls with no successful call between them the watchdog stops restarting that harness and only reports, with restart limit reached in the message — another restart does not fix a worker that wedges before it ever calls a tool. Any observed call resets the count. A worker that instead exits immediately on every start is caught by the ordinary crash-loop give-up, which needs no help here.

One case to know about. A harness the observer never reports on — a generic or command harness with no transcripts binding — has no progress signal, so its clock runs from process start alone. That is a wall-clock restart period by another name, and the five-stall bound is what keeps it from churning forever.

Where the keys are refused. stall_timeout and stall_action are refused at load on a harness with schedule or triggers: every run of one of those is already bounded by timeout, a wedged one-shot is killed by that, and its next firing is the retry. Each kind of harness gets exactly one watchdog.

Profiles​

Profiles are named groups of harnesses that switch together

[profile.default]
description = "everyday set"
harnesses = ["heartbeat"]
autostart = true

[profile.full]
description = "all agents"
harnesses = ["heartbeat", "my-agent"]
  • Only one profile is active at a time.
  • autostart = true brings that profile's harnesses up when the daemon starts (and on the active profile). The active profile is what harness list flags with *.
  • Switch with harness use-profile <name>.

Profiles are a global concern — they are not allowed in a project file (see Projects).

Drop-in harness files (harness_d)​

Instead of editing one growing harness.toml, point at a directory and add or remove a harness one file at a time:

[server]
harness_d = "~/.config/harness/harness.d"

Then drop files like ~/.config/harness/harness.d/backup-daily.toml:

[harness.backup-daily]
harness = "crush"
prompt = "run the daily backup"
schedule = "0 2 * * *"
  • Only *.toml files are read; anything else in the directory is ignored.
  • Files are merged in lexicographic order, so a numeric prefix (10-, 20-) pins the order if you care about it.
  • A drop-in may contain [harness.*] tables only. [server], [profile.*], [daemon], and bare [name] tables are rejected with the offending file and line.
  • Duplicate harness names — between two drop-ins, or with the main file — are rejected rather than silently overwritten.
  • A leading ~ expands to your home directory, and a relative path resolves against the directory holding harness.toml (not the daemon's working directory, which under systemd is not the same thing).
  • The directory must exist. A missing harness_d fails the config load rather than being treated as empty, so a typo cannot silently drop every drop-in.
  • [profile.*] tables in the main file may reference drop-in harnesses.

Drop-in files are not watched for changes. Auto-reload (watch_config) watches harness.toml only, so after adding or removing a drop-in run harness reload (or touch the main config).

Remote access ([server])​

The optional SSH front door exposes the same dashboard over the network:

[server]
enabled = true
listen = "0.0.0.0:23234"
authorized_keys_file = "~/.ssh/harness_authorized_keys"

Only listed SSH public keys can connect — there is no password auth path. Per-key read-only scoping lets a key attach without typing:

[[server.key]]
key = "ssh-ed25519 AAAA…"
read_only = true

See Remote access for setup and security notes.

Metrics listener​

The daemon serves Prometheus metrics on 127.0.0.1:10229 by default, independent of enabled above. The two keys below live in [server]:

[server]
# Changing either key needs a daemon restart: `harness reload` re-reads
# harness definitions and never rebinds a listener.
metrics_listen = "0.0.0.0:10229" # default 127.0.0.1:10229; "off" disables
metrics_token_file = "~/.config/harness/metrics.token" # required off loopback
KeyDefaultMeaning
metrics_listen127.0.0.1:10229host:port for GET /metrics, or "off". A non-loopback address without a token makes the daemon refuse to start.
metrics_token_filenoneA file holding the bearer token scrapers must send. harness.toml holds its path, never the token (ADR-0008).

See Metrics for the series, alert rules and scrape config.

Webhook listener​

[webhook.*] sources are served on an HTTP listener of their own, separate from the SSH front door and the metrics listener. It is off unless an address names it; declaring a [webhook.*] table does not open a port.

[server]
webhook_listen = "127.0.0.1:9080" # or --webhook-listen / HARNESS_WEBHOOK_LISTEN
# Both or neither; with both set the listener serves HTTPS only.
# webhook_tls_cert_file = "/etc/harness/webhook.crt"
# webhook_tls_key_file = "/etc/harness/webhook.key"

[webhook.ci]
verify = "bearer"
env_file = "~/.config/harness/triggers.env" # CI_HOOK_TOKEN=...
secret = "${CI_HOOK_TOKEN}"

[harness.on-ci]
harness = "claude-code"
prompt_file = "~/.config/harness/prompts/on-ci.md"
triggers = ["webhook.ci"]
curl -X POST -H "Authorization: Bearer $CI_HOOK_TOKEN" \
-H 'Content-Type: application/json' -d '{"status":"failed"}' \
http://127.0.0.1:9080/hooks/ci
# 202 {"webhook":"ci","event_id":"wh-…","decision":"fired",
# "firings":[{"harness":"on-ci","decision":"started","run_id":4}]}

The listener serves exactly POST /hooks/<name> and GET /healthz (ok). It never redirects and never serves files.

StatusWhen
202Verified. decision is fired with one entry per bound harness (started/skipped carry run_id; queued does not; error means that harness could not be fired at all — unknown to the supervisor, shut down under a reload, or its firing panicked — and carries no run_id, with the detail in the daemon log only), ignored when events filtered it out, or duplicate when its delivery ID already fired (with the first firing's event_id). ignored and duplicate fire nothing and make no run record. The response never waits for a run.
400The body could not be read for a reason other than size (for example, the connection dropped mid-body). Body {"error":"bad_request"}.
401Missing, malformed or wrong credential. Every cause gets the same body; the log names the route and peer, never the value presented.
404The name is unknown, disabled, or bound by no harness. All three are byte-identical, so routes cannot be enumerated.
405Any method other than POST on /hooks/<name> (or other than GET on /healthz).
413The body is over max_body. Checked before the credential is.
429The route is over its rate_limit. Retry-After says how many seconds until the next token.
50364 deliveries are already in flight.

Every response carries Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy and Cache-Control: no-store, plus Strict-Transport-Security when the listener terminates TLS itself. Slow clients are cut off: 10 s to send headers, 30 s to read the request, 30 s to write the response, 60 s idle, 64 KiB of headers.

After verification, each delivery goes through three filters, in order:

  1. events: an event name not in the list (or no event name at all) is 202 ignored.
  2. De-duplication: when the scheme has a delivery header (delivery_header for bearer/hmac-sha256, the preset's otherwise) and the delivery carries one, an ID that already fired on this route in the last 24 hours — among the last 1024 kept — is 202 duplicate. The set is in memory and starts empty when the daemon does.
  3. rate_limit: a token bucket of n refilling n per unit, so "2/m" allows a burst of two, then one every 30 s. Only a delivery that verified, passed events and is not a duplicate spends a token, so forged traffic cannot use up the real sender's budget. A 429 is not remembered as seen, so the sender's retry fires. "0" turns the limit off.

A reload keeps a route's bucket and de-duplication set as long as its name and rate_limit are unchanged; changing rate_limit starts both afresh.

Verification schemes​

verify picks how a delivery proves it came from whoever holds the secret. The check runs over the body bytes exactly as they arrived, before anything parses them. The four presets fix their header names, so a GitHub, Gitea or GitLab route is three lines:

[webhook.gh]
verify = "github"
env_file = "~/.config/harness/triggers.env" # GH_HOOK_SECRET=...
secret = "${GH_HOOK_SECRET}"
verifyCredential the sender presentsEvent headerDelivery header
bearerAuthorization: Bearer <secret>event_headerdelivery_header
hmac-sha256hex HMAC-SHA256 of the body, keyed by the secret, in signature_header after signature_prefixevent_headerdelivery_header
githubX-Hub-Signature-256: sha256=<hex HMAC-SHA256>X-GitHub-EventX-GitHub-Delivery
giteaX-Gitea-Signature: <hex HMAC-SHA256>X-Gitea-EventX-Gitea-Delivery
gitlabX-Gitlab-Token: <secret>X-Gitlab-EventX-Gitlab-Event-UUID
standard-webhookswebhook-signature: v1,<base64 HMAC-SHA256> over <webhook-id>.<webhook-timestamp>.<body>, keyed by the decoded secret, with webhook-timestamp within 5 minutes of the daemon's clocknone; the body's top-level typewebhook-id

For github and gitea, set the same value as the forge hook's Secret; for gitlab, as its Secret token. A missing signature header, two of them, a wrong prefix, anything but 64 hex digits, or a MAC that does not match is a 401. The event header becomes the event name events matches, and the delivery header becomes the run's event_id. The event file carries only Content-Type, User-Agent and those two headers, never the signature or token.

A gitlab token is a shared password, not a signature: it proves the sender knows the secret, but does not cover the body. Prefer gitea- or github-style signing where the sender offers it, and TLS either way.

A standard-webhooks route speaks the Standard Webhooks v1 signature — the scheme Switchboard's notify hooks use. The secret must be whsec_ followed by the padded standard base64 of 24 to 64 bytes, and the HMAC is keyed by what that decodes to, never by the string. A delivery passes when any v1 entry in the space-separated webhook-signature list matches, so a sender mid-rotation can sign with its old and its new secret side by side. webhook-timestamp more than 300 seconds from the daemon's clock, in either direction, is a 401; so is a body or webhook-id that was not the one signed. The event name events matches is the body's top-level type, and webhook-id becomes the run's event_id, so re-sending the same delivery is a 202 duplicate, not a second firing.

A non-loopback webhook_listen without TLS starts with a warning: bearer, GitLab and Standard Webhooks secrets then cross the network in cleartext. Bind loopback behind a TLS-terminating proxy, or set both TLS files.

harness triggers shows each source's state — listening, or no_listener when no address is set — and harness doctor flags both a non-loopback bind without TLS and a bound source no listener serves (see CLI → Trigger sources).

Changing webhook_listen or the TLS files needs a daemon restart; a reload logs that a restart is required and keeps serving on the old settings. Route changes (a source added, disabled, rebound) apply on reload. On shutdown the listener stops accepting and gives in-flight deliveries 5 seconds.

Event context​

A harness bound with triggers fires when a source delivers an event: a channel notification, or a verified webhook delivery. The delivery reaches the run as a private 0600 JSON file whose absolute path the run's $HARNESS_EVENT_FILE names; a scheduled firing has no event, and the variable is unset, not empty. Event text is attacker input by construction — anyone who can open a pull request can put words in it — so it never becomes prompt text or argv; the file is the interface for it (ADR-0021). Beyond the file, the event's validated parts render as event.* template paths in argv elements and prompt_template.

The event file​

One JSON object per event (version is 1):

{
"version": 1,
"kind": "webhook",
"source": "webhook.gh",
"event_id": "7053294418",
"received_at": "2026-10-08T14:03:11Z",
"webhook": {
"event": "pull_request",
"delivery": "7053294418",
"content_type": "application/vnd.github+json",
"headers": {"Content-Type": "application/vnd.github+json"},
"body": { "action": "opened", "number": 412 }
},
"typed": {
"name": "pull_request",
"action": "opened",
"repo": "stump.wtf/harness",
"number": 412,
"url": "https://gitea.stump.rocks/stump.wtf/harness/pulls/412"
}
}
  • kind is channel or webhook and decides which of the two sub-objects is set; source is the reference that fired (channel.sb, webhook.gh).
  • channel passes the notification through verbatim: content (the free text) and meta.
  • webhook carries the delivery's event name, the sender's delivery ID when it sent one, content_type, an allowlisted headers subset (never the signature, Authorization or a cookie), and the body under exactly one of three keys: body when the content type is JSON and the body parses, body_text when the body is valid UTF-8, body_base64 otherwise.
  • replayed_at is set only on a replay. harness trigger NAME --event FILE re-derives typed from the stored body, so a hand-edited typed in a replay file never renders.
  • typed holds the event's validated metadata, below. It is absent when nothing validates.

typed: validated event metadata​

typed is the only way a payload byte becomes a template value. When the daemon writes the envelope, it extracts the typed values from the event's own fields with a per-scheme table and validates each: a value that is missing, of the wrong JSON type, or fails its validation is simply absent — never passed through, truncated, or normalized. No typed value can begin with -.

SchemeFields the table extracts
github, giteaname (from the delivery's event header), action (action), repo (repository.full_name), number (pull_request.number, issue.number, number), url (pull_request.html_url, issue.html_url — must share the repository's own host), sha (pull_request.head.sha, after; 40 or 64 hex digits)
gitlabname (event header), action (object_attributes.action), repo (project.path_with_namespace), number (object_attributes.iid), url (object_attributes.url), sha (object_attributes.last_commit.id, checkout_sha)
bearer, hmac-sha256, standard-webhooksname only; an opaque scheme never reads the body
a channel sourcemeta.<key> for each meta entry whose key matches ^[a-z][a-z0-9_]{0,31}$ and whose value matches ^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$ (Switchboard's meta.todo_id and meta.queue, say)

number is a JSON integer, 1 to 2³¹−1; "412" is absent, not parsed. url is an absolute https URL of at most 2048 bytes, absent when its host cannot be checked against the repository's own.

event.* template paths​

An argv element and a prompt_template render the same event paths (see Argv templates and Templated prompts). The values come from the same envelope the event file was written from, so a rendered value and the file cannot disagree:

PathValuePresent when
event.filethe event file's absolute paththe run carries an event
event.idthe event's ID: the sender's delivery ID when it is ID-shaped, else a daemon-derived evt-…the run carries an event
event.kindchannel or webhookthe run carries an event
event.sourcethe trigger source, e.g. webhook.cithe run carries an event
event.received_atwhen the daemon received the event, RFC 3339 UTCthe run carries an event
event.name, event.action, event.repo, event.number, event.url, event.shathe matching typed valueextraction yielded it
event.meta.<key>a validated channel meta valuethe channel event carried it
[harness.pr-tooling]
harness = "command"
argv = ["pr-tool", "review", "{{event.repo}}", "{{event.number}}", "--event", "{{event.file}}"]
triggers = ["webhook.gh"]
[harness.pr-watch]
harness = "claude-code"
prompt_template = "A {{event.name}} ({{event.action}}) landed in {{event.repo}}: {{event.url}}. Read the full delivery in {{event.file}} and triage it."
triggers = ["webhook.gh"]

Two refusals and one rule:

  • Free text never renders. event.title, event.body, event.comment, event.ref, event.content and {{untrusted …}} are refused in argv and in prompt templates alike. Give the program or agent event.file and let it read the raw text knowing it is untrusted.
  • event.actor is not available yet (trusted_actors is a later story).
  • A required event.* that a firing could not render fails the load: a harness with no triggers never has an event, and a schedule's clock firings carry none. Write {{event.repo?}} for an empty expansion.

Environment variables​

Process-level settings — where the socket lives, how loud the log is, whether the SSH server is on — can come from the environment instead of a flag or this file. That is what makes Harness deployable as a container or a systemd unit without baking in a config file.

VariableFlagharness.toml keyTypeDefault
HARNESS_SOCKET--socket[daemon] socketpath$XDG_RUNTIME_DIR/harness.sock
HARNESS_CONFIG--config—path$XDG_CONFIG_HOME/harness/harness.toml
HARNESS_JSON--json—boolfalse
HARNESS_LOG_LEVEL--log-level[daemon] log_leveldebug/info/warn/errorinfo
HARNESS_LOG_FILE--log-file[daemon] log_filepathstderr
HARNESS_SCROLLBACK--scrollback[daemon] scrollbackint10000
HARNESS_SCROLLBACK_BYTES--scrollback-bytes[daemon] scrollback_bytessize (4MiB, bytes)1MiB
HARNESS_SSH--ssh[server] enabledboolfalse
HARNESS_SSH_LISTEN--ssh-listen[server] listenhost:portunset
HARNESS_WEBHOOK_LISTEN--webhook-listen[server] webhook_listenhost:portunset (no webhook listener)
HARNESS_WATCH_CONFIG—[daemon] watch_configbooltrue
HARNESS_COMPRESS_LOGS--compress-logs[daemon] compress_logsbooltrue (zstd-compress sealed logs)
HARNESS_MEMORY_LIMIT--memory-limit[daemon] memory_limitsize (2GiB; 0 = off)unset (GOMEMLIMIT, else off)
HARNESS_PPROF_ADDR--pprof-addr[daemon] pprof_addrloopback host:portunset (no profiler)

GOMEMLIMIT is not a Harness variable, but the daemon honours it when no source above sets a memory limit. See Memory limit and profiler.

config has no file key because it names the file. json has none because it is an output choice for one invocation: a file default would change what every script parsing Harness's output receives.

Booleans accept 1, 0, true, false, yes, no, on, off.

Sizes accept a whole number of bytes or a number with a unit: B, KiB, MiB, GiB, TiB, with K/KB, M/MB, G/GB, T/TB as the same 1024-based units. Case does not matter.

Precedence​

An explicit flag beats an environment variable, which beats this file, which beats the compiled default:

--socket /tmp/y.sock > HARNESS_SOCKET=/tmp/x.sock > harness.toml > default

"Explicit" means you actually typed the flag. Leaving a flag alone does not suppress an environment variable, so HARNESS_LOG_LEVEL=debug harness daemon run starts at debug even though --log-level has a default of info.

An exported-but-empty variable is treated as unset, because export HARNESS_SOCKET=$SOME_UNSET_VAR is a shell accident rather than a request for an empty socket path.

A bad value is a hard failure, never a silent fallback:

HARNESS_SCROLLBACK=lots harness daemon run
# HARNESS_SCROLLBACK: invalid value "lots": expected an integer

HARNESS_SCROLLBACK_BYTES=16GiB harness daemon run
# HARNESS_SCROLLBACK_BYTES: must be between 64KiB and 1GiB, got 16GiB

Which source won?​

harness doctor reports every setting with the source that supplied it, so you never have to guess:

harness doctor
SETTING SOURCE VALUE
socket env /run/harness.sock
config default /home/you/.config/harness/harness.toml
log-level flag debug
ssh file true

harness doctor --json carries the same data under .settings for scripts.

Harnesses stay in the file​

There is no environment variable that defines a harness or a profile. Those are collections, and mangling them into variable names is not reliably reversible — HARNESS_HARNESS_CLAUDE_SRC_WORKDIR cannot tell you whether the harness is claude-src or claude_src. So:

A container can run a fully-configured daemon with no harness.toml at all, but it cannot define a harness without one. A missing file is not an error; the daemon comes up and reports zero harnesses. A file that exists but does not parse is still an error.

Secrets do not go here​

No HARNESS_* variable takes a token, key, or password. Per-harness secrets belong in env_file, which is read by the harness rather than by Harness itself. See Remote access for the SSH key handling.

HARNESS_DETACH_READY_FD is reserved: it is internal plumbing between harness daemon --detach and the process it forks, not a setting.

systemd​

[Service]
Environment=HARNESS_SOCKET=/run/harness/harness.sock
Environment=HARNESS_LOG_LEVEL=info
Environment=HARNESS_CONFIG=/etc/harness/harness.toml
ExecStart=/usr/local/bin/harness daemon run

After editing​

harness reload re-reads the config and reconciles running harnesses without a daemon restart. harness doctor verifies the file parses and the daemon agrees with it.