Skip to main content

Using the HTTP API

harnessd is the HTTP daemon that backs every go-code agent run. It exposes a REST + Server-Sent Events (SSE) API on a single port (default :8080). Any process that can make HTTP requests — a shell script, a CI job, another service — can start agent runs, stream their output in real time, and control them mid-flight.

This guide walks you through the two execution models, the full RunRequest body, the run-control endpoints, and how to handle errors and limits. The streamed-run examples use the built-in fake provider for key-free local testing, which requires a turns JSON file and allow_fallback:true in the request body — see the startup step for details. The POST /v1/agents synchronous endpoint requires a real configured provider (it does not support allow_fallback in the request body and cannot use the fake provider via the fallback path).


Two execution models

There are two ways to run an agent:

ModelEndpointWhen to use
StreamedPOST /v1/runs + GET /v1/runs/{id}/eventsLong-running tasks; you want real-time tool/token events
SynchronousPOST /v1/agentsShort fire-and-wait tasks; you only need the final output

Streaming is not the only way to run an agent. POST /v1/agents is a distinct, fully synchronous execution model that blocks until the agent finishes and returns the result directly in the response body. Use it when you don't need the event stream and want simpler client code.


Start and stream a run

The streamed path is two HTTP calls:

  1. POST /v1/runs — start the run, get a run_id
  2. GET /v1/runs/{id}/events — open an SSE stream and receive events until the terminal event
  1. Start the daemon (key-free)

    The fake provider requires a turns JSON file that describes the scripted responses it will return. Write one first, then start the daemon from the repository root (it loads prompts/catalog.yaml relative to the working directory):

    # 1. Write a turns file (one scripted response per element).
    cat > /tmp/fake-turns.json <<'EOF'
    [
    {
    "content": "Hello!",
    "usage": {"prompt": 100, "completion": 50},
    "cost_usd": 0.001,
    "cost_status": "available"
    }
    ]
    EOF

    # 2. Start the daemon pointing at that file.
    HARNESS_PROVIDER=fake \
    HARNESS_FAKE_TURNS=/tmp/fake-turns.json \
    HARNESS_AUTH_DISABLED=true \
    go run ./cmd/harnessd

    The server prints harness server listening on :8080 when ready. HARNESS_PROVIDER=fake selects the built-in scripted provider (no API key, no network calls), HARNESS_FAKE_TURNS tells it which turns file to load, and HARNESS_AUTH_DISABLED=true skips Bearer token validation.

  2. POST /v1/runs — start a run

    curl -s -X POST http://localhost:8080/v1/runs \
    -H "Content-Type: application/json" \
    -d '{"prompt": "say hello", "allow_fallback": true}'

    allow_fallback: true is required in fake mode: without it, model resolution selects the OpenAI provider and the run fails immediately with API key env "OPENAI_API_KEY" is not set. With allow_fallback: true the runner falls back to the fake provider when the primary provider is unavailable.

    The server responds immediately with HTTP 202 Accepted:

    {"run_id": "run_abc123", "status": "queued"}

    The status is "queued" on creation; it transitions to "running" once a worker slot opens. The run_id is how you refer to this run in all subsequent calls.

  3. GET /v1/runs/{id}/events — stream events

    curl -s -N \
    "http://localhost:8080/v1/runs/run_abc123/events"

    The -N flag disables curl's output buffering so you see events as they arrive. The response is text/event-stream. With the fake-mode setup above (turns file + allow_fallback: true) you will see:

    id: run_abc123:0
    retry: 3000
    event: run.started
    data: {"id":"run_abc123:0","run_id":"run_abc123","type":"run.started","timestamp":"...","payload":{"prompt":"say hello"}}

    id: run_abc123:1
    retry: 3000
    event: assistant.message
    data: {"id":"run_abc123:1","run_id":"run_abc123","type":"assistant.message","timestamp":"...","payload":{"content":"Hello!"}}

    id: run_abc123:2
    retry: 3000
    event: run.completed
    data: {"id":"run_abc123:2","run_id":"run_abc123","type":"run.completed","timestamp":"...","payload":{"output":"Hello!","usage_totals":{...},"cost_totals":{...}}}

    Without allow_fallback: true in the POST body, or without HARNESS_FAKE_TURNS set, the terminal event will be run.failed with model "gpt-4.1-mini": provider "openai": API key env "OPENAI_API_KEY" is not set instead of run.completed.

    The stream closes automatically after a terminal event. There are exactly three terminal events:

    EventMeaning
    run.completedRun finished successfully
    run.failedRun failed with an error
    run.cancelledRun was cancelled via POST /v1/runs/{id}/cancel

Why streaming bypasses the 30-second timeout

Non-streaming endpoints have a 30-second handler timeout by default. SSE event stream paths (those ending in events, stream, or wait) bypass this timeout entirely so long-running agents can stream for as long as needed without the connection being cut.

Reconnecting after a disconnect

Each SSE frame includes an id: field in {run_id}:{seq} format. If your connection drops, reconnect with the Last-Event-ID header set to the last ID you received and the server will replay all events you missed:

