> ## Documentation index
> Fetch the complete documentation index at: https://haarness.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 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](https://haarness.ai/docs/server/run-the-server.md) 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`.

```sh
export HAARNESS=http://127.0.0.1:4096
```

## 1. Create a chat

```sh
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:

```sh
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

```sh
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

```sh
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:

```sh
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](https://haarness.ai/docs/security/permissions.md) ask
about, the turn pauses and the stream shows a `permission.requested` event. List what is waiting
and answer it:

```sh
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.

```sh
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](https://haarness.ai/docs/security/permissions.md#review-auto-or-yolo-how-much-does-a-chat-ask)
for what the reviewer sees and never approves.

## Stop a turn

```sh
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:

```json
{
  "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:

```sh
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](https://haarness.ai/docs/server/sdks.md) method.

## Next

- [TypeScript and Python SDKs](https://haarness.ai/docs/server/sdks.md) — the same flow, typed
- [Connect safely](https://haarness.ai/docs/server/authentication.md)
