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

Гайд для разработчиков клиента: как ходить в наш API из своего приложения (web, mobile, backend). Серверная часть, деплой и полный список ручек — в 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)

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

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. Проверка подключения

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

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

{"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.

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

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

Формат: блоки, разделённые пустой строкой; внутри строки 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"} единственный признак успешного конца

Правила:

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

{
  "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, один файл за запрос. Ответ зависит от типа:

// картинки (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)

// 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);
  }
}

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

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

Python 3.11+ (httpx)

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

# 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)))
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. Квота в интерфейсе

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