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

# TypeScript and Python SDKs

The SDKs wrap the [server API](https://haarness.ai/docs/server/api.md) in typed clients. Use them to run turns from your
own tools, tests or services.

| | TypeScript | Python |
| --- | --- | --- |
| Package | `@haarness/sdk` | `haarness-sdk` |
| Import | `@haarness/sdk` | `haarness_sdk` |
| Needs | Node.js 22+ | Python 3.10+ |
| Style | Promises, async iterators | Sync `Haarness` and async `AsyncHaarness` |

## Install

The SDKs are not on npm or PyPI yet. Install them from a checkout of the Haarness repository:

```sh
cd haarness/sdks/typescript && npm ci && npm run build
cd ~/code/my-app && npm install /path/to/haarness/sdks/typescript
```

```sh
python -m pip install /path/to/haarness/sdks/python
```

## Ask one question

Start a server in your project with `haarness serve`, then:

```ts
import { Haarness } from "@haarness/sdk";

const client = new Haarness({ baseUrl: "http://127.0.0.1:4096" });
try {
  const thread = await client.createThread({ title: "Quick question" });
  const result = await client.chat(thread.id, "What does this project do?");
  console.log(result.turn.status);
  console.log(result.text);
} finally {
  client.close();
}
```

```python
from haarness_sdk import Haarness

with Haarness("http://127.0.0.1:4096") as client:
    thread = client.create_thread({"title": "Quick question"})
    result = client.chat(thread["id"], "What does this project do?")
    print(result["turn"]["status"])
    print(result["text"])
```

You see `completed` and the answer. `chat()` starts a turn, waits up to five minutes for it to
finish, and returns that turn's `text`, its `items` and the `turn` itself.

If the turn is still going after five minutes, `chat()` raises `ApiError` with status `409` and
the turn keeps running. For longer work, start the turn and follow the event stream, as in the next
example.

Use the server's root URL, not `/v1`. For a password-protected server, pass `password` (and
`username` if it is not `haarness`). A bearer token is read from `HAARNESS_SERVER_TOKEN`, or pass
`token`.

## Stream the answer and handle approvals

`chat()` waits silently, and a turn that needs your approval waits with it. This complete example
prints the answer as it streams and asks you in the terminal when the agent wants permission.

```ts
import { randomUUID } from "node:crypto";
import { createInterface } from "node:readline/promises";
import { Haarness } from "@haarness/sdk";

const prompt = process.argv.slice(2).join(" ") || "Summarise README.md";
const client = new Haarness({ baseUrl: "http://127.0.0.1:4096" });
const terminal = createInterface({
  input: process.stdin,
  output: process.stdout,
});

try {
  const thread = await client.createThread({ title: "SDK example" });
  const { eventCursor } = await client.info();
  const turn = await client.startTurn(thread.id, {
    requestId: randomUUID(),
    text: prompt,
  });

  const answered = new Set<string>();
  for await (const event of client.events({ after: eventCursor })) {
    if (!("eventId" in event) || event.rootThreadId !== thread.id) continue;
    const payload = event.payload as Record<string, any>;
    if (event.type === "turn.text_delta" && event.turnId === turn.id) {
      process.stdout.write(payload.delta);
    } else if (event.type === "permission.requested") {
      if (answered.has(payload.permissionId)) continue;
      answered.add(payload.permissionId);
      const answer = await terminal.question(
        `\nAllow ${payload.operation}? [y/N] `,
      );
      await client.replyPermission(payload.permissionId, {
        decision: answer.trim() === "y" ? "once" : "reject",
      });
    } else if (event.type === "question.requested") {
      const choices = payload.choices?.length
        ? ` (${payload.choices.join(" / ")})`
        : "";
      const answer = await terminal.question(`\n${payload.prompt}${choices} `);
      await client.replyQuestion(payload.questionId, { answer });
    } else if (event.type === "turn.finished" && payload.id === turn.id) {
      console.log(`\n[${payload.status}]`);
      break;
    }
  }
} finally {
  terminal.close();
  client.close();
}
```

