claudetoapi
Unofficial REST API in front of the claude.ai web app. It authenticates with your browser
session cookie (sessionKey) and mirrors every user-facing feature of the web client as
plain HTTP — no api.anthropic.com, no proxy chains, no third-party routers.
What it mirrors:
- Chats — list / search / create / rename / star / delete / branching (leaf switching)
- Messages — full history incl. thinking/tool blocks; send with SSE streaming
- Models & reasoning — live model catalog (
sonnet-5-5,opus-5-5,fable-5-1, …), effort levelslow|medium|high|xhigh|max, thinking modesextended|standard|auto|off - Files — upload any document (images/PDF native, other types auto-extracted), download bytes/previews/thumbnails of everything Claude produced
- Artifacts — files generated by code execution (
/mnt/user-data/**) - Projects — instructions, description, documents, knowledge files + limits, project memory + controls, chats inside a project
- Account & usage — organizations, bootstrap profile, 5h / weekly quota windows
Responses are upstream-shaped passthrough JSON wherever possible, so clients can treat
uuid, chat_messages[], files_v2, etc. exactly like the web app does.
⚠️ This is reverse-engineered, undocumented territory: Anthropic can change paths or payload shapes at any release cycle. All handlers are thin — updates are usually one-line.
Requirements
- Node.js ≥ 22
- A logged-in claude.ai session in any browser
Quickstart
npm install
cp .env.example .env # set PORT (+ API_TOKEN when exposing)
npm run dev # tsx watch → http://localhost:9922
Give it your session once:
- Log into claude.ai in your browser.
- DevTools → Application → Cookies →
https://claude.ai→ copysessionKey(sk-ant-sid01-…). - Push it to the server:
curl -X PUT localhost:9922/admin/session \
-H "content-type: application/json" \
-d '{"sessionKey":"sk-ant-sid01-..."}'
The org UUID is resolved automatically on first use and persisted. Check health at any time:
curl localhost:9922/admin/status # {"configured":true,"upstream_ok":true,...}
Cookie updates require no restart — /admin/session swaps the session hot,
including mid-stream requests on the next turn.
Configuration
All via environment variables (.env).
| Var | Default | Purpose |
|---|---|---|
PORT |
9922 |
HTTP port |
HOST |
0.0.0.0 |
Bind address |
API_TOKEN |
(empty) | Bearer token required for every route except /healthz; empty = open |
CORS_ORIGIN |
* |
Comma-separated origins allowed cross-origin from browsers |
LOG_LEVEL |
info |
Fastify logger level |
Auth rule: if API_TOKEN is set, every request to /v1/* and /admin/* must carry
Authorization: Bearer <token>; missing/wrong token answers
401 {"error":{"code":"UNAUTHORIZED",...}}. /healthz, / and /docs* stay open so
monitors work and clients can read the docs before they have a token.
State lives in data/state.json (gitignored): sessionKey, orgUuid, stable device ids
(deviceUuid, anonymousId are generated once so upstream sees one consistent device),
updatedAt. Delete the file to start fresh.
Remote access ("not only local")
The server binds all interfaces out of the box, so within a LAN it is reachable as
http://<MacBook-ip>:9922 immediately. Anything beyond LAN needs two things:
a token and TLS. Options, cheapest first:
1. LAN only
TOKEN=$(openssl rand -hex 24); echo "API_TOKEN=$TOKEN" >> .env
# then from another machine:
curl http://192.168.1.2:9922/v1/conversations -H "Authorization: Bearer $TOKEN"
2. From anywhere: Tailscale Funnel (HTTPS on the public internet)
This Mac already runs it — the API is published worldwide at:
https://macbook-pro-dmitrij.taild47d20.ts.net
That is a real TLS cert (Let's Encrypt), valid from phones/hotels/other countries,
no port-forwarding in routers. The funnel proxies https://…/ → 127.0.0.1:9922.
Verified live through it: /healthz, guard 401/200 on token, full SSE roundtrip.
Enable / re-enable after reboot (Tailscale app must be running):
tailscale funnel --bg 9922 # publish; verify: tailscale funnel status
Clients just use the public base URL:
BASE=https://macbook-pro-dmitrij.taild47d20.ts.net
TOKEN=<API_TOKEN from .env on the Mac> # never commit it; rotate via .env + restart
curl "$BASE/v1/conversations" -H "authorization: Bearer $TOKEN"
Funnel notes:
- Funnel config does not survive Tailscale daemon restarts; re-run
tailscale funnel --bg 9922when needed (check withtailscale funnel status). - The URL is discoverable by anyone who learns it — keep
API_TOKENmandatory (API_TOKENin.env), since this endpoint spends your claude.ai quota. - Tailscale client must stay logged in; check with
tailscale status.
3. Within your own devices (private tailnet, no public exposure)
curl http://macbook-pro-dmitrij:9922/healthz # any device signed into the same tailnet
4. Reverse proxy with HTTPS (own domain)
For a branded hostname like api.example.com instead of the tailscale URL.
Caddy (auto-certificates) as an example:
# Caddyfile
api.example.com {
tls you@example.com
reverse_proxy 127.0.0.1:9922
}
nginx equivalent:
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:9922;
proxy_http_version 1.1;
proxy_set_header Host $host;
# SSE needs buffering off
proxy_buffering off;
proxy_read_timeout 600s;
}
}
Proxying through TLS also bypasses plain-HTTP restrictions and gives you rate limiting /
basic auth at the edge. Keep API_TOKEN enabled regardless — defense in depth.
Browser-based frontends hosted on another origin: set
CORS_ORIGIN=https://ui.example.com (preflight OPTIONS is answered automatically).
SSE over CORS needs no extra flags — the stream carries plain text/event-stream.
Connecting a client from anywhere — step by step
Any HTTP client (curl, Python, JS, phone app) needs exactly two things: the base URL and the bearer token.
# Python: requests works, but use httpx/sseclient-py for streaming
import requests, json
BASE = "https://macbook-pro-dmitrij.taild47d20.ts.net"
HDRS = {"authorization": "Bearer <API_TOKEN>", "content-type": "application/json"}
# 1) create a chat
conv = requests.post(f"{BASE}/v1/conversations", headers=HDRS,
json={"name": "from my laptop"}).json()
# 2) stream the answer (raw SSE lines)
with requests.post(f"{BASE}/v1/conversations/{conv['uuid']}/messages",
headers=HDRS, stream=True,
json={"text": "hi", "thinking_mode": "auto"}) as r:
for line in r.iter_lines(decode_unicode=True):
if line.startswith("data:"):
print(line[5:].strip())
// Browser / Node 18+: fetch + ReadableStream; pass ?stream parse via text/event-stream lib
const BASE = "https://macbook-pro-dmitrij.taild47d20.ts.net";
const hdrs = { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" };
const conv = await fetch(`${BASE}/v1/conversations`, {
method: "POST", headers: hdrs, body: JSON.stringify({ name: "web ui" }),
}).then(r => r.json());
const res = await fetch(`${BASE}/v1/conversations/${conv.uuid}/messages`, {
method: "POST", headers: hdrs, body: JSON.stringify({ text: "hi" }),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
process.stdout.write(dec.decode(value)); // raw SSE passthrough
}
CORS is pre-configured for browser calls from any origin (CORS_ORIGIN=* default);
restrict it in .env once your UI domain is fixed.
Exposure checklist:
-
API_TOKENset (≥ 32 random chars, e.g.openssl rand -hex 24) - Public HTTPS via Tailscale Funnel
-
CORS_ORIGINrestricted to real UI origins instead of*(do when UI lands) - Firewall/Tailscale limits which networks reach :9922 (private tailnet path)
Never expose without a token: whoever hits this API spends your claude.ai quota.
Rotate the token by editing .env and restarting the service
(launchctl kickstart -k gui/$(id -u)/com.user.claudetoapi).
24/7 service (launchd)
bash scripts/install-service.sh
Builds dist/, installs ~/Library/LaunchAgents/com.user.claudetoapi.plist, boots it
now and keeps it alive (restart on crash, start on login). Logs:
data/log/server.log. Reinstall after config changes. Remove:
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.user.claudetoapi.plist
Linux equivalent: run node dist/bin/server.js under systemd with
Restart=always; same env vars apply.
Authentication model (upstream)
Every request to claude.ai carries cookie: sessionKey=…; lastActiveOrg=<org>;
plus Chrome-shaped headers, anthropic-client-*, device-id cookies, and correct
Referer (/new for creation-scoped calls, /chat/<uuid> for conversations).
anthropic-client-sha / -version / -build are read from <html data-git-hash …> on
claude.ai/new at warmup and refreshed every 6 h, so a long-running process keeps
matching the deployed frontend (visible in GET /admin/status.client_build).
Cloudflare rejects undici's HTTP/2 fingerprint, so the internal dispatcher pins
HTTP/1.1 keep-alive — do not re-enable allowH2.
API reference
Base URL examples assume localhost:9922 and TOKEN holding the bearer value
(omit -H "Authorization…" when no token is configured).
Service
| Method | Path | Notes |
|---|---|---|
| GET | /healthz |
Liveness, always open |
| GET | /docs |
This documentation rendered as HTML, always open: /docs (integration guide), /docs/readme, raw /docs/README.md, /docs/INTEGRATION.md; / redirects here |
| GET | /admin/status |
configured, org_uuid, upstream_ok, client_build (frontend sha/build we impersonate) |
| PUT | /admin/session |
{sessionKey, orgUuid?} — hot-swap credentials |
Account
| Method | Path | Returns |
|---|---|---|
| GET | /v1/account |
/api/bootstrap merged with organizations[] |
| GET | /v1/models |
{available_models[], selector_config[], selector_state} |
| GET | /v1/usage |
{five_hour:{utilization,resets_at}, seven_day:{...}, ...} |
Model id goes verbatim into POST …/messages {model} (omit field for account default).
Conversations
GET /v1/conversations?limit=30&starred=true → {conversations[]}
POST /v1/conversations {name?, project_uuid?, model?, is_temporary?}
GET /v1/conversations/:uuid → full object + chat_messages[]
PUT /v1/conversations/:uuid {name} | {is_starred} | {settings:{...}}
DELETE /v1/conversations/:uuid
GET /v1/conversations/search?query=q&n=20&snippet=200&project_uuid=…
→ {search_id, executed_mode, data:[{conversation{uuid,name,…}, matched_snippet{text}, …}]}
POST /v1/conversations/delete_many {conversation_uuids:[..]}
PUT /v1/conversations/:uuid/leaf {current_leaf_message_uuid}
POST /v1/conversations/:uuid/title {message_content} → autotitle
POST /v1/conversations/:uuid/stop → halts running response
Branching: read a historical assistant message uuid from chat_messages[], switch
the tree with PUT …/leaf, next send continues from there (upstream answers 400
Current leaf message is not an assistant message for human uuids). parent_message_uuid
can also be given per send.
Search is the web app's conversation/search/v2 (hybrid lexical + semantic); the old
/conversation/search answers must_upgrade and is gone. n ≤ 200, snippet is the
target snippet size in characters. The former count=true flag was dropped: upstream's
count_all is no longer called by the web client and answers 429 permanently.
Sending messages (SSE)
POST /v1/conversations/:uuid/messages
Body fields (all optional except one of text/prompt):
{
"text": "…", // OR "prompt": "[Human]\n…\n\n[Assistant]\n"
"model": "claude-opus-5-5",
"effort": "high", // low|medium|high|xhigh|max
"thinking_mode": "extended", // extended|standard|auto|off ("none"→off)
"parent_message_uuid": "…",
"temperature": 0.7, // 0..1
"top_p": 0.9,
"stop_sequences": ["END"],
"locale": "en-US",
"files": ["<file_uuid>", …], // from /v1/files "uploaded" results
"attachments": [ // from /v1/files "converted" results (or inline text)
{"file_name":"note.txt","file_type":"txt","file_size":38,
"extracted_content":"…"}
]
}
Behavior:
- Response is a byte-for-byte passthrough SSE stream; ends with
message_stop. - Sends to the same conversation serialize automatically (one upstream turn at a time).
- Client disconnect kills the upstream fetch and stops generation server-side.
Deliberate stop:
POST …/stop. - Creating the conversation first (
POST /v1/conversations) keeps protocol rules (create_conversation_params, parent sentinel) handled for you.
Stream event types (claude.ai Messages format):
event: ping periodic keepalive
event: message_start contains message.uuid
event: content_block_start content_block.type: "thinking"|"text"|"tool_use"|"tool_result"
event: content_block_delta delta.type: text_delta | thinking_summary_delta | input_json_delta | tool_use_block_update_delta
event: content_block_stop
event: message_delta stop_reason + usage
event: message_limit windows.{5h,7d}.{utilization,resets_at}
event: error terminal failure
event: message_stop done
Quick consume:
curl -N -X POST localhost:9922/v1/conversations/$CONV/messages \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"text":"hi","thinking_mode":"extended","effort":"high"}' \
| while IFS= read -r line; do echo "$line"; done
Dispatch on content_block_start.content_block.type, not on index: a thinking block
may precede text even with thinking_mode:"off" (observed on file-bearing turns).
Thinking is streamed as summaries only (delta.summary.summary), never as raw thought text.
Full client-side handling: docs/INTEGRATION.md.
History
GET /v1/conversations/:uuid/messages → {chat_messages:[...]}
GET /v1/conversations/:uuid/messages/:messageUuid → single message or 404
Message objects carry sender human|assistant, content[] blocks
(text/thinking/tool_use/tool_result), files_v2 for produced attachments, and the
parent_message_uuid chain.
Files
Upload one file as multipart field file (≤ 30 MB):
POST /v1/files # anywhere
POST /v1/conversations/:uuid/files # conversation-scoped (sandbox dir)
Two distinct shapes come back, keyed by kind:
// kind:"uploaded" (image/png,jpeg,webp,gif… and application/pdf)
{"kind":"uploaded","data":{"success":true,"file_uuid":"…","file_kind":"document",
"size_bytes":545,"thumbnail_asset":{"url":"/api/{org}/files/{uuid}/thumbnail"},
"document_asset":{"url":"/api/{org}/files/{uuid}/document_pdf","page_count":1}}}
// kind:"converted" (txt, md, docx, csv, …)
{"kind":"converted","data":{"file_name":"probe.txt","file_size":38,"file_type":"txt",
"extracted_content":"The secret codeword is …"}}
Wiring: kind:"uploaded" → put data.file_uuid strings into the send body files[];
kind:"converted" → append data objects into attachments[]. Both verified live
(Claude reads PDF contents and attachment texts correctly).
Download variants (GET /v1/files/:uuid/:variant):
| Variant | Serves | Applies to |
|---|---|---|
contents |
original bytes | anything |
preview |
webp image | images |
thumbnail |
small webp | images & pdf |
document_pdf |
rendered PDF | PDF uploads |
Variants unsupported for a given file answer upstream 404 (UPSTREAM_ERROR envelope);
pick from the matching asset fields returned at upload time instead of guessing.
Claude-produced downloads (message files_v2) resolve through contents or preview the
same way, using their own file_uuids.
Artifacts (code-execution sandbox)
GET /v1/conversations/:uuid/artifacts → {files:[paths], files_metadata:[…]}
GET /v1/conversations/:uuid/artifacts/file?path=/mnt/user-data/outputs/hello.txt
Path filter must be absolute (starts with /). Binary-safe passthrough download.
Projects
GET /v1/projects → {projects[]}
POST /v1/projects {name, description?} (description optional, sent "" )
GET /v1/projects/:id
PUT /v1/projects/:id {name?|description?|prompt_template?|is_archived?}
DELETE /v1/projects/:id
GET /v1/projects/:id/docs → {docs[]}
POST /v1/projects/:id/docs {file_name,content} → custom instruction doc
GET /v1/projects/:id/docs/:docId
DELETE /v1/projects/:id/docs/:docId
GET /v1/projects/:id/files → {files[]} (knowledge base)
POST /v1/projects/:id/files multipart file (≤30MB each)
POST /v1/projects/:id/files/delete_many {file_uuids:[…]}
GET /v1/projects/:id/stats → kb size/max/threshold
GET /v1/projects/:id/memory → memory+controls+mode
PUT /v1/projects/:id/memory/controls {controls:["custom_instruction_set_by_user", …]}
GET /v1/projects/:id/conversations → chats inside the project
prompt_template = project instructions shown to Claude in every chat bound to the
project. Bind new chats via POST /v1/conversations {project_uuid} — they appear under
GET …/conversations immediately.
Errors
Uniform envelope everywhere:
{"error":{"code":"AUTH_EXPIRED","status":403,"upstream":{…}, "retry-after":"12"}}
| Code | Meaning |
|---|---|
VALIDATION |
Zod issues in request body/params (400) |
UNAUTHORIZED |
Missing/wrong Bearer token (401, only when API_TOKEN set) |
NOT_FOUND |
Unknown conversation/message/file (404) |
AUTH_EXPIRED |
Cookie expired or invalid → refresh via /admin/session (~30 day TTL) |
RATE_LIMITED |
Upstream 429; reset headers passed through verbatim (also on the stream path) |
CF_CHALLENGE |
Cloudflare interstitial hit the API plane; refresh cookies |
UPSTREAM_ERROR |
Any other non-ok upstream answer, body inside .upstream |
STREAM_BROKEN |
Mid-stream upstream failure; partial data already delivered |
INTERNAL |
Local crash equivalent |
NO_ORG |
Session valid but zero organizations resolved |
Rate-limit resets are visible in responses as retry-after,
anthropic-ratelimit-unified-reset etc., plus normalized live numbers in
message_limit SSE events and GET /v1/usage.
Smoke test
npm run smoke # or: TOKEN=… BASE_URL=http://host:port bash scripts/smoke.sh
Probes guard (401 without token), health/admin, models, usage, create→stream→history roundtrip, leaf switch, rename+star, search v2, txt conversion, project lifecycle (doc + chat), error envelopes (send to unknown chat must return 404 and leave the process alive), then deletes everything it created and prints SMOKE PASSED.
Troubleshooting
| Symptom | Fix |
|---|---|
CF_CHALLENGE HTML errors |
Cookies stale/too many parallel foreign requests — refresh session; never re-enable HTTP/2 dispatcher |
Every call AUTH_EXPIRED |
Cookie rotated out (~30 days): grab a fresh sessionKey, PUT admin/session |
404 Not found on send |
Conversation deleted upstream or leaf uuid invalid; recreate, or reset leaf |
must_upgrade in an UPSTREAM_ERROR |
Web client moved that call to a new path; find it in the SPA bundle (see below) and update src/claude/client.ts |
Upload says not_found_error |
Old proxy build — upload paths differ per kind; update & rebuild |
| Stream stops mid-way | Network drop; safe to resend — sends queue per conversation |
| EADDRINUSE on boot | Another instance holds PORT; kill it or change .env |
Development
npm run dev # tsx watch
npm run build && npm start # compiled
npx tsc --noEmit # strict typecheck gate
Structure:
bin/server.ts fastify wiring, global auth+CORS hooks, error mapper, signals
src/config.ts env + persisted state (data/state.json)
src/claude/http.ts fetch core: headers (build ids from /new), referer rules, CF detect, one network retry (never POST), error codes
src/claude/sse.ts chunk-boundary-safe SSE parser + byte reader
src/claude/client.ts every upstream method (org-prefixed `p()` / flat-file `po()`)
src/routes/*.ts admin, account(+models/usage), conversations, messages, files, projects, docs (markdown → HTML)
src/streaming.ts hijack pipe w/ backpressure, socket-close abort, binary forwarding
scripts/smoke.sh end-to-end probe
scripts/install-service.sh launchd installer
Re-discovering upstream paths when something breaks: claude.ai/new HTML lists the
SPA chunks (assets-proxy.anthropic.com/claude-ai/v2/assets/v1/*.js); fetch them with
the session cookie and grep for the feature name (conversation/search, count_all, …)
to read the current path and request body builder.
Scope note: the claude.ai «код» tab surface is intentionally not implemented — everything else the web UI does is covered above.