Skip to content

harn run

harn run <script> [--adapter <name>] [--resume-from <run-id>]
[--subscription-only] [--allow-api-billing]
[--policy <file>] [--isolation <mode>]
[--workspace-root <dir>] [--approval-to <address>]
[--max-agents <n>] [--concurrency <n>] [--cwd <dir>] [--json]
harn run resume <run-id> [--json]
harn run workspace <run-id> [--json]
harn run workspaces [--json]
harn run integration prepare <run-id> --policy <file> [--reviewer <name>]
[--reason <text>] [--target-root <dir>]
[--accept-unknown <code>] [--approval-to <address>] [--json]
harn run integration apply <run-id> --yes [--json]
harn run cleanup <run-id> --yes [--json]
harn run reclaim <run-id> --yes [--discard] [--json]
harn approval list [--status pending|approved|denied] [--json]
harn approval show <approval-id> [--json]
harn approval approve|deny <approval-id> [--actor <name>] [--reason <text>] [--json]
harn run proof <run-id> [--json]

A workflow is a small throwaway JS script with staged fan-out: the script (deterministic code), not any model, decides what runs next, and the run always terminates when the script returns. Subagents spawn as headless adapter-CLI subprocesses and are born coordination-registered: they heartbeat, emit canonical events, and stay visible to peers, so a workflow child editing a file is subject to the same claim/guard protections as any other agent. Design record: decision 0015.

Spawn adapters: claude-code (default), codex (codex exec --output-last-message), and cursor (cursor-agent -p --output-format json --trust, since headless cursor refuses untrusted workspaces without the flag). All three are live-verified end-to-end (codex against codex-cli 0.144.5, cursor against cursor-agent 2026.07.16). A missing adapter CLI fails loud with the vendor’s install one-liner and login command in the error, and harn doctor reports all three spawn targets up front (installed? authenticated? how will children bill?). Select the run default with --adapter, or per agent via opts.adapter. Mixed-adapter workflows are legal (triage on one CLI, deep work on another). Codex and cursor expose no per-run cost or max-turns controls, so costUsd is unreported and maxTurns is ignored on those adapters.

Auth: children ride the logged-in adapter session

Section titled “Auth: children ride the logged-in adapter session”

The engine never handles credentials. Each child is the plain adapter CLI, so it authenticates exactly the way that CLI already does on the machine: claude uses the OAuth login in ~/.claude (a subscription seat), codex uses its ChatGPT login, cursor-agent uses cursor-agent login. A subscription login is the default and preferred path. No per-token API billing is required or implied by running workflows.

The child-env scrub deletes session variables (CLAUDE*, CODEX*, CURSOR*), not auth: credential files and keychains are untouched (CURSOR_API_KEY is carved out of the CURSOR* scrub because it’s a credential, not session state). The one way to end up on API billing is an API-key variable in the parent shell (ANTHROPIC_API_KEY, OPENAI_API_KEY, CURSOR_API_KEY): adapter CLIs prefer a key over a stored login when both are present.

The engine probes each adapter’s billing state on its first spawn of the run (transcripted as billing.probe, echoed as a [billing] line, and reported per adapter in the RunReport and web view):

  • subscription: no key exported; children ride the stored login.
  • api-key: key present but no stored login detected, a deliberate key-only host (e.g. CI). Proceeds with a printed notice.
  • api-key-override: key present AND a stored login exists, so the key is silently shadowing subsidized auth, almost always by accident. The engine refuses to spawn unless you pass --allow-api-billing.

--subscription-only goes further: every API-key var is deleted from every child env, so children can only authenticate via their stored login, and a adapter whose login is provably absent fails loud before spawning. Pin it repo-wide with .harnery/config.jsonc. harn init stamps this pin by default into any project without a committed workflow key, so new setups are subscription-only out of the box:

{ "workflow": { "subscriptionOnly": true } }

HARNERY_WORKFLOW_SUBSCRIPTION_ONLY=1|0 overrides per process (the 0 escape hatch exists for a key-only CI job inside a pinned repo). --allow-api-billing and subscription-only mode contradict each other and are rejected as a pair.

