Skip to main content

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.toml path (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
FlagMeaning
NAME..., --harness NAMEharnesses to include (repeatable); none means every harness
--since DUR|TIMEruns started since a duration ago (7d, 36h) or an RFC 3339 instant; default 24h for a query
--until TIMEruns 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 Nat most N records, newest first (1–1000)
--wideadd MODEL, TOKENS, COST and TODO
--jsonthe 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 final result record — the agent's own account of the run's tokens and, when the agent recorded one, its cost — into the record with cost_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 transcripts binding 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:

ColumnMeaning
SOURCEThe reference a harness's triggers names, e.g. channel.sb
STATEconnecting, 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
FORHow 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 EVENTWhen it last fired
FIREDEvents fired since the daemon started
HARNESSESThe 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-hours replaces stopped for a held harness — it is down because its window is closed, not because someone turned it off. stopped is the same latch a give-up produces, so conflating the two would make an operating-hours hold page like a real failure. NEXT reads opens Mon 09:00.
  • closing replaces 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 reads stops by 13:15 — the close's own deadline, which can run past the window's own close time by up to hours_shutdown_timeout.
  • A gated harness running in hours shows NEXT as closes 13:00.
  • SCHEDULE shows the operating_hours expression, with a TZ=/CRON_TZ= prefix dropped when it names the daemon's own zone ($TZ), the same way a cron schedule'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​

ToolReturns
harness_listevery harness with state, enabled/held flags and last exit
harness_describeone harness in detail (config, state, children, sessions)
harness_runsrun history: outcome, exit, duration, trigger, skip reason; with no name, across all harnesses with the ledger's filters
harness_logsone run's output, summary or raw (see below)
harness_jobstriggered harnesses with sources, next run, recent results
harness_triggerstrigger sources with state, last event and error, counters
harness_daemon_infodaemon version, protocol, uptime, socket, counts
harness_triggerrun a triggered harness now; wait follows it to its outcome
harness_start, harness_stop, harness_restartthe 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 carries next_cursor; pass it back as cursor to get only what is newer, which is the polling pattern an agent wants and logs --follow cannot serve.
  • raw: the durable log tail, exactly as harness logs --raw prints it. Page past a truncation with the returned next_offset as offset. 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 a note, rather than coming back empty and reading as "caught up" to a poller that missed output. To poll for what is new, use the summary cursor, 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-x4yx or generic-htop-9k2p, printed when it starts;
  • starts, then attaches you to it, unless you pass --detach or either stdin or stdout is not a terminal;
  • is not restarted when its process exits. It stays in harness list with its exit state until you remove it with harness 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.

FlagWhat it does
--kind KINDUse 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 SLUGUse this slug instead of the kind or command. A random suffix is still appended.
--workdir DIRStart in DIR instead of your current directory. A relative path resolves against your shell's directory, not the daemon's.
--model MODELPrepend --model MODEL to the agent's args. Same as writing harness run claude --model MODEL.
--detachDon'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 stable manages the trust ledger. It was first spelled harness 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 update fetches. Every other verb reads the local clone. agent list's NEWER column compares against the clone as the last update left it.
  • stable lint and stable check are how a stable author validates a checkout (a directory with packages/, . by default) before publishing. They need no daemon, no registered stable and no network.
    • lint uses the parser and scanner install uses. It fails on any manifest violation, high scan finding, [package].name that differs from its directory, path key naming a missing or empty file, symlink, or missing README.md. Other low findings are warnings.
    • check runs lint, then loads each package the way config load does, under a synthetic [harness.<package>] table (with a schedule when 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. --json prints an array with one {package, errors, warnings} object per package for CI annotations.
  • install pins the package at an exact commit in the content-addressed store, scans it, and asks before it writes source = "STABLE/PACKAGE@<sha>" onto [harness.NAME].
    • Without a terminal it refuses unless --yes.
    • --yes never clears a high scan finding or an mcp_allow = ["write"] request.
    • --force-unsafe overrides a high finding after you retype the package.
    • --replace lets the table switch from another package's source.
    • The confirmation shows the package's SPDX license. A package without one carries the package.no-license low finding, which never blocks.
    • Its output ends with an env_file skeleton 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: install offers each suggestion once (accept it, overwrite it with a value you type, or skip it) and writes what you choose as your own keys beside source. Keys your table already sets are never offered. --accept-defaults takes every suggestion unattended; --yes takes none and says so.
  • upgrade re-pins to a newer commit of the local clone (--all for every sourced harness). It shows the manifest diff (a package.license change 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: --yes refuses, 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.
  • prune reads only the global harness.toml. A pin referenced only by a project file is removed, and that project's next up reports it missing.
  • The package README. info, install and upgrade print the package's README.md after 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-full prints all of it.
    • upgrade also shows a README.md diff when the README changed.
    • A package without a README gets a low package.no-readme finding, 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; 0 is off. Any of the three overrides GOMEMLIMIT. With none set, GOMEMLIMIT applies 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).