# 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 levels `low|medium|high|xhigh|max`, thinking modes `extended|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

```bash
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:

```bash
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:

```bash
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

```bash
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):

```bash
tailscale funnel --bg 9922      # publish; verify: tailscale funnel status
```

Clients just use the public base URL:

```bash
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 9922` when needed (check with `tailscale funnel status`).
- The URL is discoverable by anyone who learns it — keep `API_TOKEN` mandatory
  (`API_TOKEN` in `.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)

```bash
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:

```nginx
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
# 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())
```

```javascript
// 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:

- [x] `API_TOKEN` set (≥ 32 random chars, e.g. `openssl rand -hex 24`)
- [x] Public HTTPS via Tailscale Funnel
- [ ] `CORS_ORIGIN` restricted 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
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:

```bash
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`):

```jsonc
{
  "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:

```bash
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](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`:

```jsonc
// 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_uuid`s.

### 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:

```json
{"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

```bash
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

```bash
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.
