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:40961. 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 .statusYou 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 .statusPOST /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
- TypeScript and Python SDKs — the same flow, typed
- Connect safely