Login detection is a heuristic over well-known credential locations (Claude Code’s ~/.claude/.credentials.json, codex’s $CODEX_HOME/auth.json; a codex login --api-key stored key is classified as API-key billing, not a login). Where presence can’t be proven either way (macOS keychain storage, cursor), the state is unknown and never hard-fails. The adapter CLI itself is the final authority.

Two accounting consequences: the costUsd the claude adapter transcripts is the CLI’s notional cost figure (under subscription auth it is not an invoice, just a burn gauge), and a large fan-out consumes the subscription’s usage allowance the same way interactive sessions do, so the agent cap and concurrency pool are still your throttles.

export const meta = {
name: "review-prs",
description: "triage + deep-dive open PRs",
objective: "Produce an inspectable risk assessment for every routed PR",
acceptance: [
{ id: "tests", statement: "The focused test suite passes" },
{ id: "review", statement: "The routed changes receive a final review" },
],
};
const TRIAGE = {
type: "object",
required: ["route", "reason"],
properties: {
route: { enum: ["close", "analyze", "keep"] },
reason: { type: "string" },
},
};
export default async function run({ agent, parallel, stage, log, evidence }) {
stage("triage");
const verdicts = await parallel(
prs.map((pr) => () =>
agent(`Classify this PR… Return ONLY JSON {route, reason}.`, {
schema: TRIAGE,
model: "claude-haiku-4-5",
maxTurns: 1,
})),
);
stage("deep-dive");
for (const [i, v] of verdicts.entries()) {
if (v?.route !== "analyze") continue; // conditional routing: code decides
await agent(`Risk-assess: ${prs[i]}`, { label: `deep: ${prs[i]}` });
}
evidence({
kind: "test",
status: "passed",
label: "Focused workflow tests",
ref: "bun test src/workflows/review-prs.test.ts",
acceptanceIds: ["tests"],
});
}

When the workflow runs through harn work or harn governor, ctx.work contains the frozen assignment:

export default async function run({ work, agent, evidence }) {
if (!work) throw new Error("this workflow requires durable work context");
const result = await agent(
[
"Complete the durable assignment below.",
`Title: ${work.title}`,
`Objective: ${work.objective}`,
"Acceptance:",
...work.acceptance.map((criterion) => `- ${criterion}`),
].join("\n"),
{ specialist: "implementer" },
);
evidence({
kind: "observation",
status: "observed",
label: `Specialist completed ${work.id}`,
});
return result;
}

The shape is { schema_version, id, title, objective, acceptance }. It is deeply frozen before the script starts, copied into the private run manifest and terminal proof, and restored from that manifest after an approval park. Standalone workflows and historical parked runs created before this capability have ctx.work === undefined.

Harnery does not automatically prepend this data to child prompts. The script controls how much assignment context each specialist needs and must preserve the host’s instruction and policy hierarchy when work text came from an external source.

New work-linked runs also receive frozen ctx.attempt data:

export default async function run({ work, attempt, agent }) {
if (!work || !attempt) throw new Error("durable work context required");
const correction =
attempt.trigger === "retry"
? [
`This is attempt ${attempt.number}.`,
`Prior causes: ${attempt.prior.causes.join(", ")}`,
attempt.prior.error ? `Prior error: ${attempt.prior.error}` : "",
...attempt.prior.unresolved.map(
(criterion) => `${criterion.status}: ${criterion.statement}`,
),
].filter(Boolean)
: [];
return agent(
[
`Objective: ${work.objective}`,
...correction,
"Complete the assignment and verify its acceptance criteria.",
].join("\n"),
{ specialist: "implementer" },
);
}

An initial attempt receives { schema_version: 1, number, trigger: "initial" }. A retry additionally receives prior with the prior run ID, deterministic failure causes, the bounded workflow error when present, the acceptance summary, and only unsatisfied or unknown criteria. Causes can be workflow_error, acceptance_unsatisfied, acceptance_unknown, or lost. lost means only that terminal proof is absent; it does not diagnose why the run disappeared.

The engine persists the same value as attempt_context in the private manifest, transcript, and terminal proof. Approval resume restores that original value and does not become a retry. Standalone and historical context-free runs have ctx.attempt === undefined.

