Подключение приложения к 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. Что нужно понимать до интеграции
- Один аккаунт на всех. Сервер ходит в claude.ai под одной сессией владельца.
У upstream нет понятия «ваш пользователь»: все чаты, файлы и квота общие.
Разделение пользователей — целиком на вашей стороне (например, один пользовательский
диалог = одна
conversation, uuid хранится у вас). - Токен = полный доступ к аккаунту и к квоте. Для публичного приложения токен
должен жить только на вашем backend, который и ходит в API. Если клиент всё же
обращается напрямую из браузера — минимум ограничьте
CORS_ORIGINв.env. - Один ответ на чат одновременно. Второй
POST …/messagesв тот же uuid встаёт в очередь на сервере и начнётся после первого. Разные чаты обрабатываются параллельно. - Поток не возобновляется. Если соединение оборвалось посреди ответа — генерация
на сервере останавливается. Не переотправляйте вслепую: сначала
GET …/messages, посмотрите, что уже записано, и только потом решайте, слать ли снова (повтор создаст новый ход). - Сессия владельца живёт ~30 дней. Ошибки
AUTH_EXPIREDиCF_CHALLENGEозначают, что нужен владелец (обновить cookie черезPUT /admin/session). Ретраи бессмысленны — показывайте «сервис временно недоступен» и алертите. - 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 отвечает:
- при ошибке до старта генерации — обычный 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. Параметры отправки
{
"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
- polyfill
ReadableStream), иначе ответ придёт целиком в конце.
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. Квота в интерфейсе
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-запросах; отправка сообщения повторяется после проверки истории.