# Подключение приложения к claudetoapi

Гайд для разработчиков клиента: как ходить в наш API из своего приложения
(web, mobile, backend). Серверная часть, деплой и полный список ручек — в
[README.md](../README.md). Всё ниже проверено вживую 2026-10-10
(`npm run smoke` проходит целиком).

## TL;DR

| Что | Значение |
| --- | --- |
| Base URL (из интернета) | `https://macbook-pro-dmitrij.taild47d20.ts.net` |
| Base URL (LAN) | `http://192.168.1.2:9922` |
| Base URL (на самом Mac) | `http://localhost:9922` |
| Авторизация | `Authorization: Bearer <API_TOKEN>` на каждый запрос, кроме `/healthz` и `/docs` |
| Документация онлайн | `<BASE>/docs` (этот гайд), `<BASE>/docs/readme` (серверный reference), сырые `.md` — `/docs/INTEGRATION.md`, `/docs/README.md` |
| Откуда токен | `API_TOKEN` в `.env` на Mac. Никогда не коммитить и не вшивать в клиентский бандл |
| Формат | JSON туда/обратно; ответ на отправку сообщения — SSE (`text/event-stream`) |

Минимальный цикл: создать чат → отправить сообщение и читать поток → при
необходимости перечитать историю.

```bash
BASE=https://macbook-pro-dmitrij.taild47d20.ts.net
TOKEN=...            # API_TOKEN из .env
H=(-H "authorization: Bearer $TOKEN" -H "content-type: application/json")

CONV=$(curl -s "${H[@]}" -X POST "$BASE/v1/conversations" -d '{"name":"demo"}' | jq -r .uuid)
curl -sN "${H[@]}" -X POST "$BASE/v1/conversations/$CONV/messages" \
     -d '{"text":"Привет! Одним предложением: что ты умеешь?","thinking_mode":"off"}'
curl -s "${H[@]}" "$BASE/v1/conversations/$CONV/messages" | jq '.chat_messages[-1].text'
curl -s "${H[@]}" -X DELETE "$BASE/v1/conversations/$CONV"
```

## 1. Что нужно понимать до интеграции

1. **Один аккаунт на всех.** Сервер ходит в claude.ai под одной сессией владельца.
   У upstream нет понятия «ваш пользователь»: все чаты, файлы и квота общие.
   Разделение пользователей — целиком на вашей стороне (например, один пользовательский
   диалог = одна `conversation`, uuid хранится у вас).
2. **Токен = полный доступ к аккаунту и к квоте.** Для публичного приложения токен
   должен жить только на вашем backend, который и ходит в API. Если клиент всё же
   обращается напрямую из браузера — минимум ограничьте `CORS_ORIGIN` в `.env`.
3. **Один ответ на чат одновременно.** Второй `POST …/messages` в тот же uuid встаёт
   в очередь на сервере и начнётся после первого. Разные чаты обрабатываются параллельно.
4. **Поток не возобновляется.** Если соединение оборвалось посреди ответа — генерация
   на сервере останавливается. Не переотправляйте вслепую: сначала `GET …/messages`,
   посмотрите, что уже записано, и только потом решайте, слать ли снова (повтор создаст
   новый ход).
5. **Сессия владельца живёт ~30 дней.** Ошибки `AUTH_EXPIRED` и `CF_CHALLENGE` означают,
   что нужен владелец (обновить cookie через `PUT /admin/session`). Ретраи бессмысленны —
   показывайте «сервис временно недоступен» и алертите.
6. **Upstream недокументирован.** Формы объектов (`conversation`, `chat_messages[]`) —
   это сырые ответы claude.ai и могут меняться. Читайте только нужные поля и не падайте
   на неизвестных.

## 2. Проверка подключения

```bash
curl "$BASE/healthz"                                    # {"ok":true} — без токена
curl -H "authorization: Bearer $TOKEN" "$BASE/admin/status"
```

Ожидаемый ответ `admin/status`:

```json
{"configured":true,"org_uuid":"…","session_updated_at":"…","device_uuid":"…",
 "upstream_ok":true,"client_build":{"sha":"…","version":"1.0.0","build":"…"}}
```