curl -s -N \
-H "Last-Event-ID: run_abc123:5" \
"http://localhost:8080/v1/runs/run_abc123/events"

The retry: 3000 field in each frame tells SSE clients to wait 3 seconds before reconnecting. Keepalive pings (: ping) are sent as SSE comment lines every 15 seconds (configurable via HARNESS_SSE_KEEPALIVE_SECONDS).


Synchronous single-shot: POST /v1/agents

For short, fire-and-wait tasks, use POST /v1/agents. It blocks until the agent finishes and returns the result directly:

curl -s -X POST http://localhost:8080/v1/agents \
-H "Content-Type: application/json" \
-d '{"prompt": "what is 2+2?", "timeout_seconds": 30}'

Response:

{
"output": "4",
"summary": "...",
"duration_ms": 412
}

Key differences from the streamed path:

FieldBehavior
promptRequired (XOR with skill)
skillRun a named skill instead of a raw prompt
skill_argsArguments passed to the skill
allowed_toolsRestrict available tools for this run
timeout_secondsDefault 120, max 600

prompt and skill are mutually exclusive. Providing both returns HTTP 400.

POST /v1/agents does not work with the fake provider setup. The agentRequest body has no allow_fallback field. Internally, POST /v1/agents calls RunPrompt, which does not set AllowFallback, so model resolution tries gpt-4.1-miniopenai → and the run fails. Because RunPrompt returns only the (empty) output and discards the run's error, the handler still responds with HTTP 200 and an empty body — {"output":"","summary":"","duration_ms":<elapsed>} — instead of an error. This endpoint requires a real configured provider (a set OPENAI_API_KEY or equivalent). Use POST /v1/runs with "allow_fallback": true for key-free local testing.


RunRequest body

POST /v1/runs accepts a JSON body that maps to the RunRequest struct (internal/harness/types.go). Only prompt is required; everything else is optional.

Interactive request builder

Fill in the fields below and copy the generated JSON body or curl command. The builder runs entirely in your browser — it does not send any requests. To actually run the command, paste the curl into a terminal where your own harnessd is listening. (The docs site cannot call your local server directly because harnessd does not send CORS headers.)

POST/v1/runsRequest Builder
permissions
JSON body
{
  "model": "gpt-4o"
}
curl command
curl -s -X POST http://localhost:8080/v1/runs \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-4o"
  }'
{
"prompt": "Refactor the auth module to use context propagation",
"model": "gpt-4.1",
"provider_name": "openai",
"workspace_type": "worktree",
"max_steps": 20,
"max_turns": 40,
"max_cost_usd": 0.50,
"allowed_tools": ["bash", "read_file", "write_file"],
"conversation_id": "conv_xyz789",
"agent_id": "refactor-agent",
"agent_intent": "refactor",
"reasoning_effort": "high",
"allow_fallback": true,
"fallback_providers": ["anthropic"],
"permissions": {
"sandbox": "workspace",
"approval": "destructive"
},
"mcp_servers": [
{"name": "my-mcp", "url": "http://localhost:9000"}
],
"profile": "coding"
}

Field reference

Core fields

FieldTypeNotes
promptstringRequired. The task or question for the agent.
modelstringModel ID, e.g. "gpt-4.1", "claude-sonnet". Falls back to server default (gpt-4.1-mini).
provider_namestringExplicit provider override, e.g. "openai", "anthropic". When omitted, the provider is resolved from the model name.
workspace_typestringOne of "" (server default), "local", "worktree", "container", "vm". Unknown values are rejected.
extra_dirs[]stringAdditional absolute directory roots the run may read/work in beyond the workspace root (TUI /add-dir). Each entry must be an absolute path to an existing directory; violations are rejected with HTTP 400. Applies to file-tool path confinement only — the bash sandbox and glob still confine to the primary workspace root.
system_promptstringOverrides the runner's default system prompt for this run.
conversation_idstringPin the run to an existing conversation so the agent has prior context.

Budget and limits

FieldTypeNotes
max_stepsintCap on LLM turns. 0 = runner default (which itself may be unlimited). Negative values are rejected.
max_turnsintCap on assistant turns. Same semantics as max_steps.
max_cost_usdfloat64Spending ceiling in USD. When hit, a run.cost_limit_reached event fires and the run completes normally (not failed). 0 = unlimited. Negative values are rejected.
reasoning_effortstringFor OpenAI o-series models: "low", "medium", or "high". Empty = provider default.

Tools, providers, and context