Attempt context does not grant retry authority or add attempts. Explicit harn work retry, frozen governor policy, and the existing work and goal-wide ceilings still decide whether another run may start. As with ctx.work, the workflow controls whether and how the data enters a child prompt.

Property or function Contract
work Optional deeply frozen durable-work assignment: work ID, title, objective, and acceptance criteria. Present for new work-linked attempts; absent for standalone and legacy-resumed runs.
attempt Optional deeply frozen attempt identity. A retry includes a bounded synopsis of the preceding terminal evidence; approval resume preserves the original attempt.
agent(prompt, opts?) Spawn one subagent. With opts.schema the reply must parse as one JSON value and validate against the schema subset (type, oneOf, properties, required, additionalProperties, items, minItems, maxItems, minLength, maxLength, pattern, enum). Exact JSON and a whole fenced value are accepted. A adapter-added leading sentence is tolerated only when exactly one unambiguous object or array reaches the end of the reply; trailing prose, multiple candidates, malformed values, and schema mismatches still fail. Failures re-prompt with the validation errors appended, up to opts.maxAttempts (default 2), then throw. Without a schema, resolves to the raw reply text. Other opts: specialist, model, effort, maxTurns (default 25; use 1 for pure classification), timeoutMs (default 300000), label.
parallel(thunks) Run thunks against the run-wide concurrency pool. A rejected thunk resolves to null (transcripted), so one bad item can’t kill the batch; filter and route.
stage(title) Declare the current stage: transcript marker + progress grouping.
log(message) Narrate progress (stderr + transcript).
evidence(input) Attach a bounded receipt to the run and optional acceptance IDs. Returns a stable ID such as e1. Kinds: test, command, artifact, change, review, observation. Statuses: passed, failed, observed, unknown.
authorize(input) Authorize one host-mediated external mutation before performing it. Input includes a short action plus optional path, network, service, and bounded target. Returns the final allow decision; denial or missing host policy throws before control returns.
blocked(input) Stop the run because a human must rule. Input is a reason sentence plus the docket decision id. Never returns. See Blocking on a human.

An evidence kind outside that set is caught before the run starts. evidence() sits near the end of a workflow by construction, so an invalid kind used to throw after every agent had finished and take the whole run’s work with it. Harnery now reads the script first and refuses any literal kind it will not accept, naming the line, before a single agent is spawned. A kind computed at runtime is still only checked when the call executes.

effort is validated and mapped per registered adapter: Claude Code accepts low | medium | high | xhigh | max; Codex accepts none | minimal | low | medium | high | xhigh. Cursor does not accept a separate effort option through Harnery because its effort syntax is embedded inside some parameterized model ids. See harn adapter for the executable capability declaration.

Some work cannot be finished by any agent, however good, because the open question belongs to a person: a product trade-off, a spend authorization, a call about risk. ctx.blocked() is how a script says so.

export default async (ctx) => {
const verdict = await ctx.agent(reviewPrompt, { schema: VERDICT });
if (verdict.needsRuling) {
ctx.blocked({
reason: "which subsystem owns the cart is unsettled",
decision: "who-owns-the-cart-2026-08-01-beaf",
});
}
// …
};

This is not an error path, and using it as one defeats it. A plain throw means the work failed and a retry might do better. blocked() means the opposite: the script was right, and the blocker is a person.

The distinction is load-bearing because it changes what happens next:

  • The attempt is uncharged, so once the decision lands the item retries with its full budget. The same three classes share this treatment; see failure classes.
  • The work item goes to blocked with next_action: "none", so a harn governor running with retry_blocked cannot re-issue it. That automation exists to clear failures without a human; a correct refusal is not a failure.
  • The goal’s own reason names the decision rather than reporting a count of items “needing intervention”, and decision_blocked_work on the projection carries the work/decision pairs for anything rendering a queue.

Without a class of its own, a correct refusal and a botched attempt look identical to the engine. It re-issues the item, the next agent reaches the same correct conclusion, and the loop repeats until the budget is gone, with nobody told, because the thing it is waiting for is a person who was never asked.

Pass the decision id whenever you have one; it is what turns “something needs a human” into a question someone can actually answer. File it first with harn decision. Blocking without an id still stops the item, but the operator gets prose and has to go find the question themselves.

