Reference

HTTP API

Every route harnessd serves, with its method, path, minimum role, request body and response; plus the Mac app's local loopback API.

harnessd serves a JSON API over HTTPS on its listen address. The Mac app uses it for Remote mode, and you can call it from scripts. Every request is authenticated, role-checked, and every write is audited. The Mac app also serves a small, unauthenticated API on loopback; see Local mode API.

Basics#

Base URL: https://<listen address>, for example https://harness.internal:8443. Clients should pin the server's certificate (see TLS).

Authentication: sign in with POST /auth/login, then send the token on every request:

Shell
curl --cacert fullchain.pem https://harness.internal:8443/api/v1/whoami \
  -H "Authorization: Bearer hs_…"

Roles: each route below shows the minimum role. viewer < operator < admin. A request without a valid session gets 401 unauthenticated; one with too low a role gets 403 forbidden ("requires the operator role").

Step-up codes: routes that change secrets, test logins, server settings or app-added projects also need totp, a fresh code from the admin's authenticator, in the body. Each code works once.

Errors are JSON with a code and a message:

JSON
{ "error": { "code": "forbidden", "message": "requires the admin role" } }

Some add details, such as problems (a list of validation messages) or version.

Limits and rules:

  • Requests with an Origin header are refused: 403 origin_not_allowed. There's no browser client.
  • Request bodies are capped at 1 MiB (413 payload_too_large).
  • Long-poll routes wait up to 25 seconds for a change.
  • Path segments (project names, task identifiers, variable names) are percent-decoded.

Authentication#

POST/auth/loginNo auth

Signs in. No token needed.

Request
{ "username": "maria", "password": "…", "totp": "123456" }

totp is required for accounts with an authenticator, and for admins when require_totp_for_admins is on.

200 Response
{ "token": "hs_…", "role": "operator", "expires_in": 43200, "idle_timeout": 3600 }
StatuscodeWhen
400bad_requestMissing username or password
401totp_requiredRight password; send the code
401invalid_credentialsAnything else, without saying which part was wrong
429lockedToo many failed attempts for the account or IP
POST/auth/logoutviewer

Revokes the current session. Returns { "ok": true }.

GET/healthzNo auth

Liveness check without details. Returns { "ok": true }.

GET/api/v1/whoamiviewer

Returns { "username": "maria", "role": "operator" }.

Projects#

GET/api/v1/projectsviewer

Lists projects with a summary of each.

200 Response
{
  "projects": [
    {
      "name": "shop-storefront",
      "status": "running",
      "source": "file",
      "running": 2,
      "retrying": 0,
      "tracker": "ClickUp · Sprint 42",
      "config_errors": [],
      "preflight_error": null,
      "limit_block": null,
      "auto_paused": null,
      "spend_today": { "cost_usd": 12.4, "tokens": 1830000, "runs": 9 },
      "parked": [
        { "issue_id": "86b1x", "issue_identifier": "CU-86b1x", "reason": "stopped after 5 failed attempts in a row …",
          "attempts": 5, "cost_usd": 3.1, "at": "2026-11-06T08:12:00Z" }
      ]
    }
  ]
}

source is file for projects in harnessd.yaml and harness for projects added from the app.

POST/api/v1/projectsadmin

Adds a project from a repository URL. The server clones it into data_dir/repos/<name>, checks the workflow file, stores the project in its overlay and optionally starts it. Needs totp.

Request
{
  "name": "shop-storefront",
  "repo": "git@github.com:your-org/shop-storefront.git",
  "workflow": "WORKFLOW.md",
  "auto_start": true,
  "set": { "SENTRY_DSN": "https://…" },
  "logins": [
    { "name": "shopper", "origins": ["http://localhost:3000"], "username": "qa@example.com", "password": "…" }
  ],
  "totp": "123456"
}

repo must be https://, ssh:// or user@host:path. workflow defaults to WORKFLOW.md and must be inside the repository. set and logins are optional: the project's variables and test logins.

Returns 201 with the project summary.