FieldTypeNotes
allowed_tools[]stringAllowlist of tool names. Empty/nil = all tools available.
mcp_servers[]MCPServerConfigPer-run MCP server configs. These shadow any global server with the same name for the duration of the run.
allow_fallbackboolWhen true, two things happen: (1) if the primary provider cannot be resolved at run start (e.g. missing API key), the runner falls back to the server-level default provider instead of failing immediately; (2) if the primary provider returns a transient runtime error (HTTP 429, 500, 502, 503, 504), the runner retries with the next candidate in fallback_providers. This is required when using the fake provider via HARNESS_PROVIDER=fake — see the startup step.
fallback_providers[]stringOrdered list of provider names to try when allow_fallback is true and the primary fails at runtime. When empty and allow_fallback is true, the runner falls back to the server-level default provider if it differs from the primary.
profilestringTOML profile name to activate. Profiles are read from the runner's ProfilesDir.
prompt_profilestringPrompt profile name (separate from the TOML run profile).
agent_idstringLabel identifying the agent instance (appears in events and audit logs).
agent_intentstringHigh-level intent label used for prompt routing.
task_contextstringAdditional context injected into the run.
dynamic_rules[]DynamicRulePattern-triggered rules injected into the system prompt on demand. Zero token cost until the trigger fires.
tenant_idstringTenant isolation. When auth is enabled, must match the API key's tenant or be omitted.

Permissions

The permissions field controls the two-axis permission model. It is an object — not top-level fields:

{
"permissions": {
"sandbox": "workspace",
"approval": "destructive"
}
}

sandbox and approval are nested inside the permissions object. They are not top-level RunRequest fields. Omitting the permissions block entirely is equivalent to {"sandbox": "unrestricted", "approval": "none"} — the agent runs unsandboxed with no approval gate. See Tools and Permissions for a full explanation of what each value means.

FieldValid valuesDefault
sandbox"unrestricted", "local", "workspace""unrestricted"
approval"none", "destructive", "all""none"

What the server sets (never send this)

initiator_api_key_prefix is populated by the server from the auth context and written to the audit log. It is excluded from JSON deserialization (json:"-") — any value you send in the body is silently ignored.


Controlling a run

Once a run is in progress you can influence or inspect it through the control endpoints. All of these require the runs:write scope (or admin).

POST /v1/runs/{id}/cancel

Request cooperative cancellation. The agent finishes its current step, then stops.

curl -s -X POST http://localhost:8080/v1/runs/run_abc123/cancel

Response:

{"status": "cancelling"}

A run.cancelled terminal event will follow on the event stream.

Additional inspection endpoints

MethodPathWhat it returns
GET/v1/runs/{id}Full run object including status, output, error, usage
GET/v1/runs/{id}/summaryPost-run telemetry: steps, tokens, cost, tool calls, cache hit rate
GET/v1/runs/{id}/contextContext window status (token usage, headroom) for an active run
GET/v1/runs/{id}/todosTodo list for the run
PUT/v1/runs/{id}/todosReplace the todo list

Errors and limits

Error envelope

All error responses use a consistent JSON envelope:

{"error": {"code": "invalid_request", "message": "prompt is required"}}

Scope errors use a slightly different shape:

{"error": "insufficient_scope", "required": "runs:write"}

Request body limits

LimitValueApplies to
Default max body1 MiBAll endpoints except replay
Replay max body4 MiBPOST /v1/runs/replay only
Handler timeout30 secondsAll non-streaming endpoints

Streaming endpoints (/events, /stream, /wait path suffixes) bypass the 30-second handler timeout.

Authentication

By default, harnessd validates a Bearer token on every request:

curl -H "Authorization: Bearer <your-token>" \
http://localhost:8080/v1/runs

For SSE EventSource clients that cannot set custom headers, pass the token as a query parameter instead:

GET /v1/runs/{id}/events?token=<your-token>

Auth is disabled when HARNESS_AUTH_DISABLED=true is set, or when no database store is configured (i.e., HARNESS_RUN_DB is not set). There are three permission scopes:

ScopeGrants
runs:readGET on runs, conversations, models, skills
runs:writePOST/PUT/DELETE mutations; also satisfies runs:read
adminSuperscope; satisfies any check

Auth is implicitly disabled when HARNESS_RUN_DB is not set, even without HARNESS_AUTH_DISABLED=true. There is no key store to validate against, so all requests are accepted unauthenticated. This is intentional for local development but set HARNESS_RUN_DB before exposing the server to a network.

HTTP server timeouts

These are hardcoded at the http.Server level and are not configurable via environment variables:

TimeoutValue
ReadTimeout60s
ReadHeaderTimeout10s
IdleTimeout120s

Event reference (quick look-up)

A run emits dozens of event types. Here are the most commonly consumed ones. For a complete catalog with payload shapes, see Events.

EventWhen
run.startedRun begins executing
run.queuedWorker pool is full; run will start when a slot opens
run.completedTerminal: run finished successfully
run.failedTerminal: run failed
run.cancelledTerminal: run was cancelled
run.waiting_for_userAgent called ask_user_question; run paused
run.cost_limit_reachedmax_cost_usd ceiling hit; run continues to completion
assistant.message.deltaStreaming text token from the assistant
assistant.messageFull assistant message (no tool calls in this turn)
tool.call.startedA tool is being executed
tool.call.completedA tool finished (success or error)
tool.approval_requiredTool waiting for operator approval
usage.deltaPer-step token and cost accounting

The run.completed payload always includes output, usage_totals, and cost_totals. The run.failed payload includes error and, when the reason is max_steps_reached or max_turns_exhausted, a reason field explaining why.


Next steps