```python
import sys
import uuid
from contextlib import closing

from haarness_sdk import Haarness

prompt = " ".join(sys.argv[1:]) or "Summarise README.md"

with Haarness("http://127.0.0.1:4096") as client:
    thread = client.create_thread({"title": "SDK example"})
    cursor = client.info()["eventCursor"]
    turn = client.start_turn(
        thread["id"], {"requestId": str(uuid.uuid4()), "text": prompt}
    )

    answered = set()
    with closing(client.events(after=cursor)) as events:
        for event in events:
            if event.get("rootThreadId") != thread["id"]:
                continue
            kind, payload = event["type"], event["payload"]
            if kind == "turn.text_delta" and event["turnId"] == turn["id"]:
                print(payload["delta"], end="", flush=True)
            elif kind == "permission.requested":
                if payload["permissionId"] in answered:
                    continue
                answered.add(payload["permissionId"])
                answer = input(f"\nAllow {payload['operation']}? [y/N] ")
                decision = "once" if answer.strip() == "y" else "reject"
                client.reply_permission(
                    payload["permissionId"], {"decision": decision}
                )
            elif kind == "question.requested":
                choices = " / ".join(payload.get("choices") or [])
                hint = f"({choices}) " if choices else ""
                answer = input(f"\n{payload['prompt']} {hint}")
                client.reply_question(payload["questionId"], {"answer": answer})
            elif kind == "turn.finished" and payload["id"] == turn["id"]:
                print(f"\n[{payload['status']}]")
                break
```

Save either as a file and run it with a prompt, for example
`npx tsx ask.ts "List the TODO comments"` or `python ask.py "List the TODO comments"`.

How it works:

- `info()` returns the current event position. Streaming from there shows only new events.
- `startTurn` returns at once. Each turn needs a new `requestId`.
- The stream carries every chat on the server, so the loop keeps only this one, subagents
  included (`rootThreadId`).
- A request can be announced again, after a reconnect for example, so the loop answers each
  `permissionId` once.
- The agent may also ask you something (`question.requested`), with or without choices to pick
  from. The turn waits for your answer.
- Closing the stream or the client never stops the turn. Use `interruptTurn(turn.id)`
  (`interrupt_turn` in Python) to stop it.

## Choose how much a chat asks

New chats start in **Auto**: a reviewer model on the server approves routine requests once, blocks
risky ones with a reason the agent reads, and leaves the rest to you. Only those reach your loop.
Switch a chat to **Review** to be asked about everything, or to **Yolo** to approve everything
without asking (deny rules still hold):

```ts
await client.setApprovalMode(thread.id, { mode: "review" });
console.log((await client.getApprovalMode(thread.id)).mode); // "review"
```

```python
client.set_approval_mode(thread["id"], {"mode": "review"})
print(client.get_approval_mode(thread["id"])["mode"])  # review
```