StatuscodeWhen
400bad_projectInvalid name, URL, workflow path, variable or login; see problems
409name_takenA project with that name exists
409busyThe same project is being added
422add_failedThe clone failed, or the repository has no readable workflow file
DELETE/api/v1/projects/<p>admin

Removes a project added from the app: stops it and deletes its variables and test logins. The clone stays. Body: { "totp": "123456" }. Returns { "removed": "<p>" }. Projects in harnessd.yaml get 409 defined_in_config.

POST/api/v1/projects/<p>/startadmin

Starts the project's orchestrator. Returns the project summary. Also /stop, /pause (stop dispatching; agents continue) and /resume (which also clears an automatic pause).

Board and live state#

GET/api/v1/projects/<p>/live?since=<version>viewer

Long-poll for the project's live state. Returns at once if the state's version differs from since, otherwise waits up to 25 seconds.

200 Response
{
  "version": "a1b2c3d4e5f60718",
  "project": { "name": "shop-storefront", "status": "running", "…": "…" },
  "state": { "running": [ … ], "retrying": [ … ], "codex_totals": { … } },
  "board": {
    "columns": ["to do", "in progress", "review", "complete"],
    "editable": true,
    "issues": [
      { "id": "86b1x", "identifier": "CU-86b1x", "title": "…", "state": "in progress", "priority": 2,
        "labels": ["agent"], "running": true, "parked": null, "waits_on": [] }
    ]
  }
}

Each issue has the prompt-template fields plus running, parked (the reason, or null) and waits_on (linked tasks it waits on).

GET/api/v1/projects/<p>/mirror?since=<version>viewer

Long-poll for the full project as the Mac app mirrors it: configuration, running agents, retries, history and the workflow's text and version. Errors and messages are redacted. This is the app's internal format and may change.

GET/api/v1/projects/<p>/issues/<id>viewer

One task, by id or identifier, with comments (author, body, at) and linked_tasks (project, identifier, title, state, done, wait). 404 issue_not_found if it isn't on the board.

POST/api/v1/projects/<p>/issuesoperator

Creates a task.

Request
{ "title": "Fix the cart total rounding", "description": "Markdown…", "priority": 2,
  "state": "Todo", "labels": ["bug"], "blocked_by": ["HAR-3"] }

Only title is required (up to 300 characters). state defaults to the first board column and must be one of them. priority is 0–4; up to 20 labels. Returns 201 { "identifier": "HAR-14" }. 409 read_only_board for GitHub projects; 502 tracker_error if the tracker refuses.

POST/api/v1/projects/<p>/issues/<id>/stateoperator

Moves a task. Body: { "state": "Todo" }, one of the board's columns (400 bad_state otherwise). Returns the issue.

POST/api/v1/projects/<p>/issues/<id>/commentsoperator

Adds a comment. Body: { "body": "Markdown" } (up to 50,000 characters). The comment is signed — <user> via SI Fabrik. Returns the issue.

POST/api/v1/projects/<p>/issues/<id>/dispatchoperator

Dispatches the task now. 409 not_running if the project isn't started. Returns the issue.

POST/api/v1/projects/<p>/issues/<id>/release-waitsoperator

Stops the task waiting on its linked tasks. Returns the issue.

POST/api/v1/projects/<p>/issues/<id>/resumeoperator

Un-parks a task a guardrail parked. Returns the project summary; 404 not_parked if it isn't parked.

Runs and transcripts#

GET/api/v1/projects/<p>/runs?limit=50viewer

Persisted run history, newest first. limit is 1–200, default 50.

200 Response
{
  "project": "shop-storefront",
  "runs": [
    { "id": "7f0c…", "issue_id": "86b1x", "issue_identifier": "CU-86b1x", "title": "…",
      "outcome": "succeeded", "error": null, "started_at": "…", "ended_at": "…", "duration_s": 812.4,
      "turns": 6, "runner": "claude_code", "tokens": { "input": 120000, "output": 18000, "total": 138000 },
      "cost_usd": 2.31, "pull_request_url": "https://github.com/your-org/shop-storefront/pull/88", "delivery": "…" }
  ]
}
GET/api/v1/projects/<p>/runs/<run_id>operator