A failed run carries an optional class saying the failure was uninformative about the work. All three mean the attempt is not charged against max_attempts, because nothing was learned about whether the work is doable:

Class Meaning Retry
environment A precondition was missing; the run never started (the adapter CLI was absent). Stops. Retrying an unchanged environment cannot help.
upstream The vendor was reached and refused (5xx, 429, circuit open). Stays available, bounded by max_uncharged_attempts.
decision The script called blocked(): a human must rule. Stops. Only a ruling clears it.

Absent class ⇒ an ordinary work failure, charged and retryable as before.

An embedding host may provide EngineOpts.specialists, and a harn governor freezes those profiles into its durable goal intent. A workflow selects one with agent(prompt, { specialist: "reviewer" }). The profile contributes bounded instructions plus optional adapter, model, effort, turn, timeout, and schema-retry defaults; options on the individual agent call override those defaults.

The engine prepends the frozen role instructions to the assignment and records the specialist ID in the transcript and proof packet. The complete profile map is also frozen in the workflow run manifest. Approval resume therefore cannot silently pick up changed role instructions or model settings. Specialist ID, resolved options, and wrapped prompt participate in resume-cache identity.

  • --max-agents (default 50): total-agent ceiling for the run. The cap failing loud is the runaway backstop; there is no recursive spawning path, because subagents are leaf processes.
  • --concurrency (default 4): one shared slot pool for every spawn in the run, so nesting parallel() inside loops can’t exceed it.
  • Policy receipts are capped at 50 per run. Reaching the cap fails closed before another protected action.

--policy <file> applies a host-owned JSON/JSONC policy immediately before every uncached agent dispatch. The policy can constrain run cost, unknown pricing, adapter, model, working path, network state, and the host-declared isolation mode. It is not part of workflow metadata, so the script and child prompts cannot weaken it.

Rules return allow, ask, or deny. The standalone CLI parks on ask and writes a durable request under .harnery/approvals/; no protected operation runs while it is pending. Library callers remain fail-closed by default and must select EngineOpts.approvalMode = "park" deliberately. An embedding host can still supply EngineOpts.resolvePolicyAsk for immediate decisions. Use harn policy to validate a document before a run.

--isolation declares the requested boundary: shared, worktree, sandbox, or remote. The standalone CLI can create the built-in local Git boundary when worktree is paired with an explicit --workspace-root. Other boundary types still require an embedding host provider. Ordinary CLI adapter subprocesses inherit host networking, so the CLI declares network enabled and a network: deny policy blocks dispatch.

Workflow code may use ctx.authorize() before an external mutation:

await authorize({
action: "publish release",
network: true,
service: "package registry",
target: "https://registry.example.test/package",
});
// Perform the operation only after authorization returns.

This seam protects host-mediated operations. It does not intercept arbitrary tools invoked inside an opaque adapter subprocess. Adapter-native policy mapping remains a separate capability claim in harn adapter show.

Cursor workflow children run in non-interactive print mode with workspace trust and command execution enabled. Print mode has no operator prompt channel, so a child without command authorization can edit through its patch tool but cannot run tests or create a commit. This does not make Cursor’s inner tools policy-aware: harn adapter show cursor still reports policy mapping as unsupported. Use a provider with a stronger filesystem boundary when prompt instructions alone are not sufficient isolation.

The local provider needs an explicit writable parent:

Terminal window
harn run ./review.mjs \
--isolation worktree \
--workspace-root ../workflow-workspaces \
--policy ./workflow-policy.json

Harnery probes Git and filesystem capabilities before allocation. A supported run receives a dedicated branch and worktree. The immutable run manifest binds that workspace to the exact run or durable-work attempt. Children start in the isolated active root, and approval resume reconstructs the same built-in provider from the frozen binding.

If a non-shared mode has no provider, Harnery records a compatibility decision instead of pretending isolation happened. The run uses the shared checkout only when policy permits it. Once a binding exists, resume cannot fall back to shared execution.

Inspect one run or list all isolated and compatibility decisions:

Terminal window
harn run workspace wf-2026-07-24T02-10-00-000Z-a1b2c3
harn run workspace wf-2026-07-24T02-10-00-000Z-a1b2c3 --json
harn run workspaces

The status reader validates the manifest, proof, provider event chain, integration authority, cleanup attempts, and receipts before projecting a lifecycle. It reports allocation, verification, integration, conflicts, cleanup, and resource state. Corrupt or contradictory evidence returns an explicit invalid inspection.

Finish a verified standalone run in explicit phases:

Terminal window
harn run integration prepare <run-id> \
--policy ./integration-policy.json \
--reviewer sam \
--reason "reviewed the exact proof and diff" \
--accept-unknown network_not_attested
harn run integration apply <run-id> --yes
harn run cleanup <run-id> --yes

Preparation previews the fast-forward and writes review, plan, and policy authority. It does not update the target branch. A work-linked run gets its review from exact durable work acceptance, so --reviewer is needed only for a standalone run. Repeat --accept-unknown for each verification fact the reviewer accepts.

If the policy returns ASK, preparation creates a durable approval and parks without applying Git changes or reporting a generic workflow failure. Text mode prints the run, plan, and approval IDs plus a copyable workflow approvals approve command. JSON mode emits a stable parked envelope with status, runId, planId, and approvalId. Resolve the approval, then rerun the same prepare command. The exact plan is reused and authorized. Apply requires --yes and rereads the plan, proof, review, policy decision, approval, target identity, and attempt chain before Git changes. Divergence, target movement, dirty state, or a Git operation in progress blocks the apply. Harnery does not resolve conflicts.

Cleanup also requires --yes. It removes only resources covered by the frozen binding and cleanup intent. A clean unintegrated branch that is not reachable from the target is preserved, as is dirty or ambiguous work. The CLI supports the built-in local Git provider. Embedding hosts use prepareIntegration(), applyIntegration(), and cleanupWorkspace() for other providers.

Preserving dirty work is correct, but a preserved workspace needs a way out. Cleanup will keep finding the same dirty tree and preserving it again, so reclaim resolves the work first and then releases (ADR 0042):

Terminal window
harn run reclaim <run-id> --yes # salvage the work, then release
harn run reclaim <run-id> --discard --yes # throw the work away, then release

Salvage commits the uncommitted work to a durable harnery/salvage/<run-id> branch, so it stays recoverable by name after the worktree directory is gone. Discard is never the default.

Neither mode removes anything itself. Each makes the working tree clean and then hands off to the ordinary cleanup path, so cleanup stays the only thing that removes a worktree. A workspace whose directory is already gone reports already_gone instead of failing.

harn run workspace <run-id> lists the dirty paths, so the choice between salvaging and discarding can be made without leaving Harnery.

--resume-from <run-id> reuses completed agent results from a prior run’s transcript: an agent() call whose identity (stage, adapter, model, effort, maxTurns, schema, and the ORIGINAL prompt) matches a transcripted agent.end returns the recorded result instantly without spawning (transcripted as agent.cached). Changed or previously-failed calls re-run live. Same script + same inputs → 100% cache, $0. A typo’d run id fails loud rather than silently re-running everything.

A CLI run that reaches policy ask writes run.parked, prints its approval ID, and exits without run.end or a terminal proof. Inspect and resolve it:

Terminal window
harn approval list --status pending
harn approval show apr-0123456789abcdef0123
harn approval approve apr-0123456789abcdef0123 \
--actor ryan --reason "reviewed the release target"
harn run resume wf-2026-07-21T12-00-00-000Z-a1b2c3

Requests are immutable private records. Resolution creates a separate exclusive decision record: the first verdict wins, the same-verdict retry is idempotent, and a conflicting retry is refused. Approval does not auto-spawn work. Resume must be invoked explicitly, and an exclusive resume lease refuses concurrent attempts for the same run.

workflow resume reuses the same run ID and transcript. It verifies the frozen script SHA-256, reconstructs completed agent results and committed cost, and reruns the deterministic script from its entry point. Engine-managed agent() calls return cached results and the matching approval decision is replayed. Plain JavaScript side effects executed directly before the park run again; keep them idempotent or put protected effects behind authorize().

