Skip to content
DocsServer & SDKs
On this page

Your first API call

Everything the terminal UI and Desktop do goes through the server's HTTP API, so your own code can do it too. This page runs one turn with curl and streams the answer as it is written.

You need a running server and jq. The examples use a server on 127.0.0.1:4096 with no password; with one, add -u "haarness:$HAARNESS_SERVER_PASSWORD" to every curl.

export HAARNESS=http://127.0.0.1:4096

1. Create a chat

THREAD=$(curl -s -X POST "$HAARNESS/v1/threads" \
  -H 'Content-Type: application/json' \
  -d '{"title": "First API call"}' | jq -r .id)
echo "$THREAD"

The chat uses the project's default agent and model. To choose, add "provider" and "model" to the body, for example {"provider": "anthropic", "model": "claude-sonnet-5-5"}. haarness models lists what you can use, as provider/model.

2. Watch the event stream

In a second terminal, export HAARNESS again and print the answer as it streams:

CURSOR=$(curl -s "$HAARNESS/v1/info" | jq .eventCursor)
curl -sN "$HAARNESS/v1/events" -H "Last-Event-ID: $CURSOR" \
  | sed -un 's/^data: //p' \
  | jq -rj --unbuffered \
      'select(.type == "turn.text_delta") | .payload.delta'

Leave it running. Last-Event-ID starts the stream after the server's latest event; without it, the stream first replays recent past events. Drop the sed and jq part to see every event.

3. Start a turn

TURN=$(curl -s -X POST "$HAARNESS/v1/threads/$THREAD/turns" \
  -H 'Content-Type: application/json' \
  -d "{\"requestId\": \"$(uuidgen)\",
       \"text\": \"Summarise README.md in two sentences\"}" \
  | jq -r .id)

The call returns at once with the new turn. The answer appears in the second terminal as the model writes it.

requestId is a UUID you choose for each new turn. Sending the same one again returns the turn that already exists instead of starting a second one, so a retry after a timeout is safe.

4. Wait for the result

curl -s "$HAARNESS/v1/turns/$TURN/wait?timeout_ms=120000" | jq -r .status

You see completed, failed or interrupted. A wait returns 409 after at most five minutes; call it again if the turn is still running. To read the whole answer afterwards:

curl -s "$HAARNESS/v1/threads/$THREAD/items" | jq -r \
  '.[] | select(.data.type == "assistant_message") | .data.text'

Approve a tool call

When the agent wants to do something your permission rules ask about, the turn pauses and the stream shows a permission.requested event. List what is waiting and answer it:

curl -s "$HAARNESS/v1/permissions" | jq '.[] | {permissionId, operation}'

curl -s -X POST "$HAARNESS/v1/permissions/PERMISSION_ID" \
  -H 'Content-Type: application/json' -d '{"decision": "once"}'
decision Allows
once This call only
always Matching calls until the server stops
always_workspace Matching calls in this project, from now on
always_repository The same, across worktrees of the repository
reject Nothing. The agent is told you refused, and the chat's other waiting requests are rejected too
refuse Nothing, for this request only. For unattended code: the agent reads it as Haarness's refusal, not a person's

With reject or refuse, add "feedback": "…" to tell the agent why. A request whose allowAlways is false, such as an edit to Haarness's own settings, is meant to be answered once or reject, as the apps offer it.

The agent can also ask you a question (question.requested). Answer it with POST /v1/questions/{id} and {"answer": "…"}.

Running without anyone watching? Add "tools": {"question": false} to the turn so the agent does not stop to ask. "autoApprove": true approves that turn's permission prompts once each and saves no rule; only use it for work you would approve anyway.

New chats start in Auto: the server sends each request to a reviewer model, which approves routine requests once, blocks risky ones with a reason the agent reads, and leaves the rest to you. Those still arrive as permission.requested, with a review note saying why. To be asked about everything in one chat, switch it to Review (Ctrl+Y steps through Review, Auto and Yolo in the terminal UI); the change holds for every client until someone switches it back.

curl -s -X PUT "$HAARNESS/v1/threads/$THREAD/approval-mode" \
  -H 'Content-Type: application/json' -d '{"mode": "review"}' | jq -r .mode

{"mode": "auto"} switches it back, {"mode": "yolo"} approves every request without asking, and GET on the same path shows the current mode with a revision. Send that revision with a change to get 409 if someone changed the mode meanwhile. See Review, Auto or Yolo for what the reviewer sees and never approves.

Stop a turn

curl -s -X POST "$HAARNESS/v1/turns/$TURN/interrupt" | jq -r .status

POST /v1/threads/{id}/interrupt stops everything running in a chat, including subagents and workflows. Disconnecting from the event stream never stops a turn.

Events you will use

Every event has type, eventId, threadId, turnId and a payload. The stream covers the whole server, so filter by threadId, or by rootThreadId to include subagents.

type Payload
turn.started The turn
turn.text_delta {"delta": "…"}, the next piece of the answer
turn.reasoning_delta The next piece of visible reasoning
item.created A message, tool call or tool result
permission.requested permissionId, operation, patterns, and review when Auto left it to you
question.requested questionId, prompt, choices
turn.finished The turn, with its final status

To pick up where you left off after a disconnect, send the last eventId you saw as the Last-Event-ID header. If the stream sends resync_required, reload what you need over REST and open a new stream.

Endpoints for everyday tasks

Task Endpoint
Server, project, default model GET /v1/info
List or create chats GET, POST /v1/threads
Rename, archive, change model PATCH /v1/threads/{id}
Run a turn POST /v1/threads/{id}/turns
Read a chat GET /v1/threads/{id}/items
Wait for or stop a turn GET …/turns/{id}/wait, POST …/interrupt
Pending approvals and questions GET /v1/permissions, GET /v1/questions
Review, Auto or Yolo for a chat GET, PUT /v1/threads/{id}/approval-mode
Review the last change GET /v1/threads/{id}/diff
Undo or redo it POST /v1/threads/{id}/revert, /unrevert
Providers and models GET /v1/providers
Stop the server POST /v1/shutdown

When a request fails

A request the server refuses gets an HTTP status and a JSON body that says why:

{
  "code": "not_found",
  "message": "not found: permission xyz",
  "retryable": false
}

Branch on code, show message to people, and retry only when retryable is true.

code Status Means
invalid_request 400 A value is wrong, such as an unknown agent
permission_denied, permission_rejected 403 A rule or a person refused it
review_blocked 403 Auto's reviewer refused it
not_found 404 No such chat, turn or request
conflict 409 Something changed first; read it again and retry
tool_error 422 A tool or setting refused the change
provider_error 502 The model provider failed; the message says how

Less common: cancelled (409, the work was stopped first), provider_blocked and provider_incomplete (502, the provider refused the request or stopped before it finished) and storage_error (500).

A request the server cannot read gets a plain-text message instead of that body: 400 for a body that is not valid JSON, 422 for a missing field or a field of the wrong type, and 415 without Content-Type: application/json. Without the password or token the server needs, every API request gets 401 and the text Authentication required.

The full reference

The API also covers workflows, commands, terminals, worktrees, MCP servers, plugins, skills and more. A running server describes every route it serves, with request and response types, at /openapi.json, which needs no password:

curl -s "$HAARNESS/openapi.json" | jq '.paths | keys | length'

Load that address into any OpenAPI viewer or client generator. In the Haarness repository, sdks/OPERATIONS.md maps every endpoint to its SDK method.

Next