The mode belongs to the chat, so the terminal UI and Desktop see the change too.
`(await client.info()).defaultApprovalMode` tells you which mode new chats on this server start in.
See [Review, Auto or Yolo](https://haarness.ai/docs/security/permissions.md#review-auto-or-yolo-how-much-does-a-chat-ask)
for what each one allows.

The SDKs never approve anything on their own: a request nobody answers keeps its turn waiting. For
a script that runs with nobody watching:

- Send the turn with `tools: { question: false }` so the agent does not stop to ask you something.
- Answer a request you do not want with `{ decision: "refuse", feedback: "…" }`: that request alone
  is refused, and the agent reads it as Haarness's refusal rather than yours.

## Async Python

```python
import asyncio
from haarness_sdk import AsyncHaarness

async def main():
    async with AsyncHaarness("http://127.0.0.1:4096") as client:
        thread = await client.create_thread({"title": "Async"})
        result = await client.chat(thread["id"], "What does this project do?")
        print(result["text"])

asyncio.run(main())
```

Iterate events with `async for`, and close an early-exited stream with `contextlib.aclosing`.
Cancelling the task cancels the request in flight. `AsyncLocalServer` is the async form of the
`LocalServer` below.

## Start a private server from a script

`LocalServer` starts a server for a folder, gives you a client, and stops the server when you are
done. It uses a random port and token, so nothing else can connect.

```ts
import { LocalServer } from "@haarness/sdk";

const server = await LocalServer.start({ workspace: "/path/to/project" });
try {
  const thread = await server.client.createThread({});
  console.log((await server.client.chat(thread.id, "Hello")).text);
} finally {
  await server.close();
}
```

```python
from haarness_sdk import LocalServer

with LocalServer.start("/path/to/project") as server:
    thread = server.client.create_thread({})
    print(server.client.chat(thread["id"], "Hello")["text"])
```

If the folder's server is already running, for example one the terminal UI started, it is never
replaced: your private server serves it at its own address, and closing yours leaves that server
and its work running. Pass `executable` if `haarness` is not on your `PATH`, and
`disableProjectConfig: true` (`disable_project_config=True` in Python) for a repository you do not
trust, so its settings, hooks and MCP servers are not loaded.

If the server cannot start, the error says why in Haarness's own words, for example
`Haarness failed to start: …` followed by the reason it printed.

## Do anything else the server can

Everything the terminal UI and Desktop do goes through the same API, and each operation has a
method named after it: `createThread` in TypeScript is `create_thread` in Python. Fields keep the
server's JSON names, such as `requestId` and `eventCursor`, in both languages, and arguments go in
the order path, body, query. Some you may want next:

- `readFile` / `writeFile` (`read_file` / `write_file`) for the project's files. A write carries
  the `revision` you read, or `NEW_FILE_REVISION` for a new file, so it fails with `409` instead of
  overwriting someone's edit.
- `attachments()` turns local images and PDFs into a turn's attachments.
- `btwStream` (`btw_stream`) asks a side question without stopping the turn.
- `interruptTurn`, `listThreads`, `listItems` and the chat's `approval-mode` you saw above.

`sdks/OPERATIONS.md` in the repository lists every method with its endpoint.

## When a call takes long

Calls give up after 30 seconds unless you say otherwise. Give the whole client longer, or one call:

```ts
const client = new Haarness({
  baseUrl: "http://127.0.0.1:4096",
  timeoutMs: 60_000,
});
const info = await client.info({ timeoutMs: 5_000 });
```

```python
client = Haarness("http://127.0.0.1:4096", timeout=60)
info = client.info(options={"timeout": 5})
```

To cancel a call, pass `{ signal }` from an `AbortController` in TypeScript, or cancel the task in
async Python. `chat()` already waits five minutes for its turn.

Event streams never time out on their own. A connection that died without closing (a laptop that
slept, a network that changed) can look open for a long time, so tell the SDK how long silence may
last: `events({ idleTimeoutMs: 45_000, reconnect: true })` in TypeScript, or
`events(idle_timeout=45, reconnect=True)` in Python. The server sends a keepalive every 15 seconds,
so a healthy stream is never that quiet, and `reconnect` picks up again from the last event.

## When something fails

A request the server refuses raises `ApiError`, with `status`, `code`, `retryable` and `body`; the
[codes](https://haarness.ai/docs/server/api.md#when-a-request-fails) are the API's. A connection problem or a timeout
raises `TransportError`. The SDKs never retry on their own, and a turn is safe to send again with
the same `requestId`: the server starts it only once.

```ts
import { randomUUID } from "node:crypto";
import { ApiError, TransportError } from "@haarness/sdk";

const turn = { requestId: randomUUID(), text: "Run the tests" };
try {
  await client.startTurn(thread.id, turn);
} catch (error) {
  if (error instanceof TransportError) await client.startTurn(thread.id, turn);
  else if (error instanceof ApiError)
    console.error(error.status, error.code, error.message);
  else throw error;
}
```

```python
import uuid
from haarness_sdk import ApiError, TransportError

turn = {"requestId": str(uuid.uuid4()), "text": "Run the tests"}
try:
    client.start_turn(thread["id"], turn)
except TransportError:
    client.start_turn(thread["id"], turn)
except ApiError as error:
    print(error.status, error.code, error)
```

## Related

- [Your first API call](https://haarness.ai/docs/server/api.md) — the same flow with `curl`
- [Connect safely](https://haarness.ai/docs/server/authentication.md)
- [Automating Haarness Cloud](https://haarness.ai/docs/cloud/cli-and-sdk.md) — the same SDKs against a Cloud workspace
