# clip — 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://clip.rac.so/api` (canonical)
- `https://clip.rac.so/AGENTS.md` (alias, case-insensitive)
- `https://clip.rac.so/llms.txt` (curated index — points back here)

Base URL: `https://clip.rac.so`

## Quick orientation

- **Humans**: visit the website, sign in with GitHub or Google, 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/clip-mcp`](https://www.npmjs.com/package/@rac.so/clip-mcp)
  stdio server and clip becomes a native tool. 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. 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 ends up in the issued key's
label so the operator can recognise it later.

Response (200):

```json
{
    "device_code": "<opaque, ~43 chars, base64url>",
    "user_code": "ABCD-EFGH",
    "verification_uri": "https://clip.rac.so/device",
    "verification_uri_complete": "https://clip.rac.so/device?user_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`, types the `user_code`, picks a
sign-in provider, completes its flow, and lands on `/device/success`.
clip resolves the 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. (The operator is authorising an agent, not signing in.)

### 3. Agent: poll the token endpoint

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

{
  "grant_type":  "urn:ietf:params:oauth:grant-type:device_code",
  "device_code": "<the one from step 1>"
}
```

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 finished yet — keep polling.                                         |
| 400  | `{"error":"slow_down"}`                           | You polled before `interval` elapsed. Wait at least 5s before retrying.              |
| 400  | `{"error":"access_denied"}`                       | Operator clicked Cancel. Stop polling.                                               |
| 400  | `{"error":"expired_token"}`                       | TTL exceeded (10 min), already consumed, or unknown `device_code`. Stop; start over. |

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 clip 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),
clip ships an official stdio server that turns the most common
operations into native tools — no curl, no code generation, no
remembering the API shape.

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

Wired into Claude Desktop:

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

Tools exposed: `clip_upload`, `clip_edit`, `clip_list`, `clip_get`, `clip_delete`.
Auth is still your device-flow-minted API key — the server is a thin
passthrough. Source: [github.com/Racso/clip/tree/main/mcp](https://github.com/Racso/clip/tree/main/mcp).

---

## OAuth login (humans)

```
GET /auth/github
GET /auth/google
```

Top-level browser navigation only — these set a session cookie via
HttpOnly, scoped to clip.rac.so. The redirect_uri registered with
each provider is `https://clip.rac.so/auth/<provider>/callback`. After
a successful dance the browser lands on `/dashboard`; after a failure,
on `/login?auth_error=<reason>`.

OAuth is email-anchored: clip looks up accounts by the IdP-verified
email address, so signing in with GitHub and later signing in with
Google using the same email lands you in the same account.

---

## Upload

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

<raw body>
```

clip 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.

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.
- `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": "0aZ9pQ2rT4uV6wX8yB1cD3",
    "url": "https://clip.rac.so/view/0aZ9pQ2rT4uV6wX8yB1cD3",
    "contentType": "text/plain; charset=utf-8",
    "extension": "txt",
    "size": 42,
    "createdAt": "2026-05-24T12:00:00.000Z",
    "expiresAt": "2026-05-25T12:00:00.000Z"
}
```

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.

### 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`           | `text/html`        | Served as-is (static page)                  |
| `csv` / `tsv`    | `text/csv` / TSV   | Sortable table (`/view` only)               |
| `xml`            | `application/xml`  | Collapsible tree (`/view` only)             |
| audio (see below) | `audio/*`         | Player page with waveform (`/view` only)    |
| video (see below) | `video/*`         | Player page (`/view` only)                  |
| 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. `/raw/` never highlights.