One run with its transcript: items with at, kind, title and body, redacted and trimmed as stored. 404 run_not_found.

GET/api/v1/projects/<p>/running/<id>/transcript?after=<n>operator

Long-poll for new transcript items of a running agent, after the first n.

200 Response
{ "running": true, "identifier": "CU-86b1x", "total": 42, "phase": "streamingTurn",
  "items": [ { "at": "…", "kind": "toolCall", "title": "Bash", "body": "npm test" } ] }

When the agent isn't running, returns { "running": false, "total": <n>, "items": [] }. Bodies are redacted and capped at 64,000 characters.

POST/api/v1/runs/<issue_id>/stopoperator

Stops the running agent for a task, in whichever project. Returns { "ok": true }, or 404 run_not_found.

Workflow#

GET/api/v1/projects/<p>/workflowoperator

Returns { "text": "---\ntracker: …", "version": "<sha256 of text>" }.

PUT/api/v1/projects/<p>/workflowadmin

Saves WORKFLOW.md and hot-reloads the project.

Request
{ "text": "---\n…", "base_version": "<version from GET>" }

Returns { "version": "…" }.

StatuscodeWhen
409changedThe file changed since base_version; the response includes the current version
413too_largeOver 256 KB
422invalid_workflowIt doesn't parse or validate, or it's below the sandbox floor; see problems

Workspaces#

GET/api/v1/projects/<p>/workspacesoperator

The project's task workspaces: [{ "key": "CU-86b1x", "branch": "symphony/CU-86b1x", "saved": true, "in_use": false, "modified": "…" }].

DELETE/api/v1/projects/<p>/workspaces/<key>admin

Saves the workspace's task branch into the project repository, then removes it. Returns { "removed": "<key>" }. 404 not_found if there's no such workspace; 409 running if an agent is in it; 409 not_removed if the branch couldn't be saved.

Server settings#

GET/api/v1/serverviewer

Server facts for the General tab: listen, tls, certificate_sha256, tools_listen, require_totp_for_admins, session, users, projects, docker, which credentials are set (booleans), and the effective gateway, limits and sandbox.

GET/api/v1/server/settingsviewer

The effective settings, where each section comes from, and (for admins) variable names.

200 Response
{
  "gateway": { "enabled_by_default": true, "base_url": "http://10.8.0.20:4000", "auth_header": "bearer",
               "opus_model": "", "sonnet_model": "", "haiku_model": "claude-haiku-5-5", "codex_model": "",
               "tag_requests": true, "disable_experimental_betas": false, "wire_api": "responses" },
  "limits": { "max_concurrent_agents": 6, "daily_budget_usd": 200 },
  "sandbox": { "allow": ["registry.npmjs.org", "pypi.org"], "cloudflare_bridge_url": null },
  "sources": { "gateway": "file", "limits": "harness", "sandbox": "file" },
  "variables": [ { "name": "CLICKUP_API_TOKEN", "source": "file" } ],
  "can_edit": true
}
PUT/api/v1/server/settingsadmin

One Save: any of the sections, server-wide variables and resets, under one code.

Request
{
  "limits": { "max_concurrent_agents": 8, "daily_budget_usd": 250 },
  "reset": ["sandbox"],
  "set": { "HARNESS_GATEWAY_API_KEY": "sk-…" },
  "remove": ["OLD_TOKEN"],
  "totp": "123456"
}

gateway, limits and sandbox take the same shapes as the GET response. reset returns sections to harnessd.yaml. Returns the new settings. 400 bad_settings with problems if a value is invalid. An empty change returns the current settings without asking for a code.

POST/api/v1/server/gateway/testadmin

Lists the gateway's models from the server. Body: { "base_url": "http://10.8.0.20:4000", "auth_header": "bearer", "key": "optional" } (without key, the saved key is used). Returns { "models": [...], "latency_ms": 42 }, or 502 gateway_unreachable.

POST/api/v1/server/sandbox/testadmin

Starts a throwaway Cloudflare sandbox with no network, runs a command and destroys it, with the saved token. Body: { "bridge_url": "optional" }. Returns { "ok": true, "message": "Connected — a sandbox started and ran a command" }.

