CLI reference
The client is a set of one-shot verbs: dial the daemon, perform one
request, print (human or --json), and exit. Every verb also supports a global
--json flag for machine-readable output that mirrors the daemon RPC contract.
Common flags on every call:
--socket PATH— daemon socket (defaults to the daemon's default path).--config PATH—harness.tomlpath (defaults to~/.config/harness/harness.toml).--json— machine-readable output.
Lifecycle verbs
harness start <name> # start a harness (or a project harness)
harness stop <name> # stop a harness
harness restart <name> # restart a harness
harness start --all # start/stop/restart every harness at once
<name> resolves to <project>/<name> inside a project (see
Projects); --all keeps its daemon-wide meaning. --all runs
render live per-harness progress with a Bubble Tea animation so a large fleet
start/stop is visible as it converges.
On a terminal, a single-harness verb shows a spinner while the daemon works, then records the transition in the state's colour, with a faint context line:
● claude-rc failed → running
restarted · pid 48211 · 3 restarts · remote-control claude
A harness that comes out failed or degraded adds a
→ see why: harness logs <name> hint. The other mutating verbs (use-profile,
reload, down, rm, run, trigger, daemon stop) get the same styled
treatment on a terminal.
Piped or redirected output stays exactly one plain line per result, e.g.
● claude-rc → running or reloaded — 12 harnesses, so scripts are
unaffected. --json is unchanged. Colour follows your terminal's
capabilities and NO_COLOR; the glyph and state words carry the meaning
without it.
The client warns when its own build is older or newer than the daemon's (client/daemon skew) — after upgrading, restart the daemon so both sides speak the same protocol version.
Listing & inspection
harness list # table of every harness: name, state, enabled, restarts, PID
harness ps # inside a project: only that project's harnesses
harness describe <name> # one harness in detail (state, harness kind, backend, flapping, ...)
harness daemon status # daemon version, proto, PID, uptime, socket, active profile
list and describe also surface schedule metadata for scheduled
one-shots (see Scheduled jobs under Configuration). In the
listing, a scheduled harness is marked inline rather than by extra columns: its
state glyph becomes a clock (⏱, in the same colour, so the state still reads at
a glance) and its next firing is appended to the description as a relative time
— sweeps the fleet · in 4h3m. describe shows the full picture: the cron
spec verbatim plus the absolute time of the next firing.
The cron spec itself is config, not status, so it stays off the listing
surface; reach for describe or --json when you need it.
describe additionally lists the harness's live attach sessions — who is
attached right now, and whether each session is read-only.
Is it working, or just running?
state: running is truthful about the process. It says nothing about
whether the agent inside it is making progress, and an agent blocked for hours
inside one tool call — a hung grep, an MCP server that stopped answering —
looks healthy here. describe answers that with two kinds of row:
last call 23h ago (2026-10-19 22:23:15)
child pid 636314 · sleep · up 23h · cpu 0s
last call is the harness's last successful model call — the same instant
/metrics exports as harness_last_successful_call_timestamp, so the socket
and Prometheus cannot disagree (see Metrics). A harness the
daemon has never seen call says none since the daemon started rather than
printing a date from the epoch. Each child row is a direct child of the
supervised process: how long it has been alive and the CPU it has burned. A
child up 23h with cpu 0s is stuck, not busy.
Children are named only while the harness is running, and are omitted when the
daemon cannot read the process table (a container with no ps) — absence means
unknown, never "none". harness list --json and describe --json carry both
as last_successful_call_at and children, so a script can alert on "running,
no call in 30m" without scraping /metrics.
--json on any of these emits the same data as structured JSON.
Scheduled jobs
Scheduled one-shots fire daemon-side on a cron schedule — no verb to remember,
just configure schedule on a prompt harness (see
Configuration → Scheduled one-shots).
harness list gives every harness a SCHEDULE column (the cadence, e.g.
daily 10:00 UTC, or the raw expression when it cannot be paraphrased) and a
NEXT column (the countdown, e.g. in 2h), both derived from the config and
the running scheduler — never from the description text. A job waiting for its
next firing reads ⏱ armed rather than stopped, because it is loaded and
will fire on its own; harness describe reports armed instead of an
enabled that is false for every scheduled harness by construction.
harness stop <name> on a scheduled one-shot pauses the schedule itself: the
process stops, and the cron stops firing — harness describe reads
next run: suppressed by harness stop (harness start re-arms it), and in
harness list the row reads ⏱ disarmed with suppressed in its NEXT
column instead of a countdown — until an explicit harness start, which
re-arms it (harness trigger runs once without re-arming). The pause survives
a daemon restart. Windows that pass while stopped leave a skipped run record
instead of firing. A harness with trigger sources reads disarmed the same
way while its schedule is paused; event sources still fire, and the SCHEDULE
column keeps listing them.
Every stop path can wait for the turn in progress instead of cutting it short,
with --graceful:
harness stop demo --graceful # wait for the turn, capped by hours_shutdown_timeout
harness stop demo --graceful=2m # ... and never longer than two minutes
harness restart demo --graceful=5m # drain the turn, then start it again
harness daemon stop --graceful # every harness drains before the daemon exits
This is the operating-hours graceful close (ADR-0019) asked for by hand rather than
by a window closing, so it stops the harness at the first of: the agent's turn
ending and settling, its output going quiet for two minutes where the adapter has
no turn markers to settle on, or the cap. Without the flag nothing waits, and the
stop is the SIGTERM → stop_grace → SIGKILL sequence it has always been; a
plain SIGTERM to the daemon, including systemctl stop, keeps that timing too,
since a signal carries no arguments. A harness whose agent cannot be attributed to
a session (a generic command, an ambiguous workdir) degrades the way the hours
gate already degrades: it stops at once, and its durable log says graceful was
unavailable. The command blocks for as long as the wait takes, so prefer an
explicit cap in scripts.
A harness fired by trigger sources (triggers, see
Trigger sources) is a one-shot too, and reads the same
way: ↯ armed between firings, never stopped or (disabled). Its SCHEDULE
column lists its sources (webhook.ci, channel.sb, after the cadence when it
also has a schedule), and NEXT reads on event when there is no window to
count down to.
harness jobs # every triggered harness: sources, next run, last run, consecutive failures
harness runs <name> # its run history, newest first (--limit N, default 20)
harness trigger <name> # run it now — on_overlap applies, as for a firing
harness trigger <name> --wait # …stream the run's log and exit with its exit code
harness logs <name> --run 3 # what run 3 did (--raw for its own log)
Run history across harnesses
Every run of every harness, one-shot firings and resident process lifetimes
alike, is a record in the run ledger ($XDG_STATE_HOME/harness/ledger/,
ADR-0028). harness runs queries it:
harness runs # every harness, the last 24 hours (--limit, default 50)
harness runs pr-review nightly --since 7d # these harnesses, the last week
harness runs --since 24h --outcome failed,timed_out,interrupted --json # a morning sweep
harness runs --trigger webhook,channel --wide # add MODEL, TOKENS, COST and TODO
| Flag | Meaning |
|---|---|
NAME..., --harness NAME | harnesses to include (repeatable); none means every harness |
--since DUR|TIME | runs started since a duration ago (7d, 36h) or an RFC 3339 instant; default 24h for a query |
--until TIME | runs started before an RFC 3339 instant |
--outcome O[,O...] | only these outcomes; an unknown value fails and lists the valid ones |
--trigger T[,T...] | only these triggers (schedule, manual, catch_up, channel, webhook, autostart, restart, release, lease) |
--limit N | at most N records, newest first (1–1000) |
--wide | add MODEL, TOKENS, COST and TODO |
--json | the records as a JSON list |
harness runs NAME with no other filter is the one-harness history it has
always been: newest first, default 20, and --json prints the same
{"name": …, "runs": […]} object as before, with new fields only added. Each
run's MODEL cell in --wide shows the served model with the most output
tokens among that run's model calls (SPEC-0022 REQ-4).
With the daemon down, harness runs reads the ledger's files directly and says
so on stderr. It changes nothing on disk, and it shows a run the daemon left
open as running?, since it cannot tell a live run from one a crash left
behind. A harness redeploy after a crash kills scratchpad workers and drops
runs that were in flight at cut-over — the ledger reflects when the daemon
exited (ADR-0007).
trigger --wait exits with the run's own exit code, 124 when the run timed
out, and 75 when it was skipped because a run was already in flight — so a job
scripts like the command it wraps. The verb is trigger because harness run
starts a throwaway scratchpad.
A command harness whose argv template needs a value the run does not have
(a required {{run.source}} on a manual trigger, say) runs nothing: the run is
recorded skipped, and harness runs prints a line under the table naming the
missing path (run #4 skipped: template_unresolved ({{run.source}} has no value for a manual run)). --json carries it as "reason": "template_unresolved"
and "missing_path": "run.source", and trigger --wait exits 75. See
Argv templates.
--wait polls the run history rather than consuming events, because history is
authoritative even when an event is dropped. When the trigger was queued behind
a run already in flight, --wait attaches to the oldest manual, non-skipped run
newer than the moment it was issued — so if two manual triggers fire
concurrently, either may pick up the other's run and both stream the same log.
Scheduled firings never collide this way.
jobs lists every harness with a schedule, triggers, or both. Its
TRIGGERS column shows each source with that source's state
(webhook.ci listening, channel.sb backoff), so a job that never runs starts
its explanation in the row; a harness with no schedule shows no next
window.
Where TOKENS and COST come from
The TOKENS and COST cells of --wide are the usage folded into the run's
ledger record at run end (SPEC-0022 REQ-8). A run's usage can come from two
sources, and the two must never count the same run twice:
- The run's own stream. A stream-json one-shot
(a pipe run) writes
jobs/<name>/<run_id>.stream.jsonl. When the run ends, the daemon reads that sealed stream once and folds its finalresultrecord — the agent's own account of the run's tokens and, when the agent recorded one, its cost — into the record withcost_source = "recorded"(ADR-0033). A pipe run has no transcript for the observer to read, so this is how a one-shot's usage reaches the ledger at all. - The transcript. A run the observer watches through a
transcriptsbinding can also carry usage from the agent's transcript. The observer's usage items are not read yet, so today the stream is the only source that folds usage in.
The stream's final result covers the whole run exactly as a transcript's
report would, so the no-double-count rule is binary: a run that already
carries folded usage skips the stream's. A run folds nothing — and shows —
in both cells rather than a zero it is not — when it has no stream file (a
PTY run, or a spawn that never opened one) or its stream ends without a
result (the process was killed before it finished).
The TOKENS cell sums input and output tokens. COST prints with its source
when that source is not the recorded one: a recorded cost prints bare, a cost
the daemon priced from [budget] prices prints
with priced, and a run whose cost no one could resolve prints ? (ADR-0027).
--json carries the same totals per run — tokens, cost_usd,
cost_source and models — as the run's closed ledger line.
Trigger sources
harness triggers # every [channel.*] / [webhook.*] source
harness triggers --json # …with every per-outcome counter
harness triggers answers "did anything hear the doorbell?" — the one
question no run record can, because a source that never fired leaves none.
One row per declared source:
| Column | Meaning |
|---|---|
| SOURCE | The reference a harness's triggers names, e.g. channel.sb |
| STATE | connecting, connected, backoff or error for a channel; listening or no_listener for a webhook; disabled (enabled = false) or unbound (no harness lists it) for either |
| FOR | How long it has been in that state. For a channel that is not connected, how long it has been down — down 10m — measured from when it left connected, not from its latest retry |
| LAST EVENT | When it last fired |
| FIRED | Events fired since the daemon started |
| HARNESSES | The harnesses it fires, in config order |
Under the table, each source gets its endpoint — a channel's url with the
query removed and its header names, or a webhook's POST /hooks/<name>,
verify scheme and events — then its last error, and any deliveries it
dropped by outcome. --json carries the same fields plus every counter
(fired, ignored, duplicate, unauthorized, too_large,
rate_limited, invalid), zeros included.
No output of triggers, describe or the event stream carries a header value,
a URL query or a secret: the daemon scrubs a channel's last error of both
before it leaves the daemon, because Go's HTTP client quotes the request URL
in its errors. describe lists a harness's triggers with each source's state.
A client subscribed to events receives trigger_source_changed (source,
source_kind, state, error) on every source state change, in order, and
job_run_started/job_run_finished carry the source that fired the run.
harness doctor adds a triggers row that flags the setups that fail
quietly: a webhook listener bound off loopback without TLS, a [webhook.*]
source no listener serves (no_listener), and a channel source in error.
With the daemon down it still checks what the config alone can show. A source
env_file readable by group or other is a config load warning instead: the
daemon logs it when it loads the config and on every reload, and doctor's
config row lists it with the source and the file.
:::note Rejected webhook deliveries are not counted yet
fired and a channel's invalid are counted today. A webhook delivery the
listener rejects — unauthorized, too_large, rate_limited, duplicate,
ignored — is answered and logged but not yet counted, so those counters read
0 until the listener reports them.
:::
Operating hours
A gated harness — one with operating_hours set (see
Configuration → Operating hours) — is a
resident harness the daemon holds down outside its configured windows,
without ever touching enabled. (On a harness with triggers, hours gate
firings instead, and nothing below about holding or closing applies — see
Configuration → On a triggered harness.)
list, describe and the TUI reuse the same
STATE/SCHEDULE/NEXT columns harness jobs does (no extra column) rather than
inventing a parallel set:
off-hoursreplacesstoppedfor a held harness — it is down because its window is closed, not because someone turned it off.stoppedis the same latch a give-up produces, so conflating the two would make an operating-hours hold page like a real failure. NEXT readsopens Mon 09:00.closingreplaces the process's own state (running,degraded, ...) while a graceful close is in flight: the harness is still up, finishing its current turn before it stops. NEXT readsstops by 13:15— the close's own deadline, which can run past the window's own close time by up tohours_shutdown_timeout.- A gated harness running in hours shows NEXT as
closes 13:00. - SCHEDULE shows the
operating_hoursexpression, with aTZ=/CRON_TZ=prefix dropped when it names the daemon's own zone ($TZ), the same way a cronschedule's zone is handled.
harness start <name> # gated + out of hours: a one-hour after-hours lease
harness start <name> --for 3h # …for a chosen length instead
--for only applies to a gated harness that is currently out of hours; on any
other harness it is rejected. The lease shows in NEXT as lease until 21:00
until it ends — the gate stops the harness exactly as it would at a close — or
until hours open first, when the lease simply ends and the harness keeps
running as an ordinary in-hours one. harness stop <name> always stops the
harness, ends any lease, and clears enabled, whatever state it is in.
harness doctor warns on a gated harness with enabled = false (hours will
never start it), an operating_hours expression covering the entire week (it
gates nothing), and graceful shutdown on a harness nothing can ever be
attributed to (a generic or command adapter, or no workdir) — every
close then degrades to immediate no matter what hours_shutdown says. On a
harness with triggers only the whole-week warning applies: it must be
enabled = false, and it has no resident process to close.
Logs
harness logs <name> # tail (default 200 lines)
harness logs <name> --lines 50 # a specific number of trailing lines
harness logs <name> --follow # stream new output as it arrives
harness logs <name> --run 3 # one run of a scheduled harness (see harness runs)
When a log rotates or truncates, --follow reprints the current tail so you
never silently lose context.
A stream-json one-shot
(a claude-code prompt harness) has no terminal, and its stdout is not in its
durable log: for a run that has a stream file, jobs/<name>/<run_id>.stream.jsonl
is the run's output. harness logs renders that stream readably instead of
showing raw masked JSON, as the same entries a traced run's session gets: a
session line (short id, then the agent, model and title when the stream
recorded them), one line per classified tool call (a read or an edit names the
file), a line per mark, and the run's result line with its outcome, turns,
duration and cost, then its final answer or first error. In the terminal and
in the dashboard preview the result line is mint on success and coral on
failure, and the structured view notes that the entries are rendered from the
stream file because no agent-trace session is attributable to the run.
Entries keep their stream order, and their ids are stable across polls, so a
following view — harness trigger <name> --wait as the run goes — prints each
entry once, in order. Every field is masked again at read time: the file was
masked when it was written, and redaction rules may have grown since (ADR-0008).
The file on disk is never rewritten.
harness logs <name> --raw shows the durable log's lifecycle and stderr
lines, with a note saying where the output went. With --run N it prints run
N's stream rendering — or, when the stream parses to nothing, its raw masked
tail, each line shown cut at 64 KiB and the whole tail at 8 MiB — and, once
the run has ended, its run log after it, each under a ==> path <== header. A
closed run's stream is zstd-compressed, and the view reads it transparently.
The file itself is whole: read it with jq for anything longer.
harness logs reads the daemon's files for you, compressed or not. A rotated
backup and a closed run's log and stream are stored zstd-compressed
(.log.zst, .stream.jsonl.zst) by default. Read one by hand with
zstd -dc FILE.zst, or search plain and compressed logs together with
zstdgrep. See
Supervision → Logs on disk for which files are
compressed and when, and for the compress_logs opt-out.
Profiles
harness profiles # list profiles and which is active (*)
harness use-profile <name> # switch the active profile
Profiles (a "configuration of harnesses") switch whole sets at once. Only one
is active at a time; harness list flags it with *. See
Configuration.
Reload & diagnostics
harness reload # re-read config, reconcile running harnesses
harness doctor # health check battery (config, daemon, versions, remote SSH, notify, harnesses)
harness doctor --notify-test # also have the daemon run the [notify] hook once with a test event
reload picks up config changes without restarting the daemon. describe
shows a config — changed — restart to apply row when the running harness is
out of date with the config file.
Attach
harness attach <name> # attach to a harness as a live terminal
harness attach <name> --ro # read-only: attach but ignore keystrokes
attach reuses the same full-window terminal the dashboard uses, with the
1-line status bar and tmux-style detach chords. See Cockpit TUI.
A stream-json one-shot has no terminal to attach to, and the daemon keeps no
screen for it. Attaching shows its output as lines: its recent lines first,
then new ones as they arrive, rendered readably — the same formatter the
dashboard's preview uses, so Claude Code's stream becomes the tool calls and
prose a human reads instead of masked JSON — with Ctrl-b f flipping back to
the byte-faithful raw mirror. Keystrokes go nowhere, since its stdin is
/dev/null, and resizing the window changes nothing for it. A client too slow
to keep up sees an output dropped line where it fell behind; the run's
.stream.jsonl has everything. harness describe lists the sessions, with no
viewport.
Capture (screen dump without a TTY)
harness capture <name> # the harness's current screen, as plain text
harness capture <name> --ansi # the styled ANSI repaint (as attach's first frame)
harness capture <name> --json # the full payload: text, ansi, viewport, idle ms
capture is the non-interactive attach: it renders what is on the harness's
terminal right now so a script — a monitoring sweep, an alert pipeline — can
read it. This matters because harness logs deliberately records only
scrolled-off lines (ADR-0007): a
full-screen prompt that repaints in place never scrolls, so it never reaches
the log. When a screen has never received any output, capture answers
no screen rather than pretending it is blank.
MCP server (harness mcp)
harness mcp # read-only tools over stdio
harness mcp --allow-control # also serve the tools that change state
harness --socket PATH mcp # serve a daemon other than the default one
mcp serves Harness's control plane as an MCP
server over stdio, so an agent drives the daemon with named tools instead of a
shell loop over harness list, runs, logs and trigger. It is a thin client
like every other verb (ADR-0002):
each tool delegates to the same control call its verb makes, so a tool can never
show more than the CLI does, and there is no second implementation to keep in
step. It adds no listener and no trust boundary: whoever can reach the daemon
socket can already do everything the read tools report.
An agent's configuration launches it the way it launches any stdio server:
# The agent CLI starts and stops this process; the session ends when it closes
# the pipe.
command = "harness"
args = ["mcp"]
Tools
| Tool | Returns |
|---|---|
harness_list | every harness with state, enabled/held flags and last exit |
harness_describe | one harness in detail (config, state, children, sessions) |
harness_runs | run history: outcome, exit, duration, trigger, skip reason; with no name, across all harnesses with the ledger's filters |
harness_logs | one run's output, summary or raw (see below) |
harness_jobs | triggered harnesses with sources, next run, recent results |
harness_triggers | trigger sources with state, last event and error, counters |
harness_daemon_info | daemon version, protocol, uptime, socket, counts |
harness_trigger | run a triggered harness now; wait follows it to its outcome |
harness_start, harness_stop, harness_restart | the lifecycle verbs, returning the new state |
The last four change state, so they are absent unless the server was started
with --allow-control. A read-only server is what you hand a monitoring agent:
its tool list contains nothing that can touch a run, and a call to a control
tool is refused with not_permitted before the daemon is asked. That split is
the reason this exists: a permission system can allow one tool name and gate
another, where a shell command that both reads and acts gets one verdict for
both. Per-harness grants (mcp_allow) arrive with the broker's caller identity
(ADR-0035);
until then the startup flag is the whole grant, which is why control is off by
default (ADR-0054).
Reading logs without flooding the context
harness_logs answers in one of two formats:
summary(default): the run's activity as ordered entries (lifecycle transitions, tool calls, marks) with session bookkeeping collapsed out. Each answer carriesnext_cursor; pass it back ascursorto get only what is newer, which is the polling pattern an agent wants andlogs --followcannot serve.raw: the durable log tail, exactly asharness logs --rawprints it. Page past a truncation with the returnednext_offsetasoffset. A raw offset is only meaningful against the tail it came from: pass one that is past what this call returns and the answer repeats the whole tail with anote, rather than coming back empty and reading as "caught up" to a poller that missed output. To poll for what is new, use thesummarycursor, whose entry ids are stable.
Both are capped (256 KiB by default, max_bytes to lower it) and say
truncated when they cut. A summary of a run whose adapter records no native
trajectory answers with the raw tail and a note saying why, because for those
harnesses the log is the record (ADR-0007).
Errors stay inside the tool result with a stable code (unknown_harness,
not_permitted, unknown_tool) so a caller branches on the code instead of
matching prose, and the session stays usable after a refusal.
Not in this slice
Editing harness.toml stays a file edit plus reload. Upstream MCP servers,
the broker's per-harness endpoints, caller attribution by token, prompt
side-loading and log subscriptions belong to the gateway design
(ADR-0035),
not to this bridge.
Waiting for input
A harness frozen at an interactive prompt — a permission dialog, a workspace
trust screen, a consent wall — reports running like any healthy harness, and
its log is silent because the prompt never scrolled. harness list and
describe surface this as a distinct marker:
NAME STATE ...
stuck ● running ⏸ waiting for input (y/n prompt) · screen idle 4m12s
The verdict requires both halves: the screen has produced no output for a while (30 seconds by default), AND a known interactive-prompt pattern is visible on it. A busy agent repaints constantly and never trips it; an idle but prompt-free screen (an idle shell, a finished turn) does not either.
To see what tripped the marker, harness capture <NAME> prints the screen the
verdict came from; harness attach <NAME> takes over the interactive session. The
process lifecycle state is unchanged — waiting says what the glass is
doing, state says what the process is doing
(ADR-0044).
Scratchpads (harness run)
harness run claude # Claude Code in the current directory, then attach
harness run crush --yolo # the words after the kind are the agent's args
harness run htop # not a kind: runs `sh -c "htop"`
harness run --detach codex # print the name and leave it running
harness run --name spike --workdir ../api claude
harness run starts a scratchpad: a throwaway harness that exists only
until the daemon exits. It is the tmux new-session gesture. A scratchpad:
- is not in any
harness.toml, so it is never reloaded, rescheduled or autostarted; - gets a random name: a slug of the kind and words plus four random
characters, such as
claude-code-x4yxorgeneric-htop-9k2p, printed when it starts; - starts, then attaches you to it, unless you pass
--detachor either stdin or stdout is not a terminal; - is not restarted when its process exits. It stays in
harness listwith its exit state until you remove it withharness rm NAME; - is gone when the daemon exits, and never written to the daemon's state.
The first word picks what runs. If it names a harness kind (claude or
claude-code, crush, codex, generic), that adapter runs and every
following word becomes its args. Anything else falls back to generic, and
the whole invocation runs as one sh -c command. run's own flags must come
before that first word; everything after it belongs to the command, so
harness run htop -t reaches the shell as htop -t.
| Flag | What it does |
|---|---|
--kind KIND | Use this adapter (crush, claude-code, codex, generic) instead of guessing from the first word, and pass every word as its args. For the rare command whose name collides with a kind. |
--name SLUG | Use this slug instead of the kind or command. A random suffix is still appended. |
--workdir DIR | Start in DIR instead of your current directory. A relative path resolves against your shell's directory, not the daemon's. |
--model MODEL | Prepend --model MODEL to the agent's args. Same as writing harness run claude --model MODEL. |
--detach | Don't attach: print the name and leave it running. --json implies it. |
Use a scratchpad for a one-off session you want to detach from and come back to.
Anything that should survive a daemon restart, run on a clock, or fire on an
event belongs in harness.toml instead: a resident harness, or a prompt harness
you fire with schedule, triggers or harness trigger.
Agent packages
harness stable trusts git repositories called stables, and
harness agent installs agent packages from them (ADR-0044, SPEC-0026). Both
are client-only trees that edit your global harness.toml directly, so most
verbs work with no daemon running; when one is reachable, the verb asks it to
reload. The walkthrough is
Install shared agents from a stable.
harness stable add NAME REMOTE [--private] # clone, then write [stable.NAME]
harness stable list # trusted stables and their clones
harness stable update [NAME] # fetch + fast-forward (the only fetch)
harness stable remove NAME # drop the table; names harnesses still sourced
harness stable lint [PATH] [--json] # validate a stable checkout offline
harness stable check [PATH] [--json] # lint + load-test each package
harness agent search [QUERY] [--stable NAME] # packages in local clones
harness agent info STABLE/PACKAGE [--readme-full] # manifest, license, requests, declared env, README, scan findings, files
harness agent install STABLE/PACKAGE[@VERSION] [--as NAME] [--replace] [--yes] [--accept-defaults] [--force-unsafe] [--readme-full]
harness agent upgrade STABLE/PACKAGE[@VERSION] [--all] [--yes] [--accept-defaults] [--readme-full]
harness agent list [--json] # package-sourced harnesses, pins, NEWER, LICENSE
harness agent uninstall NAME [--yes] # remove the table; the pin stays
harness agent prune # remove pins nothing references
harness stablemanages the trust ledger. It was first spelledharness agent stable; that spelling still works as a hidden alias with identical output and exit codes, so existing scripts and CI keep running.- Only
stable updatefetches. Every other verb reads the local clone.agent list'sNEWERcolumn compares against the clone as the last update left it. stable lintandstable checkare how a stable author validates a checkout (a directory withpackages/,.by default) before publishing. They need no daemon, no registered stable and no network.lintuses the parser and scannerinstalluses. It fails on any manifest violation,highscan finding,[package].namethat differs from its directory, path key naming a missing or empty file, symlink, or missingREADME.md. Otherlowfindings are warnings.checkrunslint, then loads each package the way config load does, under a synthetic[harness.<package>]table (with aschedulewhen the package supplies a prompt). It catches what only a load sees, such as an unknown adapter or a template needing context its firing never supplies.- Both exit 1 on any error.
--jsonprints an array with one{package, errors, warnings}object per package for CI annotations.
installpins the package at an exact commit in the content-addressed store, scans it, and asks before it writessource = "STABLE/PACKAGE@<sha>"onto[harness.NAME].- Without a terminal it refuses unless
--yes. --yesnever clears ahighscan finding or anmcp_allow = ["write"]request.--force-unsafeoverrides ahighfinding after you retype the package.--replacelets the table switch from another package's source.- The confirmation shows the package's SPDX
license. A package without one carries thepackage.no-licenselow finding, which never blocks. - Its output ends with an
env_fileskeleton for the variables the package declares in[[env]]: names and descriptions, never values. - A package can suggest supervision settings in
[defaults]. Nothing in that table is ever applied on its own:installoffers each suggestion once (accept it, overwrite it with a value you type, or skip it) and writes what you choose as your own keys besidesource. Keys your table already sets are never offered.--accept-defaultstakes every suggestion unattended;--yestakes none and says so.
- Without a terminal it refuses unless
upgradere-pins to a newer commit of the local clone (--allfor every sourced harness). It shows the manifest diff (apackage.licensechange is flagged as a terms change), any new scan finding, and every change to the harness's effective values. It never applies those changes unattended:--yesrefuses, naming them. It reports the[defaults]suggestions the new commit added or changed and offers only those, so a suggestion the package left alone is not asked about twice; a key your table already sets is never moved, even under--accept-defaults.prunereads only the globalharness.toml. A pin referenced only by a project file is removed, and that project's nextupreports it missing.- The package README.
info,installandupgradeprint the package'sREADME.mdafter the requests.- It is shown as plain text with terminal control sequences removed, and nothing in it is run.
- It stops after 200 lines;
--readme-fullprints all of it. upgradealso shows aREADME.mddiff when the README changed.- A package without a README gets a
lowpackage.no-readmefinding, which does not block.
harness describe NAME marks each value a package supplied with
(package), and harness doctor reports a sourced harness whose pin is
missing.
A package's [[env]] variables are checked against what the harness's
process would see: its env_file (one file or a list, later file winning),
then the daemon's own environment. A variable counts as set only when its
value is non-empty. harness doctor adds one agent_env row per sourced
harness that declares variables (--json: the agent_env list). A missing
required variable fails the row, and a missing optional one warns with its
description. harness describe NAME lists each variable as set (env_file),
set (environment) or unset. Both report names only, never a value.
Project verbs
harness up # bring the enclosing project up (detached)
harness down [PROJECT] # stop & deregister a project's harnesses
harness ps # project-scoped listing
See Projects for the full story, discovery rules, and the project file schema.
Daemon subcommands
harness daemon is its own subcommand group:
harness daemon # run the supervisor in the foreground (== daemon run)
harness daemon stop # ask the running daemon to shut down (SIGTERM)
harness daemon status # one-shot: daemon info
harness daemon --detach # fork into the background (dev convenience)
Daemon flags: --config, --socket, --scrollback-bytes SIZE (per-harness
scrollback ring storage, default 1MiB), --scrollback N (and at most N lines
of it), --ssh, --ssh-listen, --webhook-listen, --log-level, --log-file,
--memory-limit SIZE, --pprof-addr H:P, --detach.
All but --config and --detach can also be set in harness.toml
([daemon] / [server]) or a HARNESS_* variable; see
Configuration → Environment variables.
--memory-limit(HARNESS_MEMORY_LIMIT,[daemon] memory_limit) sets the daemon's Go soft memory limit, e.g.2GiB;0is off. Any of the three overridesGOMEMLIMIT. With none set,GOMEMLIMITapplies if present, and otherwise there is no limit.--pprof-addr(HARNESS_PPROF_ADDR,[daemon] pprof_addr) serves/debug/pprof/on a loopback address. A non-loopback address makes the daemon refuse to start.
See Memory limit and profiler.
Exit codes & error handling
Every error is classified and rendered as a styled error box with an actionable
hint. A --json error still prints a structured object. Exit code is 0 on
success, non-zero on failure (doctor returns non-zero when any check fails;
trigger --wait returns the run's exit code, as described under
Scheduled jobs).