Если `upstream_ok:false` или `401` на правильном токене — проблема на стороне сервера,
а не в вашем коде. Остальное: см. [README → Troubleshooting](../README.md#troubleshooting).

## 3. Чтение SSE-потока

`POST /v1/conversations/{uuid}/messages` отвечает:

- при ошибке до старта генерации — обычный JSON (`{"error":{…}}`) с кодом HTTP ≠ 200;
  **сначала проверяйте статус**, и только для 200 разбирайте тело как SSE;
- при успехе — `200`, `content-type: text/event-stream`, chunked, байт-в-байт поток
  claude.ai.

Формат: блоки, разделённые пустой строкой; внутри строки `event: <имя>` и
`data: <JSON>`. У `data:` бывают хвостовые пробелы — `JSON.parse` их переживает. Полей
`id:`/`retry:` нет, механизм `Last-Event-ID` не поддерживается.

| event | Что внутри `data` | Что делать |
| --- | --- | --- |
| `ping` | `{"type":"ping"}` | игнорировать (keep-alive, приходят до первого токена) |
| `message_start` | `message.uuid` — uuid ответа ассистента, `message.parent_uuid` — uuid вашего сообщения, `message.model` | запомнить uuid'ы, если нужны ветвления |
| `content_block_start` | `index`, `content_block.type`: `text` \| `thinking` \| `tool_use` \| `tool_result` | завести блок `index → type` |
| `content_block_delta` | `index`, `delta.type`: `text_delta` (`delta.text`), `thinking_summary_delta` (`delta.summary.summary`), `input_json_delta` (`delta.partial_json`), `tool_use_block_update_delta` | дописывать в блок по `index` |
| `content_block_stop` | `index` | закрыть блок |
| `message_delta` | `delta.stop_reason`: `end_turn` \| `user_canceled` \| `max_tokens` \| … | причина завершения |
| `message_limit` | `windows["5h"].utilization` (доля 0..1), `windows["7d"]`, `resets_at` (unix) | обновить индикатор квоты |
| `error` | объект ошибки upstream | завершить ход как ошибочный |
| `message_stop` | `{"type":"message_stop"}` | **единственный признак успешного конца** |

Правила:

- Определяйте тип блока по `content_block.type`, а не по `index`: блок `thinking` может
  идти первым даже при `thinking_mode:"off"`, а может отсутствовать.
- Размышления приходят только как короткие сводки (`thinking_summary_delta`), сырого
  текста мыслей в потоке нет. В UI это «Claude думает: …», не часть ответа.
- Ход завершён успешно **только** если пришёл `message_stop`. Если тело кончилось
  раньше — считайте поток оборванным (сервер мог дописать строку
  `{"error":{"code":"STREAM_BROKEN",…}}` без префикса `data:`), и идите за историей.
- Остановить генерацию: либо разорвать соединение (сервер прекратит ход), либо
  `POST /v1/conversations/{uuid}/stop` — тогда поток штатно завершится с
  `stop_reason:"user_canceled"` и `message_stop`, а частичный ответ останется в истории.
- Если между вами и сервером есть свой прокси/CDN — отключите буферизацию ответов
  (`proxy_buffering off` в nginx) и поставьте большой read timeout: ход может идти минуты.

## 4. Параметры отправки

```jsonc
{
  "text": "…",                      // обязательно text ИЛИ prompt
  "model": "claude-opus-5-5",       // по умолчанию — модель аккаунта (см. ниже)
  "effort": "medium",               // low | medium | high | xhigh | max
  "thinking_mode": "auto",          // extended | standard | auto | off
  "parent_message_uuid": "…",       // ветвление: uuid сообщения-родителя
  "files": ["<file_uuid>"],         // картинки/PDF из POST /v1/files (kind:"uploaded")
  "attachments": [{                 // текстовые документы из POST /v1/files (kind:"converted")
    "file_name": "note.txt", "file_type": "txt", "file_size": 38,
    "extracted_content": "…"
  }],
  "temperature": 0.7, "top_p": 0.9, "stop_sequences": ["END"], "locale": "ru-RU"
}
```

Какую модель подставлять: `GET /v1/models` → `selector_config[].id == "chat"` → список
`models[].id` в порядке, как в UI; `selector_state[].id == "chat"` содержит текущий
дефолт аккаунта (`model`) и дефолтный `effort`/`mode` по моделям. На 2026-10-10
дефолт — `claude-opus-5-5`; доступны `claude-fable-5-1`, `claude-sonnet-5-5`,
`claude-haiku-5-5`, `claude-opus-5`, `claude-sonnet-5` и старые. Поле `model` при
отправке можно просто не передавать.

## 5. Файлы

Загрузка: `POST /v1/files`, multipart, поле `file`, ≤ 30 MB, один файл за запрос.
Ответ зависит от типа:

```jsonc
// картинки (image/*) и PDF → kind:"uploaded" → в send передаём files:[file_uuid]
{"kind":"uploaded","data":{"file_uuid":"…","file_kind":"image","size_bytes":75,
  "preview_url":"/api/{org}/files/{uuid}/preview","thumbnail_url":"…"}}

// всё остальное (txt, md, docx, csv, …) → kind:"converted" → data целиком в attachments[]
{"kind":"converted","data":{"file_name":"note.txt","file_size":37,"file_type":"txt",
  "extracted_content":"…"}}
```

Проверено: модель видит содержимое PDF/картинки через `files[]` и текст через
`attachments[]` в одном и том же ходе.

Скачивание: `GET /v1/files/{file_uuid}/contents` (оригинальные байты),
`/preview` и `/thumbnail` (webp, для картинок и PDF), `/document_pdf` (только для PDF).
Ответ — бинарный, `content-type` берите из заголовка. Неподходящий вариант даёт 404.
Файлы, которые сделал сам Claude (код-интерпретатор), лежат в
`GET /v1/conversations/{uuid}/artifacts` и скачиваются через `…/artifacts/file?path=…`.

## 6. Ошибки

Единый конверт: `{"error":{"code":"…","status":<http>,"upstream":{…},"retry-after":"…"}}`.

| code | HTTP | Что делать клиенту |
| --- | --- | --- |
| `VALIDATION` | 400 | баг в вашем запросе; `error.issues` объясняет что именно |
| `UNAUTHORIZED` | 401 | неверный/отсутствующий Bearer — проверить конфиг |
| `NOT_FOUND` | 404 | чат/сообщение/файл удалён или uuid чужой — создать заново |
| `RATE_LIMITED` | 429 | подождать `retry-after` секунд (или `anthropic-ratelimit-unified-reset`), повторить |
| `AUTH_EXPIRED`, `CF_CHALLENGE` | 403/4xx | **не ретраить**, показать «сервис недоступен», алерт владельцу |
| `UPSTREAM_ERROR` | как у upstream | 5xx — повторить с backoff; 4xx — смотреть `upstream.error.message` |
| `STREAM_BROKEN` | тело после 200 | поток оборвался; взять историю, решить про повтор |
| `INTERNAL` | 500 | повторить один раз, потом алерт |

Политика повторов, которой достаточно: до 3 попыток с экспоненциальной задержкой для
429/5xx/сетевых ошибок на **idempotent** запросах (GET, PUT, DELETE). `POST …/messages`
повторять только после проверки истории.

## 7. Готовые клиенты

### TypeScript (браузер, Node 18+, React Native с потоковым fetch)

```ts
// claudetoapi.ts — dependency-free client
export interface SseEvent<T = any> { event: string; data: T }

export class ApiError extends Error {
  constructor(public status: number, public code: string, public detail?: unknown) {
    super(`${code} (HTTP ${status})`);
  }
}

export interface SendBody {
  text?: string; prompt?: string; model?: string;
  effort?: "low" | "medium" | "high" | "xhigh" | "max";
  thinking_mode?: "extended" | "standard" | "auto" | "off";
  parent_message_uuid?: string; files?: string[];
  attachments?: { file_name: string; file_type: string; file_size?: number; extracted_content: string }[];
}

export class ClaudeToApi {
  constructor(private readonly base: string, private readonly token: string) {}

  private headers(json = false): Record<string, string> {
    return { authorization: `Bearer ${this.token}`, ...(json ? { "content-type": "application/json" } : {}) };
  }

  private async request<T>(method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
    const isForm = typeof FormData !== "undefined" && body instanceof FormData;
    const res = await fetch(this.base + path, {
      method, signal,
      headers: this.headers(body !== undefined && !isForm),
      body: body === undefined ? undefined : isForm ? (body as FormData) : JSON.stringify(body),
    });
    const text = await res.text();
    const parsed = text ? JSON.parse(text) : undefined;
    if (!res.ok) throw new ApiError(res.status, parsed?.error?.code ?? `HTTP_${res.status}`, parsed?.error);
    return parsed as T;
  }

  createConversation(opts: { name?: string; project_uuid?: string; model?: string } = {}) {
    return this.request<{ uuid: string }>("POST", "/v1/conversations", opts);
  }
  deleteConversation(uuid: string) { return this.request("DELETE", `/v1/conversations/${uuid}`); }
  history(uuid: string) { return this.request<{ chat_messages: any[] }>("GET", `/v1/conversations/${uuid}/messages`); }
  stop(uuid: string) { return this.request("POST", `/v1/conversations/${uuid}/stop`); }
  usage() { return this.request<any>("GET", "/v1/usage"); }

  async uploadFile(file: Blob, filename: string) {
    const fd = new FormData();
    fd.append("file", file, filename);
    return this.request<{ kind: "uploaded" | "converted"; data: any }>("POST", "/v1/files", fd);
  }

  /** Raw SSE events of one turn. Throws ApiError if rejected before streaming or if the stream breaks. */
  async *send(uuid: string, body: SendBody, signal?: AbortSignal): AsyncGenerator<SseEvent> {
    const res = await fetch(`${this.base}/v1/conversations/${uuid}/messages`, {
      method: "POST", headers: this.headers(true), body: JSON.stringify(body), signal,
    });
    if (!res.ok || !res.body) {
      const err = await res.json().catch(() => undefined);
      throw new ApiError(res.status, err?.error?.code ?? `HTTP_${res.status}`, err?.error);
    }
    let finished = false;
    for await (const ev of parseSse(res.body)) {
      if (ev.event === "error") throw new ApiError(502, "STREAM_ERROR", ev.data);
      yield ev;
      if (ev.event === "message_stop") finished = true;
    }
    if (!finished) throw new ApiError(502, "STREAM_BROKEN", "stream ended before message_stop");
  }

  /** Convenience: one turn → assistant text, with optional live chunks. */
  async ask(uuid: string, body: SendBody, onText?: (chunk: string) => void, signal?: AbortSignal) {
    let text = "", messageUuid = "", thinking = "";
    for await (const { event, data } of this.send(uuid, body, signal)) {
      if (event === "message_start") messageUuid = data.message.uuid;
      if (event !== "content_block_delta") continue;
      if (data.delta.type === "text_delta") { text += data.delta.text; onText?.(data.delta.text); }
      else if (data.delta.type === "thinking_summary_delta") thinking = data.delta.summary?.summary ?? thinking;
    }
    return { text, messageUuid, thinking };
  }
}

/** Chunk-boundary-safe text/event-stream parser. */
export async function* parseSse(stream: ReadableStream<Uint8Array>): AsyncGenerator<SseEvent> {
  const reader = stream.getReader();
  const decoder = new TextDecoder();
  let buf = "";
  try {
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      buf += decoder.decode(value, { stream: true });
      let sep: number;
      while ((sep = buf.indexOf("\n\n")) !== -1) {
        const block = buf.slice(0, sep).replace(/\r/g, "");
        buf = buf.slice(sep + 2);
        let event = "message";
        const data: string[] = [];
        for (const line of block.split("\n")) {
          if (line.startsWith("event:")) event = line.slice(6).trim();
          else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
        }
        if (data.length) yield { event, data: JSON.parse(data.join("\n")) };
      }
    }
  } finally {
    reader.cancel().catch(() => undefined);
  }
}
```

Использование:

```ts
const api = new ClaudeToApi(process.env.CLAUDE_API_BASE!, process.env.CLAUDE_API_TOKEN!);
const { uuid } = await api.createConversation({ name: "support #42" });

const controller = new AbortController();           // controller.abort() = стоп генерации
const { text } = await api.ask(uuid, { text: "Привет", thinking_mode: "auto" },
  (chunk) => process.stdout.write(chunk), controller.signal);

const png = await api.uploadFile(new Blob([bytes], { type: "image/png" }), "screen.png");
await api.ask(uuid, { text: "Что на скриншоте?", files: [png.data.file_uuid] });
```

В браузере `fetch` + `ReadableStream` работают из коробки; `EventSource` не подходит
(он умеет только GET). В React Native нужен потоковый fetch (Expo ≥ 52 / `react-native-fetch-api`
+ polyfill `ReadableStream`), иначе ответ придёт целиком в конце.

### Python 3.11+ (httpx)

`requests` не умеет честный стриминг с таймаутами на чтение — берите `httpx`.

```python
# claudetoapi.py — minimal client
import json, os
from dataclasses import dataclass
from typing import Any, Callable, Iterator
import httpx


class ApiError(Exception):
    def __init__(self, status: int, code: str, detail: Any = None):
        super().__init__(f"{code} (HTTP {status})")
        self.status, self.code, self.detail = status, code, detail


@dataclass
class SseEvent:
    event: str
    data: Any


class ClaudeToApi:
    def __init__(self, base: str, token: str, read_timeout: float = 600):
        self._c = httpx.Client(
            base_url=base,
            headers={"authorization": f"Bearer {token}"},
            timeout=httpx.Timeout(read_timeout, connect=10),
        )

    def _req(self, method: str, path: str, **kw) -> Any:
        r = self._c.request(method, path, **kw)
        body = r.json() if r.content else None
        if r.is_error:
            err = body.get("error", {}) if isinstance(body, dict) else {}
            raise ApiError(r.status_code, err.get("code", f"HTTP_{r.status_code}"), err)
        return body

    def create_conversation(self, **opts) -> dict:
        return self._req("POST", "/v1/conversations", json=opts)

    def delete_conversation(self, uuid: str) -> None:
        self._req("DELETE", f"/v1/conversations/{uuid}")

    def history(self, uuid: str) -> list[dict]:
        return self._req("GET", f"/v1/conversations/{uuid}/messages")["chat_messages"]

    def stop(self, uuid: str) -> dict:
        return self._req("POST", f"/v1/conversations/{uuid}/stop")

    def usage(self) -> dict:
        return self._req("GET", "/v1/usage")

    def upload(self, path: str, mime: str | None = None) -> dict:
        with open(path, "rb") as f:
            return self._req("POST", "/v1/files", files={"file": (os.path.basename(path), f, mime)})

    def send(self, uuid: str, **body) -> Iterator[SseEvent]:
        """Raw SSE events of one turn; raises ApiError on rejection or a broken stream."""
        with self._c.stream("POST", f"/v1/conversations/{uuid}/messages", json=body) as r:
            if r.is_error:
                r.read()
                err = r.json().get("error", {})
                raise ApiError(r.status_code, err.get("code", f"HTTP_{r.status_code}"), err)
            finished = False
            for ev in _parse_sse(r.iter_bytes()):
                if ev.event == "error":
                    raise ApiError(502, "STREAM_ERROR", ev.data)
                yield ev
                if ev.event == "message_stop":
                    finished = True
            if not finished:
                raise ApiError(502, "STREAM_BROKEN", "stream ended before message_stop")

    def ask(self, uuid: str, text: str, on_text: Callable[[str], None] | None = None, **opts) -> str:
        parts: list[str] = []
        for ev in self.send(uuid, text=text, **opts):
            if ev.event == "content_block_delta" and ev.data["delta"]["type"] == "text_delta":
                parts.append(ev.data["delta"]["text"])
                if on_text:
                    on_text(ev.data["delta"]["text"])
        return "".join(parts)


def _parse_sse(chunks: Iterator[bytes]) -> Iterator[SseEvent]:
    buf = b""
    for chunk in chunks:
        buf += chunk
        while (sep := buf.find(b"\n\n")) != -1:
            block, buf = buf[:sep].replace(b"\r", b""), buf[sep + 2:]
            event, data = "message", []
            for line in block.split(b"\n"):
                if line.startswith(b"event:"):
                    event = line[6:].strip().decode()
                elif line.startswith(b"data:"):
                    data.append(line[5:].lstrip())
            if data:
                yield SseEvent(event, json.loads(b"\n".join(data)))
```

```python
api = ClaudeToApi(os.environ["CLAUDE_API_BASE"], os.environ["CLAUDE_API_TOKEN"])
conv = api.create_conversation(name="bot session")["uuid"]
answer = api.ask(conv, "Привет", on_text=lambda s: print(s, end="", flush=True), thinking_mode="off")
doc = api.upload("report.txt")                       # kind == "converted"
api.ask(conv, "Кратко перескажи файл", attachments=[doc["data"]])
```

Для asyncio замените `httpx.Client` на `httpx.AsyncClient`, `stream` → `async with`,
`iter_bytes` → `aiter_bytes`; парсер не меняется.

## 8. Квота в интерфейсе

- `GET /v1/usage` → `five_hour.utilization` и `seven_day.utilization` в **процентах**
  (0–100) плюс `resets_at` (ISO). Подходит для индикатора в настройках/шапке.
- `message_limit` в каждом ходе → `windows["5h"].utilization` в **долях** (0–1) и
  `resets_at` в unix-секундах. Подходит, чтобы обновлять индикатор без лишних запросов.
- Когда окно исчерпано, отправка вернёт `429 RATE_LIMITED` с `retry-after`.

## 9. Чек-лист перед выкаткой

- [ ] Токен хранится только на backend / в секретах, не в клиентском коде.
- [ ] `CORS_ORIGIN` в `.env` сервера сужен до реальных origin'ов UI (сейчас `*`).
- [ ] Клиент проверяет HTTP-статус до разбора SSE и считает ход успешным только по `message_stop`.
- [ ] Есть обработка `AUTH_EXPIRED` / `CF_CHALLENGE` с алертом владельцу (cookie обновляется руками).
- [ ] Мониторинг `GET /healthz` (без токена) раз в минуту; после перезагрузки Mac
      Funnel включается `tailscale funnel --bg 9922`.
- [ ] Для 24/7 на Mac установлен launchd-сервис (`bash scripts/install-service.sh`);
      `tsx watch` из терминала после краша не перезапускается сам.
- [ ] Повторы только на idempotent-запросах; отправка сообщения повторяется после проверки истории.
