# Thunderclip — API & Agent Reference

Ephemeral URL service. Upload content, get a short-lived link. Both
humans and agents are first-class clients; this document is the
single source of truth for the HTTP surface and is reachable at:

- `https://thunderclip.com/api` (canonical)
- `https://thunderclip.com/AGENTS.md` (alias, case-insensitive)
- `https://thunderclip.com/llms.txt` (curated index — points back here)

App base URL (dashboard, auth, API): `https://thunderclip.com`

Clip content is served from a **separate domain**: `clip.lc`. Inert
clips (text, JSON, images, media, binary) share the host
`https://th.clip.lc/<id>`; script-capable clips (HTML, SVG, XML, PDF,
unknown types) each get their own isolated origin
`https://<host>.clip.lc/<id>` (a random short host). Always use
the `url` returned by the API — never construct clip URLs by hand.

## Quick orientation

- **Humans**: visit the website, sign in with Enter ID, mint an API key
  from the dashboard, and use it as a Bearer token.
- **Agents**: walk the [device flow](#device-flow) below to provision
  yourself an API key on the operator's behalf — no human credential
  ever passes through you.
- **MCP-speaking clients (Claude Desktop, Cursor, Zed, …)**: install
  the [`@rac.so/thunderclip-mcp`](https://www.npmjs.com/package/@rac.so/thunderclip-mcp)
  stdio server and Thunderclip becomes a native tool — including uploads
  straight from disk, which is the only way to publish binary. Auth is still
  via a bearer token you mint once with the device flow.
- **Either**: every authenticated request needs
  `Authorization: Bearer clip_<key>`. Keys never expire on their own;
  revoke them from the dashboard or via API.

---

## Device flow

RFC 8628 OAuth Device Authorization Grant. Designed for the case
where an agent is acting on behalf of a human who has a browser
nearby. The agent never sees the operator's IdP credentials; the
operator never sees the agent's machine. Approval happens entirely on
Enter ID (Thunderclip's identity provider), which hands the operator a
short **completion code** to type back into the agent — this binds the
approval to your specific flow and defeats device-code phishing. Three
actors, four steps:

### 1. Agent: request a code

```
POST /auth/device
Content-Type: application/json

{"agent_label": "claude-code"}
```

`agent_label` is optional; if provided it labels the device in the
operator's Enter ID device list. To also label the API key you receive,
resend the same `agent_label` on the token poll (step 3).

Response (200):

```json
{
    "device_code": "<opaque secret>",
    "user_code": "ABCD-EFGH",
    "verification_uri": "https://enterid.org/device",
    "verification_uri_complete": "https://enterid.org/device?code=ABCD-EFGH",
    "expires_in": 600,
    "interval": 5
}
```

Save the `device_code`. Print the `verification_uri` and `user_code`
to the operator. The `verification_uri_complete` form is convenient
when you can render a clickable link (or a QR code).

`POST /auth/device` also accepts `application/x-www-form-urlencoded`
for off-the-shelf OAuth clients.

### 2. Operator: visit the URL and authorise

The operator visits `verification_uri` (on Enter ID), enters the
`user_code`, signs in, and approves. Enter ID then shows them a
**three-character completion code** and asks them to type it back into
your agent. Collect that code from the operator — you will send it on
the final poll. Thunderclip resolves the approved operator to an account
with the same email-anchored find-or-create logic as a regular login —
an existing account is reused, a brand-new operator gets one
auto-provisioned — and binds the agent's key to it.

### 3. Agent: poll the token endpoint

Poll with just the `device_code` until the operator has approved; then
poll once more with the `completion_code` they gave you.

```
POST /auth/token
Content-Type: application/json

{
  "grant_type":     "urn:ietf:params:oauth:grant-type:device_code",
  "device_code":    "<the one from step 1>",
  "completion_code": "<the 3-char code the operator was shown>"
}
```

Poll every `interval` seconds (5s minimum). Possible responses:

| HTTP | Body                                              | Meaning                                                                              |
| ---- | ------------------------------------------------- | ------------------------------------------------------------------------------------ |
| 200  | `{"access_token":"clip_…","token_type":"Bearer"}` | Operator finished. The token IS your long-lived API key.                             |
| 400  | `{"error":"authorization_pending"}`               | Operator hasn't approved yet — keep polling (without a completion code).             |
| 400  | `{"error":"completion_required"}`                 | Approved, but no valid completion code yet. Ask the operator for it, then poll once more. |
| 400  | `{"error":"slow_down"}`                           | You polled before `interval` elapsed. Wait at least 5s before retrying.              |
| 400  | `{"error":"access_denied"}`                       | Operator denied, or a wrong completion code voided the flow. Stop; start over.       |
| 400  | `{"error":"expired_token"}`                       | TTL exceeded, already consumed, or unknown `device_code`. Stop; start over.          |
| 403  | `{"error":"signups_closed"}`                      | The operator authenticated, but has no Thunderclip account and new accounts are closed. Stop; retrying cannot help. |

Like `/auth/device`, this endpoint accepts both JSON and form-urlencoded.

### 4. Agent: store and use the key

Treat the returned `access_token` exactly like any other Thunderclip API
key — long-lived, revocable, scoped to the operator's account. Future
runs of the same agent should reuse the stored key rather than
running the device flow again.

---

## MCP server (Claude Desktop / Cursor / Zed / …)

If your runtime speaks the [Model Context Protocol](https://modelcontextprotocol.io),
Thunderclip ships an official stdio server that turns the API into native
tools — no curl, no remembering the request shape.

```bash
npx -y @rac.so/thunderclip-mcp     # served from npm
```

Wired into Claude Desktop:

```json
{
    "mcpServers": {
        "thunderclip": {
            "command": "npx",
            "args": ["-y", "@rac.so/thunderclip-mcp"],
            "env": { "THUNDERCLIP_API_KEY": "clip_..." }
        }
    }
}
```

Tools exposed: `clip_upload`, `clip_upload_from_disk`, `clip_read`,
`clip_modify`, `clip_delete`, `clip_cell`.

Because it runs on your machine it can read your filesystem, and
`clip_upload_from_disk` is the reason to prefer it over the
[hosted server](#mcp): it publishes a file without the bytes passing through
the model's context, and it is the only path that carries binary at all —
images, fonts, archives and PDFs cannot travel as text parameters.

If you would rather not put the key in your client's config file (they end up
in dotfile repos), leave `env` out and put the key on the first line of
`~/.config/thunderclip/credentials`, or `%APPDATA%\thunderclip\credentials` on
Windows. The environment wins if both are set.

Auth is your device-flow-minted API key — the server is a thin passthrough
over this same HTTP API and holds no rules of its own. Source:
[github.com/Racso/thunderclip/tree/main/mcp](https://github.com/Racso/thunderclip/tree/main/mcp).

---

## Sign-in (humans)

```
GET /auth/enterid
```

[Enter ID](https://enterid.org) is the only identity provider. Top-level
browser navigation only — this sets an HttpOnly session cookie scoped to
thunderclip.com. The registered redirect_uri is
`https://thunderclip.com/auth/enterid/callback`. After a successful dance the
browser lands on `/dashboard`; after a failure, on `/login?auth_error=<reason>`.

Sign-in is email-anchored: Thunderclip looks up accounts by the verified email
address Enter ID reports. Enter ID itself offers a magic link, a passkey, or
"Sign in with Google" — whichever you use, the same address lands you in the
same Thunderclip account. Thunderclip's own GitHub and Google buttons were
retired for exactly that reason; accounts created through them are reached by
signing in with the same email.

**New accounts are closed while Thunderclip is pre-launch.** An identity that
verifies at Enter ID but has no Thunderclip account behind it is refused —
`/login?auth_error=signups_closed` in the browser, `403 signups_closed` on the
device flow — and nothing is created. Existing accounts sign in normally.

---

## Blobs (large files)

A **blob** is a large inert file — a video, an installer, an archive — up to
**5 GB**. It is a different noun from a clip and the differences are load-bearing:

- Served from **one** public host, `https://big.clip.lc/<id>.<ext>`, **straight
  from storage**. No Thunderclip code is on a blob's read path. Range requests
  work natively, so a video seeks properly when embedded in a page.
- **Inert data types only.** Anything script-capable (HTML, SVG, XML) is
  refused with `415` — that rule is what makes one shared host safe.
- **Never a folder member.** Folders hold clips.
- **Bytes never cross the Thunderclip server.** You upload directly to storage
  with presigned URLs, which is why there is no way to create a blob from a
  request body. It comes off a disk or it does not exist.
- **Costs 1 credit per 500 MB, rounded up.** A 2.2 GB blob is 5 credits; the
  cap bounds any blob at 10. Extending a lifetime or restoring a lapsed blob is
  charged the same way.
- Default lifetime **7 days**.

Upload is three calls.

### 1. Open the upload

```
POST /api/blobs?ext=<ext>&ttl=<minutes>&recovery=1
Authorization: Bearer <key>
Content-Type: application/json

{ "size": 524288000 }
```

`size` is the total in bytes and is **required**. It is what you are charged
on, so the account must be able to cover it — the call fails with `402` before
anything moves otherwise.

```json
{
  "id": "w1q2hj07sa7wr",
  "url": "https://big.clip.lc/w1q2hj07sa7wr.bin",
  "upload_id": "…",
  "part_size": 16777216,
  "part_urls": ["https://…", "…"],
  "part_urls_expire_in": 21600,
  "credits_charged": 1,
  "disable_at": "2026-09-20T00:00:00.000Z"
}
```

### 2. Send the parts

`PUT` each slice of the file to its URL, in order, `part_size` bytes each (the
last one is whatever remains). Keep the `ETag` response header from each. A
part that fails can simply be re-PUT to the same URL — that is your resume.

### 3. Complete

```
POST /api/blobs/<id>/complete
Content-Type: application/json

{ "parts": [ { "part_number": 1, "etag": "\"abc…\"" }, … ] }
```

The server asks storage how big the assembled object really is, **settles the
difference against what you declared**, and publishes. `credits_settled` is
positive if you under-declared and were charged more, negative if you
over-declared and were refunded.

Declaring far less than you upload does not save anything: if the real size
costs more than you can cover, the blob is discarded and your credits returned
(`402`). Same for anything over 5 GB (`413`). Nothing is lost that was ever
public — a blob's URL is not real until this call returns.

| HTTP | Meaning |
| ---- | ------- |
| `402` | Not enough credits for the declared size, or for the real one at completion. |
| `409` | Already completed. |
| `413` | Over the 5 GB cap. |
| `415` | Script-capable type. Upload it as a clip. |
| `502` | Storage would not open or assemble the upload. Nothing was published; retry. |

### Managing blobs

```
GET    /api/accounts/<id>/blobs?state=enabled|disabled|all
PUT    /api/blobs/<id>?ttl=<minutes>     extend, or restore a lapsed blob
DELETE /api/blobs/<id>                   immediate and permanent
```

**Disable and restore are eventual, not instant.** A lapsed blob stops being
served within a few minutes, and a restored one comes back on the same
schedule and at the identical URL. This is deliberate: nothing about a blob's
lifetime is enforced by code sitting on its read path, so the transition is
carried out by a background sweep. What *is* guaranteed is that the edge cache
can never outlive the blob's lifetime, because the cache lifetime is written
onto the object from its disable time at upload.

A disabled blob returns a plain `404`, not a `410` — there is no Thunderclip
code there to say anything more specific.

---

## Upload

```
POST /api/
Authorization: Bearer clip_<key>
X-Extension: <ext>

<raw body>
```

Thunderclip is **extension-first**, and a type is **mandatory** — every upload
must declare its type explicitly. There is no silent fallback to binary; an
upload with no type signal is rejected with `400`. Send one of:

- **`X-Extension` header** (preferred), or **`?ext=`** query — the bare file
  extension that fits the content (e.g. `md`, `json`, `diff`, `html`, `png`).
  It drives both the stored MIME type and how the clip renders. For arbitrary
  bytes with no meaningful extension, use `bin`, `dat`, or `raw`.
- **`X-Content-Type` header**, or **`?contentType=`** query — an escape hatch
  for an exotic MIME no extension maps to (e.g. `application/vnd.foo+json`).

The ambient `Content-Type` request header is **ignored** for type resolution —
clients auto-set it, so it's unreliable. Use `X-Content-Type` when you want to
set a MIME explicitly.

Optionally, also send **`X-Filename`** (or **`?filename=`**) — the real file
name, used for `Content-Disposition` so downloads land under it instead of the
clip's id. It does not affect the type; see [Download names](#download-names).

Query parameters:

- `ttl` — Time-to-live in minutes. Default: 1440 (1 day). Maximum depends on tier.
- `ext` — Extension; same as the `X-Extension` header (the header wins if both are set).
- `contentType` — Explicit MIME escape hatch; same as the `X-Content-Type` header.
- `filename` — Download name; same as the `X-Filename` header. Optional.
- `recovery` — Set to `1` (or send `X-Recovery: 1`) to give the clip a
  **recovery window**. Off by default. Free at upload. See
  [The recovery window](#the-recovery-window).
- `cell` — Set to `1` (or send `X-Cell: 1`) to attach a **cell**, a tiny
  read/write storage slot bound to the clip. Costs one extra credit. See
  [Micro-persistence](#micro-persistence-cells).

Response (201):

```json
{
    "id": "7x4m9qk2trwbe",
    "url": "https://th.clip.lc/7x4m9qk2trwbe",
    "contentType": "text/plain; charset=utf-8",
    "extension": "txt",
    "filename": "notes.txt",
    "size": 42,
    "createdAt": "2026-05-24T12:00:00.000Z",
    "disableAt": "2026-05-25T12:00:00.000Z"
}
```

`filename` is present only when one was sent.

`disableAt` is when the clip stops being served — from that instant every URL
for it returns `410`. A clip has three lifecycle states: **enabled** (publicly
reachable), **disabled** (its TTL ran out; it is no longer served, listed, or
editable), and **destroyed** (its row and bytes are freed). Today a clip is
destroyed as soon as it is disabled, so `disableAt` is the only lifetime you
need to track.

Ids are 13 lowercase characters (Crockford base32: digits and letters minus `i l o u`). The `url` is canonical and depends on the
clip's type class:

- **Inert** types (text, markdown, JSON, CSV, source code, images, audio,
  video, binary, diffs) share the host: `https://th.clip.lc/<id>`.
- **Script-capable** types (`html`, `svg`, `xml`, `pdf`, and anything
  unknown) each get their own isolated origin:
  `https://<host>.clip.lc/<id>`, where `<host>` is a random short
  label unique to that clip.

Treat the returned `url` as opaque — don't derive it from the id.

When you request a cell (`X-Cell: 1` / `?cell=1`), the response also carries
`"cell": true` and a `"cellUrl"` pointing at the cell's read/write endpoint
on the clip's own `clip.lc` host.

### Extensions with special rendering

| Extension        | Resolved type      | Rendering                                   |
| ---------------- | ------------------ | ------------------------------------------- |
| `diff`           | `text/x-diff`      | Syntax-highlighted unified diff             |
| `json`           | `application/json` | Collapsible tree view                       |
| `md`             | `text/markdown`    | Rendered markdown                           |
| `html` / `htm`   | `text/html`        | Highlighted source (the file URL serves the live page) |
| `svg`            | `image/svg+xml`    | Highlighted source (the file URL renders the image) |
| `csv` / `tsv`    | `text/csv` / TSV   | Sortable table (rendered view only)         |
| `xml`            | `application/xml`  | Highlighted source (the file URL serves the XML) |
| audio (see below) | `audio/*`         | Player page with waveform (rendered view only) |
| video (see below) | `video/*`         | No page — both URLs serve the bytes         |
| code (see below) | per extension      | Syntax-highlighted source with line numbers |
| other            | per extension      | Raw content with the resolved MIME          |

Code files render as a highlighted, line-numbered page, dispatched by extension:
`js`/`jsx`/`ts`/`tsx`, `py`, `rb`, `go`, `rs`, `java`, `kt`, `c`/`h`, `cpp`,
`cs`, `php`, `swift`, `sh`/`bash`, `sql`, `yaml`/`yml`, `toml`, `ini`,
`css`/`scss`/`less`, `lua`, `dockerfile`, `makefile`, `r`. Files over 512 KB are
served raw instead. A file URL never highlights.

Every page above is rendered in the reader's browser: the response is a small
shell that fetches the clip's own raw URL and builds the view client-side. It
needs JavaScript, and a document over 2 MB is served raw rather than given a
viewer that would never finish painting.

Audio extensions: `mp3`, `wav`, `ogg`/`oga`, `m4a`, `aac`, `flac`, `opus`,
`weba`. The page URL serves a player with a click-to-seek waveform — something
the browser can't give you, which is the test a viewer has to pass.

Video (`mp4`/`m4v`, `webm`, `mov`, `mkv`) has **no page**: our player was a bare
`<video controls>`, identical to native playback, so both URLs serve the bytes.

Pass `?ext=diff`, `?ext=json`, `?ext=md`, or any code extension (or the
`X-Extension` header) to select rendering.

## Edit

Overwrite an existing clip's content in place, keeping the same id (and URL):

```
PUT /api/clips/<id>
Authorization: Bearer clip_<key>

<raw body>
```

Use this when a clip is a moving target (live logs, a status page, a draft) so
its link stays stable across changes. You must own the clip — anyone else (or an
unknown/disabled id) gets `404`. **Every successful edit spends a credit**, just
like an upload (including a metadata-only edit — otherwise a free TTL bump could
keep a clip alive forever).

**Metadata is preserved by default: send only the new body.** The clip keeps its
extension, content-type, and disable time. Override any of them in the same request
with the same signals as upload — whatever you omit stays as it was:

- `?ext=` / `X-Extension` — change the type by extension.
- `?contentType=` / `X-Content-Type` — change the type to an explicit MIME.
- `?ttl=` — reset `disableAt` to this many minutes from now (clamped to the tier
  max). This is how you **extend** (or shorten) a clip's life, e.g. `?ttl=43200`.
- `X-Cell` / `?cell=1` — **add a cell** to a clip that doesn't have one yet (see
  [Micro-persistence](#micro-persistence-cells)). Add-only, and it costs **one
  extra credit** on top of the edit. If the clip already has a cell the flag is a
  no-op (no second charge). Adding a cell counts as a change on its own, so
  `PUT /api/clips/<id>` with just `X-Cell: 1` is valid.

You can also send **no body at all** to change only metadata (the content is
left untouched) — the common case being a TTL bump: `PUT /api/clips/<id>?ttl=N`.
A request that changes nothing (no body, no overrides, no new cell) is rejected
with `400`.

**An edit cannot move a clip between type classes.** A clip's home — the
shared inert host vs. its own isolated origin — is fixed at upload, because
changing it would change the URL. A type override that would cross the line
(e.g. `?ext=html` on a text clip, or `?ext=txt` on an HTML clip) is rejected
with `409`; upload a new clip instead.

`X-Filename` / `?filename` renames the download without touching the content;
sending only that is a valid (charged) edit. Omitting it leaves the stored name
as it was — there is no way to clear it back to none.

Response (200) has the same shape as an upload, `url` included. `createdAt` is
unchanged; `disableAt` reflects the new `?ttl` if you sent one, otherwise the
original. When the clip has a cell (added now or earlier), the response carries
`"cell": true` and a `"cellUrl"`.

---

## Retrieve

Retrieval happens on the clip's `clip.lc` host — the exact host is in the
`url` the API returned. No auth; knowing the URL is the authorization. The
grammar, relative to that host:

```
GET /<id>              # the page: diff/markdown/JSON/code as HTML; raw if we have no viewer
GET /<id>.<ext>        # the file: raw bytes with the stored content-type
GET /<folderId>/<path> # a folder member: the file, raw bytes
GET /<folderId>/<path-without-its-extension>   # the same member as a page
GET /<folderId>/       # the folder itself
```

**An extension means "give me the file"; its absence means "give me the
page".** That single rule is the whole grammar, and it holds for folder members
exactly as it does for standalone clips: a member uploaded as `a.ts.diff` serves
bytes at `/<folderId>/a.ts.diff` and the diff viewer at `/<folderId>/a.ts`.

Stripping is one level and is matched against the member's **own** extension, so
`a.ts` reaches `a.ts.diff` and never `a.ts.diff.bak`. A real name always wins: if
the folder also contains a member literally called `a.ts`, that is what
`/<folderId>/a.ts` serves, and the diff stays reachable at its own name or via
`/v/`. If two members strip to the same path (`a.ts.diff` and `a.ts.md`) the
short form is a `404` rather than a guess.

Put another way: the **file** URL serves the resource exactly as the web would
have served it if Thunderclip didn't exist — an MP3 plays, a JSON is JSON, an
HTML page renders. The **page** URL is the Thunderclip-flavoured one, built for
a human inspecting the thing: markdown rendered, JSON as a collapsible tree, CSV
as a sortable table, audio as a waveform player, source code highlighted and
line-numbered.

The markup family — **HTML, SVG and XML** — is where this bites hardest, because
the browser renders all three natively. So `/<id>.html` gives you the live page
and `/<id>` gives you its markup; `/<id>.svg` draws the image and `/<id>` shows
the source. Those are different jobs and each URL does exactly one of them.

Where Thunderclip has nothing to add beyond what the browser already does —
images, PDFs, archives, video, arbitrary binaries — both URLs simply serve the
bytes.

The rule is also what makes an uploaded folder work as a static site: every
member path carries an extension, so assets are fetched as bytes without anyone
having to say so.

`<ext>` is **not** a type override — it is part of the clip's own name, and it
must be the extension the clip was uploaded with. A mismatch is a `404`, not a
silent fallback. (A clip uploaded with only `?contentType=` carries no
extension and is therefore addressable at `/<id>` alone.)

To force either intent explicitly:

```
GET /<id>?view    GET /v/<id>      # the page, whatever the path looks like
GET /<id>?raw     GET /r/<id>      # the bytes, whatever the path looks like
GET /<id>.<ext>?download           # the bytes as an attachment (save dialog)
```

`?view` and `?raw` work on member paths too — `/<folderId>/README.md?view`
renders it instead of serving it, and `?raw` overrides the page default on the
extensionless member form.

Use the bare `/<id>` when sharing with a human; use `/<id>.<ext>` when you want
parseable bytes: a JSON clip comes back as `application/json`, a diff as
`text/x-diff`, an image as `image/png`.

### Download names

Every raw response carries a `Content-Disposition` header, so a saved file
lands under a real name. By default that name is the clip's address
(`<id>.<ext>`), which is correct but ugly. Send the real one at upload:

```
POST /api/
X-Extension: bin
X-Filename: myapp.apk
```

and the download arrives as `myapp.apk` rather than an extensionless `a378sfhi`
the OS refuses to open. The name is stored with the clip and echoed in the
upload response as `filename`. It is a *name*, never a path — any directory
part is dropped, and characters that could corrupt the response header are
stripped. Folder members need none of this: a member's path already is its name.

The header is `inline` by default, so browsers still display what they can.
Add `?download` to force a save dialog instead.

### Folders

A folder clip is addressed the same way; members hang off its path:

```
GET /<folderId>               # folder listing (HTML)
GET /<folderId>/              # the same
GET /<folderId>?raw           # JSON manifest of the folder
GET /<folderId>/src/main.ts   # member, raw bytes
GET /<folderId>/src/main      # the same member, rendered (extension stripped)
GET /<folderId>/src/main.ts?view  # the same, said explicitly
```

A folder has no extension of its own, so `/<folderId>.<anything>` is a `404`.

**The folder root shows the listing, never `index.html`.** A folder root has no
extension, and no extension means the inspection page — for a folder that is its
file listing. A site's entry point is addressed like any other member, at
`/<folderId>/index.html`.

**Links inside a hosted folder must be relative.** An absolute path (`/style.css`)
resolves to the host root, which is not your folder — and it never can be: the
folder id is the capability secret and lives in the path, deliberately never in
the hostname. Use `style.css` or `../style.css`.

Folder API responses return the canonical folder `url` on its `clip.lc`
host, just like single clips.

**Creating a folder costs one credit only when it arrives empty.** A folder
always gets its own isolation host, and that host is a finite, never-reused
resource — so no request may create one for free. When you `POST /api/folders`
*with* files you are already charged one credit per file, and nothing extra is
added: two files cost two credits. A bodyless `POST /api/folders` creates an
empty folder and costs one credit, which is what pays for the host in the case
where no file does.

**A folder owns ONE lifetime, shared by every file in it.** `?ttl` on
`POST /api/folders` sets it; each member's `disableAt` is a copy of the
folder's. When the folder is disabled the whole folder is gone at once —
there are no per-file lifetimes and no file outlives its folder.

Adding files later does **not** extend the folder. A file added to a folder
that disables tomorrow is a file that disables tomorrow, and `?ttl` on
`POST /api/folders/<id>/files` is ignored. To buy more time, extend the folder
explicitly:

```
PUT /api/folders/<id>?ttl=<minutes>
Authorization: Bearer clip_<key>
```

Extending moves the folder and every member to `now + ttl` (clamped to the tier
maximum), and **costs one credit per file** — extending a folder of 40 files
costs 40 credits, because it is buying 40 clip lifetimes. An empty folder costs
1. `402` if you can't cover it, in which case nothing moves and nothing is
charged. Response: `{ id, url, disableAt, files, charged }`.

### Errors

- `404` — Clip not found.
- `410` — Clip disabled: its lifetime ran out (rendered as a friendly page).

The `410` is deliberately uniform: it never reveals whether the clip is still
restorable, so a stranger holding a link cannot probe someone's archive. It is
also sent `Cache-Control: no-store`, because a restored clip comes back at the
very same URL and a cached "gone" would outlive the restore.

---

## The recovery window

A clip has three states, not two:

| State | Publicly served | Bytes on disk | How you get here |
|---|---|---|---|
| **enabled** | yes | yes | until `disableAt` |
| **disabled** | no — `410` | yes | `disableAt` → `destroyAt`, if you opted in |
| **destroyed** | no — `404` | no | after `destroyAt` |

Without a window the middle state has zero width: the clip is destroyed the
instant it is disabled. That is the default, and it is the ephemeral promise —
we did not want to retract it for everyone by making recovery automatic.

**Opting in** is `X-Recovery: 1` / `?recovery=1` at upload, and it costs nothing.
The credit is charged if and when you actually restore.

**The window's length is `clamp(ttl, 24h, 7d)`** and is not yours to choose. Not
"the same as the TTL": a 10-minute clip would get a 10-minute window, which
nobody notices in time, and the whole feature is for the case where you realise
later. A 30-day clip would get a 30-day window, doubling what we store for it.

**Restoring** is an ordinary edit:

```
PUT /api/clips/<id>?ttl=<minutes>
Authorization: Bearer clip_<key>
```

Same id, same host, same URL, one credit. `?ttl` is **required** here — the
clip's old disable time is in the past, so without a new lifetime the restore
would land it straight back where it was. Once the window closes, this is a
`404`, indistinguishable from a clip that never existed.

Folders work the same way, with one window shared by every file:
`PUT /api/folders/<id>?ttl=` restores the whole folder in one action at one
credit per file, and every member returns at its own unchanged URL.

An ordinary TTL bump on a live clip neither grants nor revokes a window — but it
does re-derive the window's *length* from the new TTL. Send `X-Recovery: 1` /
`X-Recovery: 0` on the edit to change whether the clip has one at all.

### Finding your lapsed clips

A recovery window nobody can see is not a feature. The listing takes a `state`:

```
GET /api/accounts/<id>/clips?state=enabled   # default
GET /api/accounts/<id>/clips?state=disabled  # lapsed, still restorable
GET /api/accounts/<id>/clips?state=all
```

Each entry carries `state` (`enabled` | `disabled`) and, when the clip has a
window, `destroyAt` — the deadline to restore by.

**An explicit `DELETE` is always instant and permanent.** The window applies only
to a lifetime running out, never to a clip you asked us to remove.

### Legacy URLs

Old `clip.rac.so` links (`/view/<id>`, `/raw/<id>`, `/c/<id>`) respond with
`301` redirects to the new `clip.lc` locations for a one-week transition,
then retire. Update stored URLs now.

---

## MCP

Thunderclip speaks the [Model Context Protocol](https://modelcontextprotocol.io)
directly. There is nothing to install:

```json
{
  "mcpServers": {
    "thunderclip": {
      "type": "http",
      "url": "https://thunderclip.com/api/mcp",
      "headers": { "Authorization": "Bearer clip_<key>" }
    }
  }
}
```

One tool — `thunderclip_docs` — which returns this document in full, plus
server instructions the client injects into the model's system prompt. Read the
contract once, then call the API directly: creating a clip is a single POST
with the content as the body.

That is the whole server, and the omission is deliberate. An MCP tool is
reachable only by a client that has mounted this server; the HTTP API is
reachable by anything that can make a request. For a caller that can already
make one — which is everyone connecting over the network — a tool adds a layer
without adding a capability, and that layer is a second copy of the API's rules
waiting to fall behind them. The extra turn it costs is the same turn a client
already spends loading tool schemas before its first call.

If you need tools rather than documentation — because your session has no
shell, or because you want to publish a file from disk — install the
[npm package](#mcp-server-claude-desktop--cursor--zed--) instead. It runs
locally, so it can reach your filesystem, which is a thing no hosted server can
do for you.

Authorization is the ordinary API key. The MCP spec's OAuth profile is aimed at
servers facing unknown clients; a Bearer key is the same credential the REST
surface takes.

---

## Micro-persistence (cells)

A **cell** is an optional, tiny read/write storage slot bound to a single clip —
one bounded string (max 1 KB) the clip can read and update in place. It's just
enough shared state for a counter, a status flag, a small JSON blob, or a bit of
coordination between viewers, with no backend of your own. The cell stores a
string; what it means is up to you (store JSON, base64, whatever fits in 1 KB).

**Opt in at upload, or add one later.** Add `X-Cell: 1` (or `?cell=1`) to a
`POST /api/` to attach a cell at creation, or to a `PUT /api/clips/<id>`
([Edit](#edit)) to add one to a clip that doesn't have a cell yet. Either way it
costs **one extra credit** (charged together with the upload or edit, all-or-
nothing — if you can't cover it, nothing is created). The response then carries
`"cell": true` and a `"cellUrl"`. Adding is idempotent: `X-Cell` on a clip that
already has a cell is a no-op and isn't charged. A cell can't be removed once it
exists.

**Access is open — the clip id is the secret.** There are no separate tokens for
the cell: anyone who knows the clip id can read and write it, exactly like the
clip itself. Only share the id with people you'd let change the value.

**Cells live on the clip's own origin**, at `/c/<id>` on the same `clip.lc`
host as the clip — the exact URL is the `cellUrl` in API responses. For a
script-capable clip this means its embedded JavaScript talks to its own
origin: no CORS, no preflight, no credentials to configure.

That cuts both ways: **a page can only reach its own cell.** The cell endpoint
sends no `Access-Control-Allow-Origin` and answers no preflight, so a clip
cannot read or write the cell of a *different* clip — "small page here, big
state over there" is not a pattern that works. Put the state on the same id as
the page that owns it.

A page doesn't need the `cellUrl` handed to it — the id is already in its own
URL, so it can build the path with no server involvement:

```js
// on https://th.clip.lc/24rh9pxc7ach8.html → /c/24rh9pxc7ach8
const cell = "/c/" + location.pathname.slice(1).split(".")[0];
const { value, version } = await fetch(cell).then((r) => r.json());
```

Nothing is injected into your bytes: a clip is served exactly as uploaded.

The cell shares the clip's lifetime: extending the clip extends the cell, and
when the clip is disabled or deleted the cell goes with it.

### Read

```
GET /c/<id>
```

(on the clip's `clip.lc` host)

Response (200):

```json
{ "value": "…", "version": 3 }
```

An unwritten cell reads as `{"value": "", "version": 0}` — never `null`. `404` if
the clip has no cell; `410` if the clip is disabled.

### Write

```
PUT /c/<id>
Content-Type: application/json

{ "value": "…", "version": 4 }
```

`value` is the new string (max 1 KB, measured in UTF-8 bytes). `version` is
**optimistic concurrency**: send the version you expect this write to *produce* —
the current version + 1. If it doesn't match, someone wrote before you and you
get `409` carrying the current `{"value","version"}`, so you can reconcile and
retry without a separate read. On success you get back the new
`{"value","version"}`.

**Force write:** omit `version` to overwrite unconditionally (the single-writer /
don't-care case). It still bumps the version so concurrent readers notice the
change.

Writes are rate-limited per cell — back off on `429` using `Retry-After`.

- `400` — `value` missing or not a string, or a malformed `version`.
- `404` — clip has no cell.
- `409` — version conflict; the body includes the current value + version.
- `410` — clip disabled.
- `413` — value larger than 1 KB.

---

## Standalone cells (experimental)

> **Experimental.** This is a lab surface under evaluation, published alongside
> the clip-bound cells above rather than replacing them. It may change shape or
> be withdrawn. Nothing here affects an existing cell.

A **standalone cell** is the same 1 KB–64 KB read/write slot, minus the clip. It
has its own id, its own lifetime and its own purchased size, and it is served
from a single shared host — `https://c.clip.lc/<id>` — to **any** clip origin
that asks. So one page can keep its state next to a _different_ page's data, two
clips can share one counter, and an agent and a browser can meet on the same id.

It answers the question the clip-bound cell cannot: state that outlives, or is
shared by, more than one clip.

### Buy a cell

```
POST /api/cells
```

Authenticated, on the app origin. Query parameters:

- `kb` — size tier. One of **1**, **8** or **64**, costing **1**, **2** and **3**
  credits. Defaults to `1`. A value between tiers is a `400`, not a round-up.
- `ttl` — lifetime in minutes. Defaults to 1440 (24 h).
- `parent` — id of a clip or folder **you own**. The cell then follows that
  parent's lifetime instead of having one of its own. Mutually exclusive with
  `ttl` (passing both is a `400`). See [Parents](#parents) below.
- `token=1` (or `X-Cell-Token: 1`) — mint a **write token**. Free.

Response (`201`):

```json
{
    "id": "4hj2k9x7mq3wp",
    "url": "https://c.clip.lc/4hj2k9x7mq3wp",
    "maxBytes": 1024,
    "disableAt": "2026-09-24T18:00:00.000Z",
    "writeToken": "kQ8…"
}
```

`writeToken` appears **only here**, only when asked for, and never again — not in
a read, not in a listing. Store it when you see it or mint a new cell.

### Parents

A standalone cell has its own lifetime, which means a page and the state it
reads can expire on different days — and nothing would tell you which one went
first. Passing `parent` fixes that by making the cell follow a clip's or
folder's lifetime:

```
POST /api/cells?parent=<clipOrFolderId>&kb=8
```

The response's `disableAt` is then the **parent's**, and so is every `410`.

- **Linked, not copied.** Renew the parent and the cell moves with it. There is
  no stored duplicate of the date that could fall behind.
- **Deleting the parent destroys the cell.** That is the point of linking, and
  it is the sharp edge: a cell can be read by pages *other* than its parent, so
  deleting one clip can take out state another page depends on.
- **No reparenting and no detaching.** If a cell needs more time, renew its
  parent. One lifetime, one owner, no way to end up with two answers.
- The parent must be **yours** and must still be enabled. Someone else's id is a
  `404`; a lapsed one is a `400` rather than a cell sold dead.

What is *not* coupled is where the cell is served. A parented cell still lives
on `c.clip.lc` and is still readable and writable by any clip origin — only the
lifetime is borrowed.

`GET /api/cells` reports `parent` for each cell, so "when does what this page
depends on stop working?" has an answer. Note the limit: this records the cell a
clip **owns**, not every cell a page **reads**. A page reading a cell it didn't
parent is not tracked, which is inherent to sharing.

### Read and write

```
GET  https://c.clip.lc/<id>
PUT  https://c.clip.lc/<id>
```

Identical semantics to a clip-bound cell: `{"value","version"}` on read, CAS on
write when you send `version`, unconditional overwrite when you omit it, `409`
with the current state on conflict.

Two differences, both deliberate:

- **`413` quotes this cell's own limit**, because the size was bought. A 4 KB
  value is rejected by a 1 KB cell and accepted by an 8 KB one.
- **A cell with a write token requires it** on `PUT`, as
  `Authorization: Bearer <token>`. Without one it is `401`. Reads never require
  it, and a read cannot tell you whether a cell has one.

A cell **without** a token is writable by anyone holding the id — the same model
as clip-bound cells, and still the default. That is the right choice for a
counter shared between cooperating agents, and the wrong one for anything whose
integrity matters. Note what a token cannot fix: if you embed it in a public
page, every reader of that page has it. A token is only a real boundary when the
writer is not the public page — an agent, a private admin console, your own
machine.

### CORS

The cell host answers preflights and grants `Access-Control-Allow-Origin` to any
`https://*.clip.lc` origin, so a page served from a clip or a folder can read and
write cells directly. `Origin: null` (sandboxed iframes, `file://`) and
third-party sites get no grant.

This is not a security boundary and is not meant as one — any HTTP client sets
`Origin` to whatever it likes, and authority lives in the token or the id. What
it prevents is an unrelated website using Thunderclip as a free key-value store
out of its visitors' browsers.

Credentials are never allowed: no cookie is ever read or written on the cell
host.

### Manage

```
GET    /api/cells        — your cells (never includes a write token)
DELETE /api/cells/<id>   — immediate and permanent
```

The listing reports `hasWriteToken`, `parent` and `enabled`. Lapsed cells are
**included** and flagged: there is no reaper for standalone cells yet, so a
lapsed one stops serving (`410`) but its row remains until deleted — or until its
parent is, which cascades.

### Errors

- `400` — invalid `kb`, `parent` together with `ttl`, a lapsed `parent`, or
  `value`/`version` malformed.
- `401` — no API key (on `/api/cells`), or missing/wrong write token (on `PUT`).
- `402` — not enough credits for the tier.
- `404` — no such cell or parent, or not yours.
- `409` — version conflict; the body carries the current value + version.
- `410` — lifetime has run out.
- `413` — value larger than **this cell's** `maxBytes`.
- `429` — rate limited; writes are capped per cell _and_ per IP.

---

## Account

### Who am I

```
GET /api/me
Authorization: Bearer clip_<key>
```

Response:

```json
{ "id": "uuid", "email": "you@example.com", "tier": "free" }
```

Fast, cheap — useful as a credential sanity check.

### Account details

```
GET /api/accounts/<id>
Authorization: Bearer clip_<key>
```

Response:

```json
{
    "id": "uuid",
    "email": "you@example.com",
    "tier": "free",
    "balance": 12,
    "keys": [{ "id": "uuid", "label": "default", "createdAt": "...", "active": true }]
}
```

Only readable for your own account.

### Mint an additional API key

```
POST /api/accounts/<id>/keys
Authorization: Bearer clip_<key>
Content-Type: application/json

{"label": "my-other-machine"}
```

Labels are 1–64 characters from `[A-Za-z0-9 ._-]` (letters, digits,
space, dot, underscore, dash). Leading and trailing whitespace is
trimmed before validation. 400 if the label is otherwise.

Response (201):

```json
{ "id": "uuid", "apiKey": "clip_…", "preview": "clip_…", "label": "my-other-machine" }
```

Save the `apiKey` — it's shown only once. The `preview` is the
display-safe partial returned for every key in the keys list.

### Revoke an API key

```
DELETE /api/accounts/<id>/keys/<key_id>
Authorization: Bearer clip_<key>
```

### List your clips

```
GET /api/accounts/<id>/clips
Authorization: Bearer clip_<key>
```

Returns enabled clips ordered newest-first.

### Delete a clip

```
DELETE /api/clips/<clip_id>
Authorization: Bearer clip_<key>
```

Returns `{"ok": true}`.

Deletion is permanent and there is no recovery window for it — a recovery
window applies to a clip that ran out of lifetime, not to one you deleted on
purpose.

A `500` here means the stored bytes could not be removed and **nothing was
deleted**: the clip is still yours, still listed and still reachable. Retry it.
(The same applies to `DELETE /api/folders/<folder_id>`, which is held back
whole if any file in it cannot be removed.)

### Sign out (browser session)

```
DELETE /api/sessions
```

Destroys the cookie session. Bearer-token clients don't need this —
revoke the key instead.

---

## Tiers and quotas

| Tier | Max body | Max TTL  | Quota               |
| ---- | -------- | -------- | ------------------- |
| free | 5 MB     | 16 hours | 15 clips per week   |
| paid | 25 MB    | 30 days  | per prepaid balance |

A `paid` account uploads against its `balance` (`clips_remaining`) —
one credit per upload, refunded automatically if storage fails.

| Package | Price | Credits |
| ------- | ----- | ------- |
| starter | $1    | 500     |
| pro     | $5    | 5,000   |
| bulk    | $25   | 50,000  |

(Stripe checkout integration is not live yet.)

---

## Errors

Every error response is JSON:

```json
{ "error": "human-readable description" }
```

| Status | Meaning                                                          |
| ------ | ---------------------------------------------------------------- |
| 400    | Empty body / malformed request                                   |
| 401    | Missing or invalid Bearer token                                  |
| 402    | Quota exhausted                                                  |
| 403    | Forbidden (e.g. accessing another account's data)                |
| 404    | Resource not found                                               |
| 409    | Cell version conflict, or an edit crossing type classes          |
| 410    | Clip disabled (lifetime ran out)                                 |
| 413    | Body too large for tier (or cell value over 1 KB)                |
| 429    | Rate limited — back off using `Retry-After`                      |
| 503    | Provider misconfigured (OAuth endpoints only)                    |

### Rate limits

Rate limits are per API key, sized by tier:

| Tier | Sustained | Burst |
| ---- | --------- | ----- |
| free | 1 req/s   | 5     |
| paid | 5 req/s   | 30    |

Account creation (`POST /api/accounts`), email login (`POST /api/sessions`),
and OAuth start (`GET /auth/<provider>`) are rate-limited per source IP
at roughly 1 request per 10 seconds with a burst of 3.

A `429` response includes a `Retry-After` header (in seconds). Back
off and retry — automated retries with no backoff will get keep
hitting the same wall.

---

## Examples

### Device flow, end-to-end

```bash
# 1. Agent: get a code
RESP=$(curl -sX POST https://thunderclip.com/auth/device \
  -H 'Content-Type: application/json' \
  -d '{"agent_label":"my-agent"}')

DEVICE_CODE=$(echo "$RESP" | jq -r .device_code)
USER_CODE=$(echo "$RESP" | jq -r .user_code)
VERIFY_URI=$(echo "$RESP" | jq -r .verification_uri)

echo "Operator: visit $VERIFY_URI and enter $USER_CODE"

# 2. Operator approves on Enter ID, which shows them a 3-char completion code.
COMPLETION_CODE=""   # ask the operator for it, then set it here

# 3. Agent: poll until success (send the completion code once you have it)
while true; do
  RESP=$(curl -sX POST https://thunderclip.com/auth/token \
    -H 'Content-Type: application/json' \
    -d "{\"grant_type\":\"urn:ietf:params:oauth:grant-type:device_code\",\"device_code\":\"$DEVICE_CODE\",\"completion_code\":\"$COMPLETION_CODE\"}")
  ERR=$(echo "$RESP" | jq -r '.error // empty')
  case "$ERR" in
    "")                       KEY=$(echo "$RESP" | jq -r .access_token); break ;;
    "authorization_pending")  sleep 5 ;;
    "completion_required")    read -rp "Enter the code Enter ID showed you: " COMPLETION_CODE ;;
    "slow_down")              sleep 10 ;;
    *)                        echo "Failed: $ERR"; exit 1 ;;
  esac
done

echo "Got key: $KEY"

# 4. Use it — a type is mandatory, so always send X-Extension (or ?ext)
curl -X POST https://thunderclip.com/api/ \
  -H "Authorization: Bearer $KEY" \
  -H "X-Extension: txt" \
  -d "hello from a freshly-onboarded agent"
```

### Upload examples

Every upload declares its type explicitly via `X-Extension` (or `?ext=`).
For arbitrary bytes use `bin`/`dat`/`raw`; for an unmapped MIME use
`X-Content-Type`.

```bash
# Plain text — url comes back on the shared inert host (th.clip.lc)
curl -X POST https://thunderclip.com/api/ \
  -H "Authorization: Bearer clip_…" \
  -H "X-Extension: txt" \
  -d "Hello world"

# Diff (syntax-highlighted)
git diff | curl -X POST https://thunderclip.com/api/?ext=diff \
  -H "Authorization: Bearer clip_…" \
  --data-binary @-

# JSON (tree view)
curl -X POST https://thunderclip.com/api/ \
  -H "Authorization: Bearer clip_…" \
  -H "X-Extension: json" \
  -d '{"key":"value","nested":{"a":1}}'

# HTML (served as a static page) — url comes back on its own
# isolated origin, e.g. https://az58.clip.lc/<id>
curl -X POST 'https://thunderclip.com/api/?ext=html' \
  -H "Authorization: Bearer clip_…" \
  -d '<h1>Hello</h1><p>This is a page.</p>'

# Raw bytes (binary) — explicit opt-in
curl -X POST https://thunderclip.com/api/ \
  -H "Authorization: Bearer clip_…" \
  -H "X-Extension: bin" \
  --data-binary @photo.png

# Unmapped MIME via the escape hatch
curl -X POST https://thunderclip.com/api/ \
  -H "Authorization: Bearer clip_…" \
  -H "X-Content-Type: application/vnd.foo+json" \
  -d '{"foo":1}'

# With a custom TTL (in minutes)
curl -X POST 'https://thunderclip.com/api/?ttl=60' \
  -H "Authorization: Bearer clip_…" \
  -H "X-Extension: txt" \
  -d "disabled in an hour"
```

### Edit examples

Overwrite a clip you own in place (keeps the same id/URL, spends a credit).

```bash
# Replace just the content (extension, type, and disable time are preserved)
curl -X PUT https://thunderclip.com/api/clips/<id> \
  -H "Authorization: Bearer clip_…" \
  -d "the new content"

# Push the disable time out only — no body, metadata-only edit
curl -X PUT 'https://thunderclip.com/api/clips/<id>?ttl=43200' \
  -H "Authorization: Bearer clip_…"

# Change content and type together (within the same type class —
# e.g. txt→md is fine, txt→html is a 409)
curl -X PUT 'https://thunderclip.com/api/clips/<id>?ext=md' \
  -H "Authorization: Bearer clip_…" \
  -d "# now markdown"

# Add a cell to a clip that doesn't have one (costs one extra credit).
# Works with or without a body; here it's cell-only.
curl -X PUT https://thunderclip.com/api/clips/<id> \
  -H "Authorization: Bearer clip_…" \
  -H "X-Cell: 1"
```

### Cell examples

Attach a cell at upload, then read and write it on the clip's own host (no
auth needed on the cell — the clip id is the secret). Use the `cellUrl` from
the upload/edit response; for an HTML clip it looks like
`https://<host>.clip.lc/c/<id>`.

```bash
# Upload with a cell (costs one extra credit); grab cellUrl from the response
curl -X POST 'https://thunderclip.com/api/?ext=html&cell=1' \
  -H "Authorization: Bearer clip_…" \
  -d '<h1>Guestbook</h1>'

# Read the cell
curl https://az58.clip.lc/c/7x4m9qk2trwbe
# {"value":"","version":0}

# Write with optimistic concurrency — send the version you expect to produce
curl -X PUT https://az58.clip.lc/c/7x4m9qk2trwbe \
  -H "Content-Type: application/json" \
  -d '{"value":"{\"visits\":1}","version":1}'
# {"value":"{\"visits\":1}","version":1}

# Force-overwrite, ignoring concurrency (omit version)
curl -X PUT https://az58.clip.lc/c/7x4m9qk2trwbe \
  -H "Content-Type: application/json" \
  -d '{"value":"{\"visits\":2}"}'
```