Sandboxes#

GET/api/v1/server/sandboxesviewer

The server's Sandboxes screen: docker (engine info), running_containers, agent_image and browser_image (reference, present, size, built), containers (only this server's), build (building, browser, log, succeeded), cloudflare (bridge_url, token_set, healthy, relay_connected, relay_routes, relayed_requests, hub, projects) and kept Cloudflare workspaces.

GET/api/v1/server/containers/<id>/logsoperator

A container's logs, redacted: { "name": "…", "logs": "…" }. Only this server's containers (404 not_found otherwise). An id prefix of 12 or more characters works.

POST/api/v1/server/containers/<id>/stopoperator

Stops and removes one of this server's containers. Returns { "stopped": "<name>" }, or 502 docker_error.

POST/api/v1/server/images/buildadmin

Builds harness-agent:latest, or with { "browser": true } the browser image. Returns 202 { "building": true }; follow progress in /api/v1/server/sandboxes. 409 docker_unavailable or 409 building.

Secrets#

All secret routes are admin-only and write-only: responses contain names and sources, never values. Changes need totp.

GET/api/v1/secretsadmin

Server-wide variable names: { "variables": [ { "name": "CLICKUP_API_TOKEN", "source": "file" } ] }. source is file (from secrets.env) or harness (set from the app).

PUT/api/v1/secrets/<NAME>admin

Sets a server-wide value in the overlay. Body: { "value": "…", "totp": "123456" }. One line, up to 16 KB. Returns the list. DELETE with { "totp": "…" } removes the overlay value, falling back to the file's.

GET/api/v1/projects/<p>/secretsadmin

The project's variable names, and its test logins without passwords: name, username, origins, has_totp, problems.

PUT/api/v1/projects/<p>/secrets/<NAME>admin

Sets a project value. Same body as server-wide. DELETE removes it.

PUT/api/v1/projects/<p>/logins/<name>admin

Saves a test login.

Request
{ "origins": ["http://localhost:3000"], "username": "qa@example.com", "password": "…",
  "totp_secret": "JBSWY3DPEHPK3PXP", "totp": "123456" }

400 bad_login with problems if it isn't valid. DELETE with { "totp": "…" } removes it.

Common step-up errors:

StatuscodeWhen
401totp_requiredNo code in the body
401bad_totpWrong or already-used code
403totp_not_enrolledThe admin has no authenticator (harnessd user totp <name>)
429lockedToo many wrong codes

Symphony state API#

The read API from the Symphony spec (§13.7), shared with Local mode.

GET/api/v1/stateviewer

Aggregated runtime state across projects: counts (running, retrying), running and retrying entries, codex_totals (input_tokens, output_tokens, total_tokens, seconds_running), rate_limits and per-project snapshots. Add ?project=<name> for one project.

GET/api/v1/<identifier>viewer

Runtime detail for one task (for example /api/v1/HAR-12): its running or retry state, workspace, recent events and log paths. 404 issue_not_found if it isn't in the current runtime state.

POST/api/v1/refreshoperator

Polls and reconciles now. Returns 202 { "queued": true, "coalesced": false, "requested_at": "…", "operations": ["poll", "reconcile"] }. Repeated requests are coalesced.

Agent tools endpoint#

POST/mcp/<session-token>session token

An MCP streamable-HTTP endpoint (JSON responses) for one agent session, served only on tools_listen. It supports initialize, ping, tools/list and tools/call; DELETE ends the session, and SSE streams aren't supported. The token is per run and is injected into Claude Code with --mcp-config and into Codex through its mcp_servers config. Unknown or expired tokens get a JSON-RPC error. See Agent MCP tools.

Local mode API#

The Mac app serves this on http://127.0.0.1:7878 (change it in Settings → General). It's loopback only and has no authentication.

RouteDescription
GET /A minimal HTML dashboard: totals, running agents, retry queue
GET /api/v1/stateAs above; ?project=<name> narrows
GET /api/v1/<identifier>As above
POST /api/v1/refreshAs above, 202
POST /mcp/<session-token>The agents' tracker tools

See also#