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:

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

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:

  1. Log into claude.ai in your browser.
  2. DevTools → Application → Cookies → https://claude.ai → copy sessionKey (sk-ant-sid01-…).
  3. 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:

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:

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:

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.