Audio extensions: `mp3`, `wav`, `ogg`/`oga`, `m4a`, `aac`, `flac`, `opus`,
`weba`. Video: `mp4`/`m4v`, `webm`, `mov`, `mkv`. On `/view/` these render a
player page (audio includes a click-to-seek waveform); `/raw/` always serves
the bytes with the resolved `audio/*` / `video/*` MIME.

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/expired 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 expiry. 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 the expiry 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`.

Response (200) has the same shape as an upload. `createdAt` is unchanged;
`expiresAt` 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

Two paths with explicit intent:

```
GET /view/<id>     # render diff/markdown/JSON/code as HTML; otherwise raw
GET /raw/<id>      # always raw bytes with stored content-type
GET /c/<id>        # legacy alias for /raw/<id>
```

Use `/view/` when sharing with a human (browsers get the renderer where
we have one, native rendering where we don't). Use `/raw/` when you
want parseable bytes: a JSON clip comes back as `application/json`, a
diff as `text/x-diff`, an image as `image/png`, etc. `POST /` returns
a `/view/<id>` URL by default; swap the prefix to consume the bytes.

Both paths accept an `<id>.<ext>` suffix that overrides the stored
content-type for that response — useful when a clip was uploaded
without a precise type (e.g. quick-paste sends `text/plain`).

- `/view/abc.diff` — force the diff renderer regardless of stored type
- `/raw/abc.html` — force `Content-Type: text/html`
- `/raw/abc.txt` — view HTML source as plain text in a browser

Unknown extensions fall back to the stored content-type.

- `404` — Clip not found.
- `410` — Clip expired (rendered as a friendly page).

---

## 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 /` to attach a cell at creation, or to a `PUT /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.

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

### Read

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

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 has expired.

### Write

```
PUT /c/<id>/cell
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 expired.
- `413` — value larger than 1 KB.

---

## 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 active (un-expired) clips ordered newest-first.

### Delete a clip

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

Returns `{"ok": true}`.

### 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 (body has current state)    |
| 410    | Clip expired                                      |
| 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://clip.rac.so/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)

echo "Operator: visit https://clip.rac.so/device and enter $USER_CODE"

# 2. Operator finishes in browser

# 3. Agent: poll until success
while true; do
  RESP=$(curl -sX POST https://clip.rac.so/auth/token \
    -H 'Content-Type: application/json' \
    -d "{\"grant_type\":\"urn:ietf:params:oauth:grant-type:device_code\",\"device_code\":\"$DEVICE_CODE\"}")
  ERR=$(echo "$RESP" | jq -r '.error // empty')
  case "$ERR" in
    "")                       KEY=$(echo "$RESP" | jq -r .access_token); break ;;
    "authorization_pending")  sleep 5 ;;
    "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://clip.rac.so/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
curl -X POST https://clip.rac.so/api/ \
  -H "Authorization: Bearer clip_…" \
  -H "X-Extension: txt" \
  -d "Hello world"

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

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

# HTML (served as a static page)
curl -X POST 'https://clip.rac.so/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://clip.rac.so/api/ \
  -H "Authorization: Bearer clip_…" \
  -H "X-Extension: bin" \
  --data-binary @photo.png

# Unmapped MIME via the escape hatch
curl -X POST https://clip.rac.so/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://clip.rac.so/api/?ttl=60' \
  -H "Authorization: Bearer clip_…" \
  -H "X-Extension: txt" \
  -d "expires 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 expiry are preserved)
curl -X PUT https://clip.rac.so/api/clips/<id> \
  -H "Authorization: Bearer clip_…" \
  -d "the new content"

# Extend the expiry only — no body, metadata-only edit
curl -X PUT 'https://clip.rac.so/api/clips/<id>?ttl=43200' \
  -H "Authorization: Bearer clip_…"

# Change content and type together
curl -X PUT 'https://clip.rac.so/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://clip.rac.so/api/clips/<id> \
  -H "Authorization: Bearer clip_…" \
  -H "X-Cell: 1"
```

### Cell examples

Attach a cell at upload, then read and write it (no auth needed on the cell — the
clip id is the secret).

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

# Read the cell
curl https://clip.rac.so/c/<id>/cell
# {"value":"","version":0}

# Write with optimistic concurrency — send the version you expect to produce
curl -X PUT https://clip.rac.so/c/<id>/cell \
  -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://clip.rac.so/c/<id>/cell \
  -H "Content-Type: application/json" \
  -d '{"value":"{\"visits\":2}"}'
```