Children spawn in --cwd (default: the coord root) and load that directory’s repo-instructions file (CLAUDE.md / AGENTS.md) into their system prompt, a per-child cache-write a fan-out multiplies. The engine prints the estimate up front ([context] each child cache-writes ~51K tokens …, bytes/4 heuristic) and reports it as contextTokensPerChildEstimate in the RunReport. For mechanical stages that don’t need repo instructions, point --cwd at a directory without one.

Human mode prints a run summary (agents, cached count, cost, duration, transcript path, proof path, acceptance summary, result). --json emits the full RunReport. Every run transcripts to .harnery/workflows/<run-id>/transcript.jsonl: run.start, stage.start, agent.start/end (cost, duration, child session id, result), agent.cached, agent.schema_retry, evidence.recorded, run.end. New transcript lines carry schema_version and run_id; older transcripts remain resumable. Durable approval runs also use approval.requested, approval.resolved, approval.consumed, run.parked, and run.resume.

Every terminal run, including a failed one, writes .harnery/workflows/<run-id>/proof.json. The packet is written atomically with mode 0600 and contains:

  • the objective, terminal status, timestamps, and acceptance rollup;
  • the exact bounded work context for work-linked runs;
  • typed evidence declared through ctx.evidence();
  • agent outcomes, attempts, duration, cost, and session ID when reported;
  • SHA-256 digests and byte counts for agent and workflow results, not their raw text;
  • engine-observed repository snapshots before and after the run;
  • adapter tool-evidence coverage from the adapter capability registry;
  • explicit unknowns for missing capability claims, cost, session IDs, and incomplete repository drift;
  • a SHA-256 digest of the final transcript.
  • the normalized host policy, its digest, host isolation/network declarations, and bounded initial/final decision receipts when policy was configured.

Acceptance is deliberately simple. Any attached failed evidence makes a criterion unsatisfied. At least one passed item and no failure makes it satisfied. observed, unknown, or no attached evidence leaves it unknown. An item recorded through ctx.evidence() has source workflow: Harnery stores the declaration but does not claim it independently ran the command or test. Repository snapshots and packet integrity are marked separately as engine-observed.

Inspect a packet with harn run proof <run-id>. Human mode renders the status, criteria, evidence count, repository drift, unknowns, and transcript digest. --json emits the stored schema unchanged. Failed workflow run commands include the proof path in their error output.

Proof packets are bounded to 50 criteria, 200 evidence records, and 512 KiB. Labels, summaries, and references have field-level limits. Unknown acceptance IDs and duplicate criterion IDs fail loud. The packet does not run a secret redactor; do not put credentials into objectives, evidence summaries, or references. The detailed transcript continues to retain full agent results for resume compatibility.

The programmatic surface is exported from harnery/core/workflow, including the engine, proof reader and renderer, workspace provider, integration and cleanup functions, readWorkflowWorkspaceStatus(), inspectWorkflowWorkspace(), listWorkflowWorkspaceInspections(), schema types, and WORKFLOW_PROOF_SCHEMA_VERSION.

The dashboard (harn web up) ships a /workflows page: transcript-driven list of runs (status, agents, cached count, cost, stage chain) and a per-run detail view rendering the stages → agents tree with per-agent adapter, attempts, duration, and cost. Parked runs have a distinct awaiting-approval status and show the durable approval ID and inspection command. When a terminal proof packet exists, the detail page also shows the objective, acceptance criteria, declared evidence, repository drift, host-policy decisions, and explicit unknowns. The run list also shows the workspace lifecycle. Detail pages separate allocation, verification, integration, cleanup, and repository conflicts. Invalid durable workspace evidence appears as an error instead of a healthy badge. Transcript-driven means a run stays inspectable while live and after the orchestrator exits, with no terminal paging limits.

Workflow children and the end-of-turn ritual

Section titled “Workflow children and the end-of-turn ritual”

Children run with the host’s hooks on (that’s what makes them coordination-visible) but with HARNERY_WORKFLOW_CHILD=1, which the stop-hook rule honors as an exemption (stop-hook.workflow_child): the human-facing end-of-turn ritual doesn’t apply to a headless child reporting to a transcript. Everything else (heartbeats, canonical events, claims) behaves normally.