Add your repository
harness projects add ~/code/my-app --name my-app
harness projects list
Add a project on the dashboard's Projects page does the same. projects add registers the repository in
$HARNESS_HOME/projects.json, which is ~/.harness/projects.json by default. It creates this in your repository:
| Path | What it is | In git |
|---|---|---|
.harness/config.json | the project's settings | yes, versioned |
.harness/tasks/ | one Markdown file per task | no |
.harness/trace/ | the evidence every unit leaves | no |
.harness/JOURNAL.md | notes agents leave for the next unit | no |
It appends those paths, .harness/artifacts/ and .env to the project's .gitignore. A folder counts as a harness
project only when it has .harness/config.json. harness projects remove <id|name> unregisters a project and never
deletes files.
Then create the first task with harness new --title "…" --project my-app, or with New task on the dashboard.
config.json
projects add writes only the keys you are likely to change:
{
"baseBranch": "main",
"autonomy": {
"mode": "gated"
},
"limits": {
"bounces": 2,
"prototypeRounds": 3,
"unitTimeoutMinutes": 60
},
"gate": null,
"probe": null,
"deliver": {
"pr": false,
"remote": "origin"
}
}
Every other key keeps its default in code, so a changed default reaches every project.
| Key | Default | What it does | More |
|---|---|---|---|
baseBranch | main | the branch every task branch starts from | Task lifecycle |
autonomy.mode | gated | gated: a plan and a prototype wait for you; full: they move on | Task lifecycle |
limits.bounces | 2 | how many rejections park a task for you | Contracts |
limits.prototypeRounds | 3 | the most prototype rounds per task | Task lifecycle |
limits.unitTimeoutMinutes | 60 | a unit that runs longer is a hard stop | Contracts |
gate | null | the project's check command; a task's own gate: wins | Contracts |
probe | null | a command that must exit 0 before a unit starts | Contracts |
deliver.pr, deliver.remote | false, origin | push the branch and open a pull request for a done task | Task lifecycle |
Keys you can add when you need them:
| Key | Default | What it does | More |
|---|---|---|---|
roles.models | coordinators sonnet, judge opus | the model per role; the judge must differ from the implementer and the tester | Roles |
roles.mergeQaJudge | false | one review unit per task instead of two | Roles |
notify.events | all seven | which Telegram events to send | Running the loop |
agent.command | null | a command that replaces claude, like AGENT_CMD | |
agent.permissionMode | dontAsk | claude --permission-mode: refuse what is not allowed instead of waiting | |
agent.allowedTools | a list | claude --allowedTools; read-only grep, cat, head, tail and wc, and sleep, are in; commits and pushes are absent on purpose | |
agent.settingSources | project,local | claude --setting-sources: your personal hooks never fire inside the loop | |
supervisor.* | in code | poll and wait intervals, and barrenUnits: 3 | Running the loop |
app | null | start the project's app, and a browser, for the qa and prototype units, described below |
Your app for qa and prototype units
Set app when a unit should see your project running. The runner starts the app, never the agent:
"app": { "start": "pnpm exec next dev -p {port}" }
| Key | Default | What it does |
|---|---|---|
app.start | required | the command that starts the app; without {port} the config is refused (exit 2) |
app.ready | "/" | the path the runner polls until the app answers |
app.timeoutSeconds | 60 | how long the runner waits for that answer |
app.host | "localhost" | the host in the address the units get; Next 16's dev server serves its live-reload socket only on localhost, so a page on 127.0.0.1 never hydrates |
app.stages | ["qa", "prototype"] | the unit stages that get the app and the browser |
app.browser | "@playwright/mcp@0.0.83" | the Playwright MCP package the browser runs through npx, pinned |
The example project sets "app": { "start": "pnpm exec next dev -p {port}", "ready": "/" }, so its qa and prototype units
get the app at http://localhost:<port>.
Before each unit of those stages, the runner picks a free port, starts the command in its own process group and waits
for the app. The unit gets the address in HARNESS_APP_URL and as a line in ## This run, which also tells the agent
not to start or stop a server. After the unit, whatever its outcome, the runner stops exactly that process group. If
the app does not start, the task's Log says why and the unit runs without it. A run also stops an app whose runner
died, and logs it.
The runner starts the app before it records the tree for the scope contract, so files the server writes at start are
not the unit's. It also removes the AI-agent markers from the app's environment: Next.js 16.4 writes AGENTS.md into a project when it
detects an agent, and that stray file would park a read-only unit. NODE_ENV and Next's own internals (__NEXT_*,
NEXT_PRIVATE_*, NEXT_RUNTIME, NEXT_DEPLOYMENT_ID, TURBOPACK*) go too, so an app started from the dashboard's
supervisor runs as a plain next dev. A tracked file the app rewrites while it runs is checked out again after it
stops, and the task's Log names it.
The browser
A unit of an app.stages stage also gets a browser, but only when its app started: no app, no browser. Every other
unit gets no MCP server at all. The runner writes .harness/trace/unit-NNNN.mcp.json and passes it with
--mcp-config … --strict-mcp-config, adds mcp__playwright to the allowlist, which allows every tool of that one
server, and sets HARNESS_MCP_CONFIG. The server runs as npx -y <app.browser> --headless --isolated --browser chrome,
so npx must be on PATH: harness agents --check warns when it is not, and its exit code stays the same.
harness preview shows these flags for an app stage.
The stage's coordinator passes the app's address to its specialist, because a specialist sees only its dispatch
prompt. A specialist that lists its tools gets mcp__playwright in the --agents JSON; the judge never does.
Playwright MCP saves a named screenshot in the project root, not in the artifacts folder. After a browser unit, the
runner moves new untracked png, jpg and webp files from the project root into .harness/artifacts/T-NNN/ and notes
each one in the task's Log. A unit without a browser still parks on such a file as a stray file.
Your own specialist
A specialist is one Markdown file. Put it in <project>/.claude/agents/. The harness merges that folder with its base
agents, and a file with a base agent's name replaces that agent.
---
name: a11y-reviewer
description: Checks every changed screen for keyboard access and contrast, and reports what fails.
model: sonnet
tools: Read, Grep, Glob
stages: [qa]
when: in the qa stage of a `kind: ui` task
---
You review accessibility. You change nothing.
| Field | What it means |
|---|---|
name | the agent's name; the file name when it is missing |
description | required: one line the coordinator reads to decide whether to call it |
model | its model; roles.models.<name> overrides it |
tools | the tools it may use |
stages | the stages that offer it; without stages, every stage offers it |
when | a hint for the coordinator: in which tasks to call it |
Then add the standing rules to the file and check the registry:
harness agents --sync --project my-app
harness agents --check --project my-app
The coordinator of each stage reads the offered specialists and calls the ones the task needs.
Environment variables
| Variable | What it changes |
|---|---|
HARNESS_HOME | where the project registry and the fallback .env live; ~/.harness by default |
HARNESS_AGENTS_DIR | the folder of base agents; harness-demo's agents/ by default |
AGENT_CMD | a command that replaces claude for every unit; the fake agent uses it |
HARNESS_GH | a command that replaces gh for delivery |
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID | Telegram notifications, described on Running the loop |
Before the first real run
- The base branch is clean and up to date. The supervisor refuses a dirty tree it did not make.
- The gate passes when you run its command yourself.
harness agents --checkends withok.harness preview T-NNNshows the prompt you expect.- The first run is short: use
--once-throughor--max-units <n>, and keep the dashboard open.