# API Reference

Base URL: `https://agentpad.cc`

Document content and collaboration routes require `Authorization: Bearer <api_key>` plus the relevant document role, except configured public sample reads and explicit general-link viewer reads with an owner-generated `accessToken`. Public catalog and status routes document their own auth requirements below.

## Public macOS releases

`GET`/`HEAD /updates/macos/appcast.xml` serves the uncached signed Sparkle feed.
Immutable release objects are public only when their path exactly matches
`/updates/macos/releases/<run-id>-<attempt>/AgentPad-<version>-<build>.zip` or
the corresponding `.dmg`. ZIPs use `application/zip`; DMGs use
`application/x-apple-diskimage`.

`GET`/`HEAD /download/macos` derives the manual download from the newest valid
appcast item. The item must pair a workflow-shaped ZIP enclosure with the
matching DMG `<link>` (same run attempt, version, and build); otherwise it is
ignored and the route fails closed when no valid item remains. During the
one-time rollout from the former DMG-based feed, a workflow-shaped DMG
enclosure with a matching `sparkle:version` and no item link remains accepted
so deploying the ZIP-capable Worker cannot interrupt the existing download.

## Native offline replay

`POST /api/docs`, `POST /api/docs/:id/comments`, and `POST /api/docs/:id/comments/:cid/reply` accept an optional `Idempotency-Key` header. `POST /api/docs/:id/images` strongly recommends it so an accepted object write is published atomically with a replayable response. Legacy image clients that omit the header receive a per-request compatibility reservation and retain their prior non-deduplicating behavior; new clients should send one opaque key per queued mutation and retain it while retrying that exact mutation. Keys are scoped to the authenticated user, operation, and target resource; they are 1–200 characters of letters, digits, `.`, `_`, `:`, or `-`.

The worker retains successful responses for 90 days. An exact replay returns the original success status and body. Reusing a key with a different payload returns `409`.

At most one request owns a pending key at a time. A duplicate while that request is still running returns `425 Too Early` with `Retry-After: 2`; retain the same key and retry after that delay. If the owner crashes, its lease expires and a later retry recovers the reservation with the original stable document, comment, reply, or image identifier rather than creating another resource. A stale owner cannot record a response after another retry has recovered the lease. Non-image requests without the header retain their existing behavior; image uploads use the compatibility reservation described above.

## Auth

### Request Email Sign-In

```bash
curl -X POST https://agentpad.cc/api/auth/request-code \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com"}'
```

**Request body:**

| Field   | Type   | Required | Description        |
|---------|--------|----------|--------------------|
| `email` | string | yes      | User email address |
| `client` | string | no | Set to `browser-login-v2` only for the first-party link-first browser contract. |
| `returnHash` | string | no | Browser v2 only. Safe destination limited to `#/` or `#/d/<id>` with a non-secret document target. Invalid values become `#/`. |
| `generalAccessToken` | string | no | Browser v2 only. When signing in from an owner-enabled general-link document, the SPA may submit the current capability separately from `returnHash`. The challenge stores only its SHA-256 hash and matching document id. |

**Response 200:**
```json
{
  "ok": true,
  "message": "Code sent to your email",
  "email": "user@example.com"
}
```

The user receives one contiguous 8-digit code, for example `84729103`. It expires in 15 minutes. `email` is the normalized destination address that must be sent back to `verify-code`. Codes sent before the contiguous-code rollout and pasted variants with a hyphen or spaces remain accepted.

An exact same-origin first-party request with `"client":"browser-login-v2"`
instead sends a primary **Sign in to AgentPad** link and a secondary contiguous
6-digit fallback code:

```json
{
  "ok": true,
  "message": "Sign-in email sent",
  "email": "user@example.com",
  "method": "link",
  "challengeId": "c4213884-8bcd-4f87-974d-663fd195cd01",
  "codeLength": 6,
  "expiresIn": 900
}
```

The raw link token is carried only after the SPA URL fragment and stored by the
server only as a SHA-256 hash. The fallback code is stored only as a keyed
verifier. Link and code expire together after 15 minutes, are mutually
single-use, and a newer browser request invalidates older active browser
challenges for that email. Calls without the exact browser opt-in retain the
legacy response and eight-digit email above.

When browser sign-in begins from a general-link document, the capability is
sent separately from the safe return path. The challenge persists only its
SHA-256 hash and the bound document id; the raw capability is absent from the
email, response, return URL, and challenge row. After either link or fallback
verification resolves the user, the Worker compares that hash with the
document's current server-side general-access token. A match on an enabled role
creates the existing token-specific `document_general_access_claims` row, so a
new email tab can return to the document without carrying the capability.
Rotation or disabling prevents the claim, and the sign-in response never
returns the document token.

---

### Verify Code

```bash
curl -X POST https://agentpad.cc/api/auth/verify-code \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","code":"84729103"}'
```

**Request body:**

| Field  | Type   | Required | Description              |
|--------|--------|----------|--------------------------|
| `email`| string | yes      | Email used in request-code |
| `code` | string | yes      | Legacy flow: 8 digits; `84729103`, `8472-9103`, and pasted variants with spaces are accepted. Browser v2 fallback: exactly 6 contiguous digits. |
| `client` | string | no | `browser-session-v1` preserves the legacy SPA cookie-session contract; `browser-login-v2` submits the new challenge-bound browser fallback. |
| `challengeId` | string | no | Required with `client: browser-login-v2`; identifies the browser challenge whose exact 6-digit fallback is being submitted. |

**Response 200:**
```json
{
  "apiKey": "0123456789abcdef0123456789abcdef",
  "email": "user@example.com",
  "accountId": "4ea78e16-942f-45d7-976a-5bbb2448310c",
  "isNew": true,
  "welcomeDocId": "abc12345"
}
```

For non-browser clients, `apiKey` is the user's durable AgentPad API key. Use it as `Authorization: Bearer 0123456789abcdef0123456789abcdef` on authenticated REST and MCP endpoints. The bearer key is a 32-character hex string. The server stores a one-way verifier for authentication and an encrypted copy only for signed-in user copy/install UX.

First-party web verification returns `{"browserSession":true,...}` and sets a revocable `Secure; HttpOnly; SameSite=Lax` browser-session cookie only when the request is same-origin and explicitly includes `"client":"browser-session-v1"`. Without that opt-in—even from a browser—the endpoint retains the legacy durable-key response contract. Deploy the Worker before the SPA as described in `deployment.md`. The browser-session response never returns or resets the durable agent API key. The SPA keeps only a non-secret signed-in marker, email, and stable `accountId` in local storage, restores them from `GET /api/auth/session` when needed, and uses the cookie for authenticated same-origin requests. The account id scopes durable local drafts across account switches; it is replaced when a deleted email signs up as a new account. Cookie-authenticated reads reject browser requests whose `Sec-Fetch-Site` is `same-site` or `cross-site`; unsafe methods additionally require an exact same-origin `Origin` header. Existing REST, MCP, CLI, iOS, and older SPA callers retain the bearer response contract above.

The link-first SPA instead sends the exact six-digit fallback with
`"client":"browser-login-v2"` and the `challengeId` returned by
`request-code`. It receives the same browser-session response plus the
server-bound `returnHash`. Five incorrect fallback attempts invalidate the
challenge. It does not accept separators and does not affect legacy eight-digit
verification.

If a valid code was consumed concurrently with deletion of that account, or
the newly created account was superseded before its API-key record could be
finalized, verification returns `409` with
`code: "signin_superseded"`. No session is minted. Request a new code and
retry; the consumed proof cannot recreate the deleted account.

Existing non-browser clients whose encrypted copy is unavailable may receive:

```json
{
  "apiKeyUnavailable": true,
  "apiKeyResetToken": "eyJraW5kIjoiYXBpLWtleS1yZXNldCIs...",
  "email": "user@example.com",
  "accountId": "4ea78e16-942f-45d7-976a-5bbb2448310c",
  "isNew": false
}
```

`apiKeyResetToken` is short-lived, single-use, and only accepted by `POST /api/api-key/rotate`; it cannot read or display the current key. First-party browser login does not enter this recovery flow and does not disrupt installed agents.

The response also sets an HttpOnly `agentpad_human_control` cookie for first-party browser controls such as pausing or resuming agent edits. It is not exposed to JavaScript, REST clients, or agents; it is not a document access grant and does not replace the bearer API key.

`isNew` is `true` when the email had no prior account. `welcomeDocId` is returned for newly created accounts when the welcome document is seeded successfully.

### Browser Session

`GET /api/auth/session` returns the signed-in email and stable `accountId` for a valid bearer or browser session. `POST /api/auth/logout` revokes the current browser session and clears its HttpOnly cookies. Logout requires an exact same-origin browser request.

### Verify Browser Sign-In Link

```js
await fetch('/api/auth/verify-link', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  credentials: 'same-origin',
  body: JSON.stringify({
    token: 'opaque-token-captured-from-the-url-fragment',
    client: 'browser-login-v2'
  })
})
```

This endpoint accepts only an exact same-origin browser POST; there is no GET
authentication route. It applies durable IP rate limiting, consumes one valid
unexpired challenge atomically, and returns the existing browser-session
contract without a durable API key:

```json
{
  "browserSession": true,
  "email": "user@example.com",
  "accountId": "4ea78e16-942f-45d7-976a-5bbb2448310c",
  "isNew": false,
  "returnHash": "#/d/abc12345"
}
```

Malformed, expired, consumed, and superseded links return `401` with
`code: "invalid_or_expired_link"`. If the browser is already signed into a
different email, the first exchange returns `409` with
`code: "account_switch_confirmation_required"` without consuming the link.
Repeating the POST with `confirmAccountSwitch: true` revokes and replaces the
presented browser session only after sign-in completes. If account deletion
supersedes the consumed challenge, the endpoint instead returns `409` with
`code: "signin_superseded"`; the existing browser session remains valid and
the user must request a new link.

---

## Agent API Key

### Get API Key Status

```bash
curl https://agentpad.cc/api/api-key \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Response 200:**
```json
{
  "email": "user@example.com",
  "accountId": "4ea78e16-942f-45d7-976a-5bbb2448310c",
  "hasApiKey": true,
  "recoverable": true,
  "apiKey": "0123456789abcdef0123456789abcdef",
  "updatedAt": "2026-06-03T00:00:00.000Z"
}
```

`accountId` is the stable account-instance identifier used by native and web clients to partition offline data. `apiKey` is omitted when the account has a valid hashed key but no encrypted copy.

### Remember Current Key

```bash
curl -X POST https://agentpad.cc/api/api-key/remember \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"apiKey":"0123456789abcdef0123456789abcdef"}'
```

Stores an encrypted copy of the existing key only after verifying it belongs to the authenticated user.

### Rotate API Key

```bash
curl -X POST https://agentpad.cc/api/api-key/rotate \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

Returns a replacement key and invalidates the previous key. Installed agents must update `~/.agentpad/token`.

For existing accounts whose raw key is unrecoverable after email-code sign-in, the browser may use the short-lived reset token returned by `/api/auth/verify-code`:

```bash
curl -X POST https://agentpad.cc/api/api-key/rotate \
  -H "Content-Type: application/json" \
  -d '{"resetToken":"eyJraW5kIjoiYXBpLWtleS1yZXNldCIs..."}'
```

That reset token is single-use and only rotates the key; it does not authorize `GET /api/api-key` or any other authenticated API.

---

## Account

### Delete Account

Account deletion is a two-step operation. First, exchange authenticated inbox
verification for a short-lived deletion capability:

```bash
RECEIPT="$(openssl rand -hex 32)"
curl -X POST https://agentpad.cc/api/account/deletion-intent \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $RECEIPT" \
  -d '{"confirmEmail":"you@example.com","verificationCode":"12345678"}'
```

```json
{
  "state": "prepared",
  "expiresAt": "2026-07-29T12:15:00.000Z"
}
```

The prepared capability expires after 15 minutes. Commit the deletion with the
same opaque receipt. This request intentionally needs no bearer token or body:

```bash
curl -X DELETE https://agentpad.cc/api/account \
  -H "Idempotency-Key: $RECEIPT"
```

```json
{
  "deleted": true,
  "email": "you@example.com",
  "documentsDeleted": 12,
  "deferredDocumentCleanup": 0
}
```

Permanently deletes the account bound to the prepared receipt. There is no
deactivate or disable variant and no undo. The preparation request accepts a
bearer API key or a first-party browser session; cross-site cookie use is
rejected with `403`.

`confirmEmail` is required and must equal the authenticated account's email
address (compared case- and whitespace-insensitively). A missing or
mismatched value returns `400` and changes nothing. It is sent only while
preparing the intent, never with `DELETE /api/account`.

`verificationCode` must be a fresh, single-use code requested through
`POST /api/auth/request-code`. This inbox check is required even when the
caller has a valid long-lived API key. Preparing the intent consumes the code
and stores only the receipt hash plus its temporary account binding.

`Idempotency-Key` must be a new high-entropy token between 32 and 128
URL-safe characters. Retain it until local cleanup finishes. The raw value is
a single-purpose deletion capability: it can commit only the account that was
bound during preparation, and it can be retried after an ambiguous response
without resending credentials or the consumed inbox code.

If a committed response was lost, retrying `DELETE` returns the distinct
identity-free replay shape `{"deleted":true,"replayed":true}`. The committed
receipt is deliberately detached from the erased email, and the server omits
`email`, `documentsDeleted`, and `deferredDocumentCleanup` rather than
fabricating cleanup counts. Use deletion status as the authoritative
completion signal; the first observed completion response is the only source
of informational cleanup counts.

Check its state without authentication:

```bash
curl -X POST https://agentpad.cc/api/account/deletion-status \
  -H "Idempotency-Key: $RECEIPT"
```

Browsers recovering a receipt written before stable local account scopes were
required may also send `X-AgentPad-Account-Scope: account:<uuid>`. For receipts
prepared by a current Worker, a committed response then includes
`"matchesAccountScope": true|false`. This compares a receipt-bound one-way hash;
the deleted account ID is neither returned nor retained directly. Older
committed receipts omit the field, so clients must treat the stable local scope
as ambiguous and purge only legacy/unattributed data.

The status response is one of:

- `200 {"state":"prepared","deleted":false,"expiresAt":"..."}` — the live
  account is unchanged and `DELETE /api/account` may be retried.
- `200 {"state":"committed","deleted":true}` — deletion committed; local
  account data may be purged.
- `404 {"state":"unknown","deleted":false}` — no intent was prepared for this
  receipt.
- `410 {"state":"expired","deleted":false}` — the prepared intent expired
  without deleting the account.

The server stores a SHA-256 hash of the opaque receipt and an unlinkable,
receipt-bound hash used only for the optional local-scope comparison. Once
deletion commits, the receipt is detached from the erased email and raw account
ID. The unauthenticated status and commit endpoints share a Durable Object-backed limit
of 60 requests per 60 seconds per caller IP. A limited
request returns `429` with `Retry-After` and `retryAfter` before querying the
receipt database.

During the migration-first production rollout,
`POST /api/account/deletion-intent` returns
`503 ACCOUNT_DELETION_ROLLOUT_PENDING` until
`0035-enable-account-deletion.sql` is applied. Receipt status and commit are
not gated, so pausing a rollout cannot strand a capability that was already
prepared. Enabling the rollout also requires a verified full R2 metadata
sweep and a permanent `img/` object-create notification consumer. The latter
deletes late objects whose document no longer exists and strips legacy
`uploadedBy` email metadata from surviving pre-deploy uploads that finish
after the one-time scan; failed events remain retryable or in the dedicated
DLQ rather than becoming undiscoverable. The DLQ has its own consumer and
redrives exhausted failures to the primary queue after a delay. A permanent
multi-page, cursor-based `actions:*` KV sweep provides the equivalent fence
for late legacy action writes.

Before the irreversible database transaction, deletion invalidates cached
avatars and every admin overview that may contain the account email. If that
KV cleanup cannot complete, the endpoint returns
`503 ACCOUNT_DELETION_CACHE_CLEANUP_FAILED`; no database changes are committed
and the same prepared receipt may be retried. Avatar cache keys are one-way
hashes, so a later public avatar request cannot recreate a raw email key.

What deletion removes:

- **Every document you own** — content, version history, comments,
  suggestions, review artifacts, uploaded images, and all access to them.
  Collaborators lose access as soon as the request returns, including
  documents shared by link.
- **Your account** — email address, API key, encrypted key copy, browser
  sessions, MCP OAuth grants, favorites, visit history, demo-workspace
  state, and any pending sign-in codes or links.

What deletion keeps, with your identity removed: comments, versions,
suggestions, review artifacts, and edit records you left on documents owned
by **other** people. Deleting those rows would remove other users' content
alongside yours, so the rows survive with the author shown as
`Deleted account` (or `Agent of deleted account`) and your email erased.
Access-event rows on those documents keep their audit shape with your user id
hidden immediately and physically scrubbed by bounded scheduled cleanup.

`documentsDeleted` counts owned documents accepted for permanent deletion.
`deferredDocumentCleanup` is normally `0`; a non-zero value means the account
and identifying records are already gone, while detached document rows and
content-store objects for that many documents remain queued for the scheduled
purge. The purge normally runs hourly, is bounded to protect the service, and
retries transient database, KV, or R2 failures until cleanup succeeds.
Provider-side MCP OAuth grant erasure uses a separate durable retry queue.
Tokens for the deleted account are rejected immediately by the authoritative
account check even if that provider cleanup is temporarily unavailable.

Signing in again with the same email address creates a new, empty account.
Nothing from the deleted account is recoverable from it.

---

## Document access audit

### List document content access events

```bash
curl "https://agentpad.cc/api/audit/document-access?since=2026-08-01T00:00:00Z&limit=100" \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

`GET /api/audit/document-access` returns the authenticated account owner's
append-only ledger for documents they own. It is account-wide: a collaborator
cannot use it to inspect another owner's audit history, and events remain
available after document purge. Deleting an actor account atomically commits a
marker that makes `actorUserId` return as `null`; bounded cleanup later removes
the stored identity. Actor email addresses are never exposed by this endpoint.
Deleting the
document owner's account deletes that owner's ledger asynchronously.

Optional filters are `docId`, `action`, `outcome=allowed|denied`, `since` as an
ISO timestamp, and `limit` from 1 to 200 (default 100). Pass the opaque
`nextCursor` value as `cursor` for the next page. Pagination is ordered by the
compound `(createdAt, id)` key, so events sharing a timestamp are not skipped.

```json
{
  "events": [
    {
      "id": "32a8c057-5e88-4ac7-a228-499370928572",
      "docId": "abc12345",
      "actorUserId": "57c2a26b-24fe-4de2-bdcc-a5d26332d695",
      "action": "document_read",
      "outcome": "allowed",
      "surface": "rest",
      "authMethod": "bearer",
      "accessSource": "grant",
      "accessRole": "viewer",
      "requestId": "444710f0-443f-4af0-9713-dfe1befa3a94",
      "ipHash": "f0b0...",
      "clientContext": {},
      "createdAt": "2026-08-03T15:10:00.000Z"
    }
  ],
  "nextCursor": null
}
```

The ledger covers successful and known-document denied disclosures through
REST and MCP, including full reads, body-search results, exports, comments,
history, review/suggestion content, image bytes and tokens, access settings,
target excerpts, and legacy action reads. `GET /api/docs/:id/head` is excluded
because it returns metadata only. Content-bearing requests fail closed: if the
audit insert does not commit, the Worker does not return content that was
already stored before the request. Pure create echoes made only from material
submitted by that same caller are excluded because they disclose no existing
document data and blocking them after commit would encourage duplicate retries.
PDF `HEAD` filename disclosures and denied reads of a known trashed document
are logged; truly missing document IDs remain unlogged to avoid creating an
enumeration oracle.
Image-heavy pages are represented by one `image_read` owner-ledger event per
viewer, document, and access path within a 120-second view window. The
database serializes that coalescing before charging capacity, so parallel
image requests cannot exhaust the viewer's audit budget; the retained
Cloudflare structured stream still records each individual image response.
Structured access events include `ledgerStatus`: `persisted`, `coalesced`, or
`failed`. A denied request keeps its normal hidden response even if ledger
persistence fails, but remains visible in the retained structured stream with
`ledgerStatus: "failed"`.

Audit details never contain document content, titles, search queries,
snippets, access tokens, or email addresses. The optional `ipHash` is an HMAC
correlator enabled by the `AUDIT_IP_HASH_SECRET` Worker secret; raw IP addresses
are never stored. `requestId` is generated by the Worker and caller-provided
request IDs are ignored. The endpoint is served with `Cache-Control: private,
no-store`.

Migrations `0036-document-content-audit.sql` through
`0039-dedicated-audit-writer.sql` must be applied and their privileges verified
before deploying code that emits these events. The ordinary application role
has only `SELECT` on the ledger; direct `INSERT`, `UPDATE`, `DELETE`, and
`TRUNCATE` plus execution of the insertion function are revoked. The separate
`audit_writer` login has no direct table or sequence privileges and may execute
only the atomic, fixed-policy `SECURITY DEFINER` function that reserves capacity
and inserts the exact batch in one transaction. The Worker supplies it through
`AUDIT_DATABASE_URL`, never through `DATABASE_URL`. There is deliberately no
document foreign key, so a
document purge cannot erase its audit evidence. A fixed `SECURITY DEFINER`
retention function removes at most 10,000 events older than 365 days per
hourly retention run; the application role receives no general delete right.
Account deletion first commits an opaque cleanup pointer. Owner readback joins
that marker and masks the actor identity immediately; bounded hourly batches
then delete events owned by that account and null its stored actor identity.
The account request therefore does not synchronously cascade through the
retained ledger. The application role may read the marker solely for masking,
but cannot insert, update, delete, truncate, or grant references on the queue.
Every REST and MCP batch consumes its exact row count in database-serialized
minute, hour, and day windows. Protected-owner and authenticated budgets are
scoped to the actor, while anonymous budgets are scoped to the keyed source;
one caller therefore cannot consume another caller's fail-closed capacity. A
keyed HMAC derived from `AUDIT_IP_HASH_SECRET` scopes per-caller daily budgets
without persisting a brute-forceable IP identifier. Database credentials are
never used as HMAC key material. Ordinary document traffic that
returns no stored content does not consume this budget.
Database administrators remain technically capable of
altering infrastructure, so provider access removal and retained Cloudflare
structured logs are part of the control; this application ledger alone is not
proof against a compromised database administrator.

---

## Documents

### List Documents

```bash
curl https://agentpad.cc/api/docs \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** required

Responses use `Cache-Control: private, no-store`; clients must revalidate the
workspace catalog instead of relying on a shared or persistent HTTP cache.

**Query params (optional):**

| Param    | Type   | Description                                                                 |
|----------|--------|-----------------------------------------------------------------------------|
| `q`      | string | Query string. Default scope is title-only (case-insensitive substring); set `search=body` to run the query through the full-text index covering both title and content. Trimmed, max 200 chars. |
| `sort`   | string | One of `updated` (default), `created`, `title-asc`, `title-desc`. Unknown values fall back to `updated`. |
| `tag`    | string | Repeatable. Filter to docs that carry **all** of the specified tags (AND). Up to 10 occurrences are accepted; requests with more than 10 return `400`. Tags are normalized server-side (lowercase + hyphenated) before matching, so casing typos in the URL are forgiven. |
| `search` | string | `title` (default) or `body`. `body` runs `q` through the full-text search index covering title + content; the response gains a `snippet` field with the matched fragment wrapped in `<mark>` tags. |
| `offset` | integer | Body-search continuation offset, from `0` through `9800`. Use the prior response's `nextOffset`; invalid values return `400`. Ignored for title-only catalogs. |

**Examples:**

```bash
# Title substring (default — fast, ILIKE)
curl "https://agentpad.cc/api/docs?q=roadmap" -H "Authorization: Bearer ..."

# Full-text body search (returns highlighted snippets)
curl "https://agentpad.cc/api/docs?q=auth%20token&search=body" -H "Authorization: Bearer ..."

# Filter by tag (single)
curl "https://agentpad.cc/api/docs?tag=design" -H "Authorization: Bearer ..."

# Filter by multiple tags (AND, match all) plus a body search
curl "https://agentpad.cc/api/docs?tag=design&tag=draft&q=onboarding&search=body" -H "Authorization: Bearer ..."

# All docs sorted alphabetically
curl "https://agentpad.cc/api/docs?sort=title-asc" -H "Authorization: Bearer ..."
```

**Response 200:**

When `search=body` matched the document body, response items include a `snippet`. On a default title-scope listing, `snippet` is omitted entirely:
Each body-search page returns at most 200 documents so that page and its exact
audit batch commit atomically. When more matches remain, `nextOffset` contains
the value to pass as `offset` on the next request; the final page returns
`nextOffset: null`. If matches remain beyond the final bounded
`offset=9800` page, the response instead returns `nextOffset: null` with
`searchTruncated: true`; clients must tell the user to refine the query rather
than presenting that page as complete. Ordinary title-scope catalogs are not
subject to this audit batch bound and omit both continuation fields. Owned and
visited body-search catalogs each have their own bounded continuation range and
audit-capacity pool, so advancing one does not invalidate the other's token.

```jsonc
// Default GET /api/docs (no body search) — snippet absent
{
  "docs": [
    {
      "id": "abc12345",
      "title": "Project Roadmap",
      "icon": "rocket",
      "tags": ["design", "q2"],
      "favoritedAt": "2026-04-25T12:00:00Z",
      "createdAt": "2026-04-06T10:00:00Z",
      "updatedAt": "2026-04-06T11:30:00Z",
      "revision": 14,
      "agentsPaused": false,
      "pendingAccessRequests": 0
    }
  ]
}
```

```jsonc
// GET /api/docs?q=auth&search=body — snippet present on body matches
{
  "docs": [
    {
      "id": "abc12345",
      "title": "Project Roadmap",
      // …all the fields above…
      "snippet": "…we will ship the new <mark>auth token</mark> rotation flow…"
    }
  ],
  "nextOffset": null,
  "searchTruncated": false
}
```

Field notes:

- `tags` is always present on docs, an empty array when the doc has no tags.
- `snippet` is **only** present when `search=body` produced a body match. The worker sanitizes it before returning: everything is HTML-escaped, then the configured `<mark>` / `</mark>` wrappers are re-injected. So clients can safely render the field as innerHTML without further sanitization.
- `favoritedAt` is the ISO timestamp when the authenticated caller bookmarked the doc, or `null` if it isn't favorited by that caller. The home screen pins favorited owned and shared docs in a "Favorites" section.
- `pendingAccessRequests` is owner-only on `GET /api/docs` and lets the browser surface pending access requests before the owner opens the Share dialog.
- Shared docs include `accessSource` (`grant`, `domain`, `general`, or `sample`) and `role` (`viewer`, `commenter`, or `editor`) so clients can label effective capabilities.

> **Scope note:** `favoritedAt` is caller-scoped. Owners and shared readers each have private favorite state for the same document; callers never receive another user's bookmark timestamp. Anonymous reads omit favorite state.

`GET /api/docs/visited` is the browser "Shared with me" surface. It includes readable non-owned docs even before the caller has visited them, so an explicit grant or eligible same-domain share is discoverable without already knowing the link. General-link docs are included only after the signed-in caller has successfully opened the document with the owner-generated `accessToken`; that creates a token-specific claim while general-link access remains enabled.
Its responses also use `Cache-Control: private, no-store`.

---

### Unified Workspace Search

```bash
curl "https://agentpad.cc/api/search/docs?q=roadmap&scope=all&limit=50" \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

Queries of three or more characters use an accent-folded prefix index across
titles, tags, and document bodies (for example, `lau` matches `Launch` and
`cafe` matches `café`). Candidate ranking and the result cap run in PostgreSQL
before snippets are generated. During a staged schema rollout the endpoint
falls back to a bounded compatibility query; deployment ordering and the
`searchIndex.ready` health field are documented in `deployment.md`.

**Auth:** required

This read-only endpoint merges owned and currently readable shared documents
into one deduplicated result list. It preserves normal/demo workspace
isolation, excludes trash and revoked documents, and returns the same document
summary fields as the existing list APIs.

| Param | Type | Description |
|---|---|---|
| `q` | string | Required non-empty query, trimmed and capped at 200 characters. |
| `scope` | string | `all` (default) or `metadata`. Queries shorter than three normalized characters always use metadata scope. |
| `limit` | integer | Default 50, minimum 1, maximum 200. |

`metadata` searches title and tags without scanning bodies. `all` uses the
trigger-maintained GIN index over title, tags, and the coherent `body_text`
search replica. Body matches may include a bounded **plain-text** `snippet`;
the endpoint never returns markup that a native client must parse.

The existing `GET /api/docs?q=...` and `GET /api/docs/visited?q=...` surfaces
remain available during native-client rollout. `agentpad_search` uses the same
indexed document query while retaining its exact Company Knowledge response
shape.

---

### Document Suggestions

```bash
curl "https://agentpad.cc/api/docs/suggestions?q=road&limit=8" \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** required

**Query params:**

| Param   | Type   | Description |
|---------|--------|-------------|
| `q`     | string | Required title substring. Empty or whitespace-only queries return an empty list. Trimmed, max 200 chars. |
| `limit` | number | Optional result cap. Defaults to `8`, clamped to `1..20`. |

**Response 200:**

```json
{
  "docs": [
    { "id": "abc12345", "title": "Project Roadmap" }
  ]
}
```

This endpoint is for cross-document autocomplete. It returns only readable live documents, with owned matches first and explicitly granted or eligible same-domain shared matches after that. Results are title-only and de-duped. It never returns content, snippets, tags, pending access request counts, comments, history, visits, roles, or other document metadata. Visit history is not an access source.

---

### Create Document

```bash
curl -X POST https://agentpad.cc/api/docs \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"title":"Project Roadmap","content":"# Roadmap\n\n## Q2 Goals\n\n- Ship v2"}'
```

**Request body:**

| Field    | Type   | Required | Description                      |
|----------|--------|----------|----------------------------------|
| `title`  | string | no       | Document title (default: "Untitled") |
| `content`| string | no       | Initial markdown content (default: empty) |

**Response 201:**
```json
{
  "id": "abc12345",
  "revision": 0,
  "url": "https://agentpad.cc/d/abc12345"
}
```

The `url` uses the `/d/:id` shareable preview bridge. It returns server-rendered link metadata and immediately opens the existing `/#/d/:id` SPA route in browsers. Existing hash-route URLs remain supported. The URL and document id are addresses, not permission. New documents are restricted by default; owners grant access explicitly, enable eligible same-domain viewing, or enable general-link access with a separate `generalAccessToken`.

The bridge never includes document body text. Public sample documents may include the title in preview metadata. Private, missing, deleted, revoked, and general-link documents all receive the same generic metadata. General-link browser URLs keep the credential after `#` (for example, `/d/abc12345#accessToken=...`) so it is available to the SPA without being sent to the Worker or edge logs. Target parameters remain in the normal query string because they are non-authorizing. Tokens and targets are omitted from Open Graph URLs and canonical metadata.

Installed AgentPad apps on iPhone and iPad claim canonical `/d/*` URLs through
`GET /.well-known/apple-app-site-association`. That endpoint is public,
dependency-free JSON and claims no API, OAuth, developer-doc, or homepage paths.
The native client preserves the same authorization contract: fragment tokens
are forwarded as `accessToken` only to the document and target APIs.

---

### Duplicate Document

```bash
curl -X POST https://agentpad.cc/api/docs/abc12345/duplicate \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"title":"Copy of Project Roadmap","maintainAccess":true}'
```

**Auth:** Required. The caller must be able to read the source document. The duplicate is owned by the caller.

**Request body:** Optional. Omit it or send `{}` to use the default title and keep the copy private.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | no | New title. Defaults to `Copy of <source title>`. |
| `maintainAccess` | boolean | no | Owner-only. When `true`, copies explicit grants, the editor-invite setting, eligible same-domain access, and anyone-with-link access to the duplicate. Non-owners can still duplicate readable shared documents, but their copies start private. |
| `actorType` | string | no | Optional attribution hint. Pass `"agent"` for agent-created copies so the initial version author is recorded consistently. |

The duplicate copies the source markdown body, icon, tags, and referenced uploaded images. Image URLs are rewritten to the new document. General-link access receives a fresh token for the new document.

**Response 201:**
```json
{
  "id": "def67890",
  "title": "Copy of Project Roadmap",
  "revision": 0,
  "icon": "rocket",
  "url": "https://agentpad.cc/d/def67890",
  "accessCopied": true,
  "grantsCopied": 2,
  "hadSharedAccess": true
}
```

---

### Get Document

The returned `revision` and body are a coherent pair. After the versioned-body
rollout flag is activated, current documents point to an immutable KV body; if
that key has not propagated to the serving edge yet, the Worker loads the
same-revision Postgres fallback only for this body read. Metadata and `/head`
queries never select the full fallback. While the flag is disabled, legacy
documents continue to read and write their `doc:{id}` key.

```bash
curl https://agentpad.cc/api/docs/abc12345
```

**Auth:** Required for restricted documents. The configured public sample document and owner-enabled general-link reads with a valid `accessToken` are the anonymous read exceptions. A document id is an address, not a grant: callers without owner, explicit grant, eligible same-domain access, or a valid general-link token receive an `access_required` response and no document bytes or metadata.

**Response 200:**
```json
{
  "revision": 14,
  "content": "# Roadmap\n\n## Q2 Goals\n\n- Ship v2\n- Hire 3 engineers",
  "title": "Project Roadmap",
  "icon": "rocket",
  "tags": ["design", "q2"],
  "favoritedAt": "2026-04-25T12:00:00Z",
  "isSample": false,
  "isOwner": true,
  "role": "owner",
  "source": "owner",
  "capabilities": {
    "read": true,
    "comment": true,
    "edit": true,
    "invite": true,
    "manage": true
  },
  "imageToken": "short-lived-token",
  "imageTokenExpiresIn": 300,
  "agentsPaused": false
}
```

Field notes:

- `icon` — slug from `GET /api/icons`, or `null` when none is set.
- `tags` — array of normalized tag strings ([a-z0-9-], <= 32 chars each). Empty array when the doc has no tags. Available to all readers (not gated on ownership), since the home page lets non-owner viewers see the tags as part of the doc card.
- `favoritedAt` — ISO timestamp when the authenticated caller bookmarked the doc, or `null` if it isn't favorited by that caller. Present for signed-in readers, omitted for anonymous reads.
- `isSample` — `true` when this doc is the configured public Welcome / showcase. See [Sample Document](#sample-document) for the lock semantics. Use this to decide whether to fork before attempting a write.
- `isOwner` — `true` iff a Bearer token was sent AND the caller owns the doc. Anonymous callers always get `false`. Combined with `isSample`, an agent can detect "I'm reading the public sample as a non-owner — fork it before editing".
- `role` / `capabilities` — effective role for this caller. Roles are `viewer`, `commenter`, `editor`, or `owner`. `capabilities.invite` is true for owners and for explicit-grant editors when the owner has enabled delegated invites. Anonymous general-link readers with a valid `accessToken` are reported as `viewer`; comment and edit still require sign-in.
- `source` — effective access source for this read: `owner`, `grant`, `domain`, `general`, or `sample`. Clients use this to avoid presenting a cached general-link token as the active share permission when the read actually succeeded through another source.
- `imageToken` / `imageTokenExpiresIn` — present for authenticated non-sample reads. Use it as `?imageToken=...` on `/api/images/img/...` URLs; do not put API keys in image URLs. Anonymous general-link readers can render images by passing the same `accessToken` to image URLs.
- `agentsPaused` — `true` when the owner has paused agent-facing writes. Gated mutating endpoints return 423 while this is on; see [Agent Pause](#agent-pause) for the full contract.

**Access required:**

```json
{
  "error": "Access required",
  "code": "access_required",
  "docId": "abc12345",
  "requestable": true,
  "availableRequests": ["viewer", "commenter", "editor"],
  "signInRequired": false,
  "pendingRequest": {
    "id": "req_123",
    "requestedRole": "commenter",
    "status": "pending",
    "createdAt": "2026-05-27T12:00:00.000Z"
  }
}
```

This response deliberately omits title, owner email, tags, revision, snippets, comments, history, and image existence.
`requestable` is `true` only for signed-in callers when access requests are enabled. It deliberately does not vary with the hidden agent-pause state, so a request may still receive the same generic `403 access_required` response if the document cannot accept the write. Clients should keep retry and account-switch recovery available after that response and whenever `requestable` is `false`.

When the caller can still read the document but lacks the role required for the attempted action, the same response also includes `currentRole` and `requiredRole`. In that case `availableRequests` contains only useful upgrades that satisfy the action, and `requestable` is `false` when no requestable grant can satisfy it. For example, an editor-only write attempted by a commenter returns:

```json
{
  "error": "Access required",
  "code": "access_required",
  "docId": "abc12345",
  "requestable": true,
  "availableRequests": ["editor"],
  "signInRequired": false,
  "currentRole": "commenter",
  "requiredRole": "editor"
}
```

Clients must not treat this role-specific denial as loss of read access. Keep the readable document visible, protect any unsent mutation, and offer the minimum useful role upgrade. A denial without `currentRole` retains the document-wide access-boundary meaning.

For anonymous callers, missing document ids and restricted document ids use the same sign-in-required denial shape on read surfaces. This keeps guessed ids from distinguishing "does not exist" from "exists but private" before authentication. Signed-in callers without access receive `403 access_required` for existing restricted documents so they can request access; missing documents still return `404`.

`POST /api/docs/:id/visit` validates an authenticated open for a readable live document, schedules visit persistence, and returns `{ "ok": true, "revision": 7, "openVersion": "..." }` without returning document content. The browser uses this after serving a fresh same-auth prefetched document from its local cache so speculative reads stay side-effect free while real opens still update recently visited surfaces. Authorization and demo-workspace checks match `GET /api/docs/:id`; public sample reads by non-owners do not create visit rows, and trashed docs return `409`.

### Get Document Head

```bash
curl https://agentpad.cc/api/docs/abc12345/head
```

**Auth:** Same access rules as `GET /api/docs/:id`, including the anonymous general-link `accessToken` path.

**Response 200:**
```json
{
  "revision": 14,
  "openVersion": "..."
}
```

A cheap change-detection probe for polling clients. It reads only document metadata — never document content — and never records a visit or mints image tokens, so it is safe to call at sub-second cadence. Compare `revision` (content changes) and the opaque `openVersion` string (title, icon, tags, `agentsPaused`, role, capabilities, favorite state) against the values returned by the last full `GET /api/docs/:id`; when either moves, fetch the full document. Owners of a trashed document additionally get `deletedAt` and `purgesAt` so pollers can detect trash transitions; non-owners get `404` as on the full GET.

Both this probe and the full document read use
`Cache-Control: private, no-store`, including authenticated reads without a
general-access token.

### Document Targets

Document URLs may include a lazy target query parameter:

```text
https://agentpad.cc/d/abc12345?target=comment:c_x1y2z3
https://agentpad.cc/d/abc12345?target=section:0:budget-model
https://agentpad.cc/d/abc12345?target=anchor:a_x1y2z3
https://agentpad.cc/d/abc12345?target=highlight:h_x1y2z3
```

Targets are resolved against the current markdown on demand. Existing documents are not backfilled with block ids, and section links are derived from current headings.

#### Resolve Target

```bash
curl "https://agentpad.cc/api/docs/abc12345/targets/comment:c_x1y2z3" \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** `viewer` or higher. General-link documents accept the same `accessToken` query parameter as `GET /api/docs/:id`.

Supported target forms:

| Target | Resolves by |
|---|---|
| `comment:<commentId>` | Durable comment selector; legacy rows fall back conservatively to `selectedText` |
| `highlight:<highlightId>` | Existing highlight/comment highlight anchor, or a persisted highlight row |
| `section:<occurrence>:<slug>` | Current markdown headings using the same slugging as the SPA |
| `anchor:<anchorId>` | A lazily persisted selection selector |

`range` is a current markdown line range, not a ProseMirror position. Text
selectors also return `anchorStatus`: `exact`, `rebased`, `ambiguous`, or
`detached`. Ambiguous duplicate text and deleted text return `resolved:false`;
the resolver never chooses the nearest duplicate as a guess.

**Response 200:**

```json
{
  "docId": "abc12345",
  "revision": 14,
  "target": {"kind": "comment", "raw": "comment:c_x1y2z3", "id": "c_x1y2z3"},
  "resolved": true,
  "range": {"startLine": 12, "endLine": 14},
  "lines": "9| Context before\n10| ...\n12| Hire 3 engineers\n13| ...",
  "nearbyComments": [
    {"id": "c_x1y2z3", "kind": "comment", "selectedText": "Hire 3 engineers", "occurrenceIndex": 0}
  ]
}
```

#### Create Selection Anchor

```bash
curl -X POST https://agentpad.cc/api/docs/abc12345/anchors \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"selectedText":"Hire 3 engineers","occurrenceIndex":0}'
```

**Auth:** `viewer` or higher.

Creates a persisted selector only when a user explicitly copies a selection link. It does not change document markdown. Selection text is matched against the rendered document projection, including conservative delimiterless pipe tables; a selection spanning cells such as `Name Status Alpha Ready` resolves to the source lines containing those cells.

**Response 201:**

```json
{
  "id": "a_x1y2z3",
  "docId": "abc12345",
  "target": "anchor:a_x1y2z3",
  "url": "https://agentpad.cc/d/abc12345?target=anchor:a_x1y2z3",
  "selector": {
    "exactText": "Hire 3 engineers",
    "occurrenceIndex": 0,
    "startLine": 12,
    "endLine": 12,
    "createdRevision": 14
  }
}
```

### Document Access

Owners manage access through dedicated endpoints. Non-owners can inspect their own effective role or create a request without receiving document metadata.

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/api/docs/:id/access` | optional | Effective access for the caller; owners also see settings, grants, and requests |
| `GET` | `/api/docs/:id/access/suggestions` | owner | Suggest email recipients for the grant field from current-document collaborators and the owner's prior grants |
| `PATCH` | `/api/docs/:id/access/settings` | owner | Set `linkAccess`, `requestAccessEnabled`, `editorsCanInvite`, eligible `domainAllowlist`, `domainAccessRole`, and `generalAccessRole`; enabling general access returns `generalAccessToken` |
| `POST` | `/api/docs/:id/access/grants` | owner or inviter | Owners grant `viewer`, `commenter`, or `editor` to one `email` or up to 20 `emails`; explicit-grant editors with `capabilities.invite` may invite up to 10 new recipients |
| `PATCH` | `/api/docs/:id/access/grants/:grantId` | owner | Change a grant role |
| `DELETE` | `/api/docs/:id/access/grants/:grantId` | owner | Revoke a grant |
| `POST` | `/api/docs/:id/access/requests` | required | Request access as `viewer`, `commenter`, or `editor`; duplicate pending requests dedupe, and lower-role pending requests upgrade to the requested role |
| `PATCH` | `/api/docs/:id/access/requests/:requestId` | owner | Approve or deny an access request |

`linkAccess` remains `restricted` by default. `domainAccessRole` enables same-domain access for authenticated users whose email domain is both on the document allowlist and passes the custom-domain eligibility rules. It can be `viewer`, `commenter`, `editor`, or `null`; legacy `linkAccess: "domain_viewer"` is treated as `domainAccessRole: "viewer"` when no explicit domain role is sent. Generic or disposable providers such as Gmail, Outlook, iCloud, Yahoo, Proton, Fastmail, and temporary-mail domains are rejected.

`generalAccessRole` enables "anyone with the link" access and can be `viewer`, `commenter`, `editor`, or `null`. Enabling it creates or preserves a long random `generalAccessToken`; disabling it clears the token. Pass that token as `?accessToken=...` on read/write API calls for a link-shared document. Logged-out visitors can use a valid general-link token only as read-only viewers; signed-in visitors receive the configured role. General-link documents appear in "Shared with me" and MCP list/search only after that signed-in user has opened the document URL with the token at least once, which creates a token-specific claim. Token rotation invalidates old claims.

`editorsCanInvite` enables delegated invites for explicit-grant editors only. Editors whose access comes from same-domain or anyone-with-link access do not receive `capabilities.invite`. Delegated editor invites are create-only: they use a non-enumerating success response, do not reveal existing grants, do not change existing grants, and do not expose settings, people lists, or access requests. Owners still manage links, requests, revokes, role changes, and all access settings.

Do not treat the 8-character document id as a sharing secret. The bearer capability for general-link access is `generalAccessToken`.

Access-management writes are live-document writes. They reject trashed documents, and while `agentsPaused` is true they require the first-party browser human-control cookie. MCP access-management writes are always treated as agent writes and return 423 while the document is paused. Owner-created or owner-updated grants resolve matching pending access requests with the granted role. Delegated editor-created grants may also resolve a matching pending request, but editors cannot list or act on requests directly. Grant notifications are opt-in: pass `notify: true` and optional `message` when creating specific email grants. Share notification sends are rate-limited per user and per user/document/recipient; delegated editor grant creation is additionally rate-limited per inviter/document. If email delivery fails or the notification throttle is hit after an owner grant commits, that grant remains active and the owner response includes `notification.sent: false`. A single-recipient owner request keeps the legacy grant response shape; a multi-recipient owner request returns `{ "grants": [...] }`. Delegated editor invite responses are always generic: `{ "ok": true, "invited": true }`.

Recipient suggestions are read-only and owner-scoped:

```bash
curl "https://agentpad.cc/api/docs/abc12345/access/suggestions?q=mar&limit=8" \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Response 200:**
```json
{
  "suggestions": [
    { "email": "maria@example.com", "source": "grant" }
  ]
}
```

`q` is an optional case-insensitive email substring filter. `limit` is clamped to 1-20 and defaults to 8. Sources are `grant`, `request`, `comment`, or `typed-email`. The endpoint excludes the caller and does not expose collaborators from documents the caller does not own.

#### Numbered Format

Add `?format=numbered` to get content with line numbers, which is required for the line-edit protocol.

```bash
curl "https://agentpad.cc/api/docs/abc12345?format=numbered" \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Response 200:**
```json
{
  "revision": 14,
  "lines": "1| # Roadmap\n2| \n3| ## Q2 Goals\n4| \n5| - Ship v2\n6| - Hire 3 engineers",
  "lineCount": 6,
  "isSample": false,
  "isOwner": true,
  "agentsPaused": false
}
```

The `lines` field prefixes each line as `{number}| {content}`. This format maps directly to the `line` and `after` parameters in edit operations. `isSample` and `isOwner` mean the same thing as on the plain GET — agents reading this format for line-edits should still consult them before attempting a write.

Document reads also include a `features` object. Review workflow flags default
off and are safe for clients to ignore:

```json
{
  "features": {
    "reviewProposals": false,
    "reviewThreadReplacements": false,
    "reviewChat": false,
    "reviewPresets": false,
    "focusMode": false
  }
}
```

#### Download as Markdown

The SPA exposes Markdown and PDF downloads in the document More menu. Agents and scripts use the same artifact endpoints:

```bash
curl -L https://agentpad.cc/api/docs/abc12345/export.md \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -o roadmap.md

curl -L https://agentpad.cc/api/docs/abc12345/export.pdf \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -o roadmap.pdf

curl -L 'https://agentpad.cc/api/docs/abc12345/export.pdf?fontSize=large&lineNumbers=1&paper=a4&margins=wide&headerFooter=0' \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -o roadmap-large.pdf
```

| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/api/docs/:id/export.md` | optional | Raw Markdown attachment (`text/markdown`) |
| `GET` | `/api/docs/:id/export.pdf` | optional | Rendered PDF attachment (`application/pdf`) |

Export access matches `GET /api/docs/:id`: sample documents are public, restricted documents require a readable role, and non-owners get the same access-required or not-found responses as the normal read path. Owners can export their own trashed documents as Markdown during the retention window. PDF export requires a live document and returns `409` until the owner restores the document.
For general-link documents, append the owner-generated `accessToken` query parameter just as you would on `GET /api/docs/:id`.

Both endpoints set `Content-Disposition: attachment` with a safe filename derived from the document title.
PDF rendering is rate-limited per authenticated user or caller IP and can return `429` with `retryAfter`.

PDF export accepts optional layout query parameters:

| Param | Values | Default | Description |
|---|---|---|---|
| `fontSize` | `small`, `normal`, `large` | `normal` | Scales body and heading text in the rendered PDF |
| `lineNumbers` | `1`, `0`, `true`, `false` | `0` | Shows or hides printable document line numbers |
| `paper` | `letter`, `a4` | `letter` | Sets the generated PDF paper size |
| `margins` | `compact`, `normal`, `wide` | `normal` | Sets page margins |
| `headerFooter` | `1`, `0`, `true`, `false` | `1` | Shows or hides the document title and page count header/footer |

Google Docs export is not part of v0. [`documents.create`](https://developers.google.com/docs/api/reference/rest/v1/documents/create) in the Google Docs API creates a blank document and ignores supplied content, so a future one-click export should use [Google Drive upload-and-convert](https://developers.google.com/drive/api/v3/manage-uploads) to [`application/vnd.google-apps.document`](https://developers.google.com/workspace/drive/api/guides/mime-types) with Google OAuth, token lifecycle handling, and Drive scopes.

---

### Replace Document Content

With `AGENTPAD_VERSIONED_DOCUMENT_BODIES_ENABLED=true`, successful content
writes stage a new immutable KV object, then atomically publish the new
revision, body pointer, search/fallback snapshot, and prior version history. A
concurrent loser returns a revision conflict and cannot overwrite the winning
body or insert its history. The checked-in production configuration is
`AGENTPAD_VERSIONED_DOCUMENT_BODIES_ENABLED=true` and
`AGENTPAD_DOCUMENT_BODY_WRITES_DISABLED=false`. During a coordinated rollout,
the phase-1 configuration is `false` and `true`: reads stay available, but
creates and body writes return retryable 503 before touching KV. Phase 2 must
flip those values together to the checked-in `true` and `false` state.
`versioned=false, disabled=false` also fails closed and is not a supported
production state. Replacing content with byte-identical content is a no-op: the
current revision is returned and no history row or KV object is created.

```bash
curl -X PUT https://agentpad.cc/api/docs/abc12345 \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"content":"# Roadmap\n\n## Q2 Goals\n\n- Ship v2\n- Hire 3 engineers\n- Launch EU region","actorType":"agent"}'
```

**Request body:**

| Field          | Type   | Required | Description           |
|----------------|--------|----------|-----------------------|
| `content`      | string | yes      | Full new document content |
| `baseRevision` | number | no       | If supplied, server returns 409 when the current revision doesn't match (optimistic concurrency). Omit for last-write-wins. |
| `actorType`    | string | no       | Set to `agent` for agent-authored writes so attribution is correct. Pause enforcement is server-side and does not trust this field as the boundary. |

**Auth:** required

**Response 200:**
```json
{
  "revision": 15
}
```

**Response 409 (only when `baseRevision` was supplied and stale):**
```json
{
  "error": "Revision conflict",
  "details": "Expected revision 14, current is 15",
  "currentRevision": 15
}
```

Re-read with `GET /api/docs/:id`, reconcile your changes against the new `content`, and retry the PUT with the updated `baseRevision`. Clients that omit `baseRevision` keep last-write-wins semantics and never see 409 from this endpoint.

This overwrites the entire document. For surgical edits, use the line-edit protocol instead.

**Uploading a local file:** Read the file and send its text as the `content` field. The previous content is automatically saved as a version in history.

```bash
# Upload a local file as the document content
curl -X PUT https://agentpad.cc/api/docs/abc12345 \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --arg c "$(cat myfile.md)" '{content: $c, actorType: "agent"}')"
```

---

### Update Document Metadata

```bash
curl -X PATCH https://agentpad.cc/api/docs/abc12345 \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"title":"Q2 Project Roadmap","agentsPaused":true}'
```

**Request body:** at least one mutable field must be present.

| Field          | Type             | Description                                                                 |
|----------------|------------------|-----------------------------------------------------------------------------|
| `title`        | string           | New document title (non-empty).                                             |
| `icon`         | string \| `null` | Catalog slug from `GET /api/icons`, or `null` to clear.                     |
| `agentsPaused` | boolean          | When `true`, gated mutation endpoints return 423. Toggling requires the first-party browser human-control cookie; see [Agent Pause](#agent-pause). |
| `favorited`    | boolean          | `true` to bookmark, `false` to remove the caller's bookmark. Favorite-only PATCHes are allowed for any signed-in reader and do NOT bump `updated_at`. |
| `tags`         | string[]         | **Atomic replace.** Server normalizes each entry (lowercase, trimmed, interior whitespace/underscores → hyphens, drops chars outside `[a-z0-9-]`, collapses runs of `-`, trims trailing `-`, truncates to 32 chars), deduplicates, and caps at **10 tags per doc**. Pass `[]` to clear all tags. Tag updates **do** bump `updated_at`. |
| `actorType`    | string           | Optional attribution hint only. It is not trusted for authorization or pause boundaries. |

**Auth:** required. `title`, `icon`, and `agentsPaused` require document ownership. `tags` requires an effective `editor` role and updates the document-global tag set for every collaborator. A request containing only `favorited` requires readable access and updates only the caller's private favorite state. Mixed requests use the strongest gate before any write: `tags + favorited` requires `editor`, while `tags + title` requires `owner` and fails atomically for non-owners.

**Examples:**

```bash
# Bookmark a doc
curl -X PATCH https://agentpad.cc/api/docs/abc12345 \
  -H "Authorization: Bearer ..." -H "Content-Type: application/json" \
  -d '{"favorited":true}'

# Pause agent edits
curl -X PATCH https://agentpad.cc/api/docs/abc12345 \
  -H "Authorization: Bearer ..." -H "Content-Type: application/json" \
  -d '{"agentsPaused":true}'

# Replace the tag set (PATCH is atomic — to add, fetch current tags first)
curl -X PATCH https://agentpad.cc/api/docs/abc12345 \
  -H "Authorization: Bearer ..." -H "Content-Type: application/json" \
  -d '{"tags":["design","q2","planning"]}'

# Clear all tags
curl -X PATCH https://agentpad.cc/api/docs/abc12345 \
  -H "Authorization: Bearer ..." -H "Content-Type: application/json" \
  -d '{"tags":[]}'
```

**Response 200:** echoes only the fields the request included (so `{"title": "..."}` alone returns `{id, title}` — no `icon` / `agentsPaused` / `favoritedAt`).
```json
{
  "id": "abc12345",
  "title": "Q2 Project Roadmap",
  "agentsPaused": true,
  "favoritedAt": "2026-04-25T12:00:00Z",
  "tags": ["design", "q2", "planning"]
}
```

`favoritedAt` is the ISO timestamp when the bookmark was set, or `null` after a `{"favorited": false}` call.

When the caller passes more than 10 tags, the response also includes `"tagsTruncated": true` so agents notice that their list was capped. Inputs that normalize to empty (e.g. `"!!!"`) or to a duplicate of another tag in the same request are silently dropped without setting `tagsTruncated`.

**Response 400 (invalid `tags`):**
```json
{
  "error": "tags must be an array",
  "details": "Pass an array of strings (max 10). Pass [] to clear."
}
```

**Response 403 (insufficient role):**
```json
{ "error": "Access required", "currentRole": "commenter", "requiredRole": "editor" }
```

**Response 403 (`agentsPaused` without browser human-control cookie):**
```json
{
  "error": "Human control required",
  "details": "Sign in again in the browser to pause or resume agent edits."
}
```

**Response 423 (document already paused without browser human-control cookie):**
```json
{
  "error": "Agents are paused on this document",
  "code": "human_control_required",
  "details": "Sign in again in the browser to continue working on this paused document.",
  "agentsPaused": true
}
```

---

### List Icons

```bash
curl https://agentpad.cc/api/icons
```

**Response 200:**

```json
{
  "icons": [
    {
      "slug": "meeting",
      "label": "Meeting",
      "search": "meeting meetings standup sync workshop reunión reuniones junta comité llamada grupo team équipe reunião encontro",
      "svg": "<g ...></g>"
    }
  ]
}
```

Field notes:

- `slug` — persisted document metadata value. Pass it as `icon` to `PATCH /api/docs/:id`.
- `label` — human-readable English display label.
- `search` — multilingual aliases used by the browser picker and exposed so API/MCP clients can reproduce the same quick icon search locally. Clients should normalize accents if they want accent-insensitive matching.
- `svg` — inner SVG markup for a 24×24 icon viewBox. Callers wrap it in their own `<svg>` element.

---

### List Tags (Vocabulary)

```bash
curl https://agentpad.cc/api/tags \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

Returns the authenticated user's tag vocabulary, sorted by usage frequency (most-used first, ties broken alphabetically). Powers the home-page tag strip and the in-document tag autocomplete; agents call it before tagging to prefer existing vocabulary over inventing new strings.

**Auth:** required

**Query params (optional):**

| Param   | Type    | Description                                                              |
|---------|---------|--------------------------------------------------------------------------|
| `q`     | string  | Prefix match on the tag name (case-insensitive). Use for autocomplete.   |
| `limit` | integer | Max results, default `50`, hard-capped at `200`.                         |

**Response 200:**
```json
{
  "tags": [
    { "name": "design",     "count": 14 },
    { "name": "engineering","count": 9  },
    { "name": "meeting",    "count": 7  }
  ]
}
```

`count` is the number of the caller's own documents that carry the tag. Visited / shared docs are not included — tags belong to the owner. Pair with `GET /api/docs?tag=...` to filter by any vocabulary entry.

---

### Delete Document (Soft — moves to Trash)

```bash
curl -X DELETE https://agentpad.cc/api/docs/abc12345 \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"

# Guarded cleanup mode for abandoned brand-new empty documents
curl -X DELETE https://agentpad.cc/api/docs/abc12345 \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"cleanupIfPristine":true}'
```

DELETE is a **soft delete**. The document moves to the owner's trash and stays recoverable for **30 days** via `POST /api/docs/:id/restore`. After 30 days an hourly cron permanently deletes the row, the KV body, and any uploaded R2 images. There is no API to bypass the 30-day window.

With no request body, `DELETE /api/docs/:id` always performs the normal owner-only soft delete. With a JSON body of `{"cleanupIfPristine": true}`, the server uses the same Trash flow but refuses unless the document is still a pristine abandoned draft: owner-owned, revision `0`, blank KV body, blank/default `Untitled` title, no icon, no favorite, no tags, no comments, no saved versions, no pending suggestions, no grants or access requests, no customized access settings, and not paused. This guarded mode is intended for cleanup clients that must not trash a document the user has customized in another tab.

While in trash:
- The doc is hidden from `GET /api/docs`, `GET /api/docs/visited`, search, recent-edits, and version history.
- `GET /api/docs/:id` returns the doc to the owner with `deletedAt` and `purgesAt` fields set so the SPA can render the "in trash" banner. Non-owners get `404 Document not found`.
- `GET /api/docs/:id/export.md` is owner-only during the retention window. `GET /api/docs/:id/export.pdf` returns `409` until the owner restores the document.
- All mutation endpoints (`PUT /api/docs/:id`, `PATCH`, `POST .../edit`, `POST .../comments`, `POST .../images`, `POST .../history/:rev/restore`) reject the owner with `409 Document is in trash` and reject non-owners with `404`.
- While `agentsPaused` is true, moving a live document to trash requires the first-party browser human-control cookie; MCP delete writes return 423.

**Auth:** required (must be document owner)

**Response 200 (first call or idempotent retry):**
```json
{
  "ok": true,
  "deletedAt": "2026-05-04T20:30:00Z",
  "purgesAt":  "2026-06-03T20:30:00Z",
  "retentionDays": 30
}
```

**Errors:**
- `400 Invalid JSON body` if a non-empty request body is not valid JSON.
- `400 Invalid delete body` if a non-empty request body is not an object or is missing `cleanupIfPristine: true`.
- `409 Document is not pristine` when guarded cleanup is requested but any pristine check fails.
- `423 Agents paused for this document` when a paused document is deleted without the first-party browser human-control cookie.

---

### List Trash

```bash
curl https://agentpad.cc/api/docs/trash \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

Returns the authenticated user's trashed documents, most-recently-deleted first. Owner-only — no non-owner ever sees trash for any doc.

**Auth:** required

**Response 200:**
```json
{
  "docs": [
    {
      "id": "abc12345",
      "title": "Old draft",
      "icon": "notebook",
      "revision": 12,
      "agentsPaused": false,
      "tags": ["draft"],
      "deletedAt": "2026-05-04T20:30:00Z",
      "purgesAt":  "2026-06-03T20:30:00Z",
      "createdAt": "2026-04-15T10:00:00Z",
      "updatedAt": "2026-05-01T17:42:00Z"
    }
  ],
  "retentionDays": 30
}
```

---

### Restore Document

```bash
curl -X POST https://agentpad.cc/api/docs/abc12345/restore \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

Restore a trashed document. Idempotent: restoring an already-live doc returns `{"id": "...", "restored": false}`. Returns `404` if the doc never existed or if the cron has already started permanently purging it (only happens after 30 days in trash).

**Auth:** required (must be document owner)

Restoring a trashed document while `agentsPaused` is true requires the first-party browser human-control cookie; MCP restore writes return 423.

**Response 200 (just restored):**
```json
{ "id": "abc12345", "restored": true }
```

**Response 200 (already live):**
```json
{ "id": "abc12345", "restored": false }
```

**Response 404 (purged or never existed):**
```json
{ "error": "Document not found" }
```

---

## Line-Based Edit Protocol

Surgical edits without replacing the full document. Operates on line numbers from `?format=numbered`.

### Submit Edits

```bash
curl -X POST https://agentpad.cc/api/docs/abc12345/edit \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "revision": 14,
    "ops": [
      {"type": "replace", "line": 5, "content": "- Ship v2.1"},
      {"type": "insert", "after": 6, "content": "- Launch EU region"},
      {"type": "delete", "lines": [2]}
    ],
    "actorType": "agent",
    "intent": "update Q2 goals"
  }'
```

**Request body:**

| Field      | Type   | Required | Description                           |
|------------|--------|----------|---------------------------------------|
| `revision` | number | yes      | Current revision (from last GET/edit) |
| `ops`      | array  | yes      | Array of edit operations              |
| `actorType`| string | no       | Set to `agent` to mark the author as an agent persona. |
| `intent`   | string | no       | Free-form short description of why the edit was made (max 200 chars). Surfaced via `GET /api/docs/:id/recent-edits` so other clients can show what an agent recently changed. Returns `400 {"error":"intent too long"}` if longer than 200 chars. |

**Operation types:**

| Type      | Fields                    | Description                            |
|-----------|---------------------------|----------------------------------------|
| `replace` | `line`, `content`         | Replace content of line N              |
| `insert`  | `after`, `content`        | Insert new line after line N (0 = before first line) |
| `delete`  | `lines`                   | Delete lines at given numbers          |
| `append`  | `content`                 | Append line at end of document         |
| `move_block` | `startLine`, `endLine`, `after` | Move the inclusive line range after a pre-move line boundary (`0` = before the first line). Must be the only op in the request. |

Browser and native editors reorder their local canonical Markdown and save it
through their normal document-write path. `move_block` is the atomic equivalent
for REST and MCP clients. All three coordinates refer to the revision supplied
in the request; the client does not resend the block content. `after` may be
before or after the source range. A destination inside the range returns `400`;
the existing boundaries (`startLine - 1` and `endLine`) are successful no-ops
and do not bump the revision. A successful move preserves every Markdown byte
and keeps the line count unchanged. For a complete top-level block bounded by
blank lines, the adjacent blank separator stays between the reordered blocks
even when it is outside the selected range; a destination across the block's
current separator is therefore a successful no-op. A changed move creates one
normal document revision. Bodies may use consistent LF or CRLF endings; a body
that mixes both returns `400` rather than normalizing bytes during the move.

```json
{
  "revision": 14,
  "ops": [
    {"type": "move_block", "startLine": 8, "endLine": 11, "after": 3}
  ],
  "actorType": "agent",
  "intent": "move action items below the summary"
}
```

**Response 200:**
```json
{
  "revision": 15,
  "lineCount": 7
}
```

**Response 409 (conflict):**
```json
{
  "error": "Revision mismatch",
  "details": "Expected revision 14, but document is at revision 15",
  "currentRevision": 15
}
```

On 409, re-read the document with `?format=numbered`, rebase your edits against the new content, and retry.

See [Edit Protocol Spec](protocol.md) for processing rules and examples.

---

## Suggested Edits

Suggestions use the `replace`, `insert`, `delete`, and `append` line ops from
`POST /api/docs/:id/edit`, but creating one does not change the document body or
bump the revision. `move_block` is a direct revision-locked reorder operation,
not a suggestion op. Accepting a suggestion applies the stored ops only when
the document is still at the suggestion's `baseRevision`; otherwise the server
returns `409` and the reviewer should recreate the suggestion against current
numbered lines.

### List Suggestions

```bash
curl "https://agentpad.cc/api/docs/abc12345/suggestions?status=open" \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** `viewer` or higher

`status` may be `open` (default), `accepted`, `rejected`, or `all`.

### Create Suggestion

```bash
curl -X POST https://agentpad.cc/api/docs/abc12345/suggestions \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"revision":14,"ops":[{"type":"replace","line":5,"content":"- Ship v2.1-beta"}],"summary":"Scope launch to beta","actorType":"agent"}'
```

**Auth:** `commenter` or higher

**Response 201:** a suggestion object with `id`, `baseRevision`, `ops`, `summary`, `author`, `authorActorType`, `status`, `createdAt`, and `resolvedAt`.

### Accept or Reject Suggestion

```bash
curl -X PATCH https://agentpad.cc/api/docs/abc12345/suggestions/SUGGESTION_ID \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"action":"accept","actorType":"agent"}'
```

**Auth:** `editor` or owner

`action` is `accept` or `reject`. Accept returns `{suggestion, revision, lineCount, content}` so browser clients can replace their editor content without a separate GET. Reject returns `{suggestion}` and does not mutate the document.

---

## Review Proposals

Review proposals are grouped, non-mutating review artifacts. Blocks can be
accepted or rejected one by one. Applying accepted blocks is a separate final
step and creates exactly one normal document revision. If the document moved
past the proposal's `baseRevision`, apply returns `409` and does not write KV.

These endpoints are behind runtime feature flags. Disabled review routes return
`404 {"error":"Feature disabled", ...}`.

### List Review Proposals

```bash
curl "https://agentpad.cc/api/docs/abc12345/review-proposals?status=all" \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** `viewer` or higher

`status` may be `open`, `applied`, `rejected`, or `all` (default).

### Create Review Proposal

```bash
curl -X POST https://agentpad.cc/api/docs/abc12345/review-proposals \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"revision":14,"summary":"Copyedit pass","sourceType":"agent","blocks":[{"summary":"Tighten launch bullet","ops":[{"type":"replace","line":5,"content":"- Ship v2.1 beta"}]}]}'
```

**Auth:** `commenter` or higher

Request fields:

| Field | Type | Required | Description |
|---|---|---:|---|
| `revision` | number | yes | Expected current document revision. |
| `summary` | string | no | Proposal summary, capped at 200 chars. |
| `sourceType` | string | no | `manual`, `thread`, `chat`, `preset`, or `agent`. |
| `presetId` | string | no | One of the static review preset ids when applicable. |
| `prompt` | string | no | Review request text that produced the proposal. |
| `blocks` | array | yes | 1-50 blocks. Each block has `ops`, optional `summary`, optional `commentId`. |

`commentId` attaches a block to a normal root comment in the same document and requires
`reviewThreadReplacements` to be enabled. Cross-document or reply comment ids
return `400`. Highlight ids also return `400`; use a normal comment thread for
review proposals.

**Response 201:** `{ "proposal": { ... } }` with `blocks[]`. Creating a
proposal never changes `revision` or document body content.

### Accept or Reject a Block

```bash
curl -X PATCH https://agentpad.cc/api/docs/abc12345/review-proposals/PROPOSAL_ID/blocks/BLOCK_ID \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"action":"accept"}'
```

**Auth:** `editor` or owner

`action` is `accept` or `reject`. This changes review state only.

### Accept All or Reject All

```bash
curl -X PATCH https://agentpad.cc/api/docs/abc12345/review-proposals/PROPOSAL_ID \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"action":"accept_all"}'
```

**Auth:** `editor` or owner

`accept_all` marks every block accepted without mutating the document.
`reject_all` marks every block rejected and resolves the proposal.

### Apply Accepted Blocks

```bash
curl -X POST https://agentpad.cc/api/docs/abc12345/review-proposals/PROPOSAL_ID/apply \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{}'
```

**Auth:** `editor` or owner

Every block must already be accepted or rejected, and at least one block must be
accepted. Response 200 returns `{proposal, revision, lineCount, content}`. KV
persistence failures roll back the claimed revision and reopen accepted blocks.

### Review Chat and Presets

```bash
curl https://agentpad.cc/api/docs/abc12345/review-messages \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"

curl -X POST https://agentpad.cc/api/docs/abc12345/review-messages \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"text":"Please risk-review this draft","presetId":"risk-review","clientRequestId":"req-123"}'

curl https://agentpad.cc/api/docs/abc12345/review-presets \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

Review messages are document-level non-mutating artifacts. They may link to a
proposal or normal root comment, but never write document content directly.
`clientRequestId` is an idempotency key per document/user.

---

## Comments

### List Comments

```bash
curl https://agentpad.cc/api/docs/abc12345/comments \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
curl https://agentpad.cc/api/docs/abc12345/comments?status=active \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
curl https://agentpad.cc/api/docs/abc12345/comments?kind=highlight \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** `viewer` or higher

**Query params:**
- `status` (optional) — `all` (default), `active`, `resolved`, `detached`, or `ambiguous`. Filters root comments; replies always stay attached to their parent. `active` includes every explicitly unresolved thread, including detached/ambiguous ones. Missing anchors never change the lifecycle state.
- `kind` (optional) — `all` (default), `comment`, or `highlight`. Comments are actionable feedback; highlights are shared reading marks. Replies are always `kind: "comment"` and can attach to normal comment roots or highlight roots.

**Ordering:** Root comments and highlights are returned in current document
order when their selector resolves. `rebased` selectors use quote context to
find their current occurrence. `ambiguous`, `detached`, and legacy blank
anchors are placed at the end; ties use creation time. Replies remain attached
and in creation order.

**Response 200:**
```json
{
  "comments": [
    {
      "id": "c_x1y2z3",
      "kind": "comment",
      "selectedText": "Ship v2",
      "occurrenceIndex": 0,
      "text": "Should we scope this down to v2-beta first?",
      "author": "alice",
      "resolved": false,
      "resolvedAt": null,
      "resolvedRevision": null,
      "resolutionReason": null,
      "anchor": {
        "id": "4f9c4bcc-7af0-4bbd-ae38-74828ef54f61",
        "status": "exact",
        "createdRevision": 14,
        "startLine": 12,
        "endLine": 12
      },
      "createdAt": "2026-04-06T11:00:00Z",
      "editedAt": null,
      "permissions": {"canEditText": true},
      "replies": [
        {
          "id": "c_r1s2t3",
          "kind": "comment",
          "text": "Good idea, let's do beta first.",
          "author": "bob",
          "createdAt": "2026-04-06T11:05:00Z",
          "editedAt": null,
          "permissions": {"canEditText": false}
        }
      ]
    }
  ],
  "counts": {
    "total": 1,
    "open": 1,
    "active": 1,
    "resolved": 0,
    "detached": 0,
    "ambiguous": 0
  }
}
```

Counts cover all root items before `status`/`kind` filtering and are computed
from the same SQL rows and document-body read—there is no per-comment query.
`open` is the lifecycle term; `active` is retained as an equal-valued alias
for existing browser clients.

---

### Add Comment

```bash
curl -X POST https://agentpad.cc/api/docs/abc12345/comments \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "selectedText": "Hire 3 engineers",
    "occurrenceIndex": 0,
    "kind": "comment",
    "text": "Budget approved for 2, not 3.",
    "actorType": "agent",
    "revision": 14
  }'
```

**Auth:** required

**Request body:**

| Field             | Type            | Required | Description                          |
|-------------------|-----------------|----------|--------------------------------------|
| `selectedText`    | string          | yes      | The text in the document being commented on, or a canonical Markdown image token for an image anchor (for example `![Chart](</api/images/img/DOC_ID/chart.png>)`). Must be a non-empty string, max 8192 characters. Leading and trailing whitespace are stripped before storage. Requests with missing, empty, whitespace-only, or over-limit `selectedText` return `400`. |
| `occurrenceIndex` | integer \| null | no       | 0-based index picking which match of `selectedText` in the document to anchor to (0 = first, 1 = second, …). Omit or pass `null` to default to the first match. Must be a non-negative integer; invalid values return `400`. |
| `kind`            | string          | no       | `comment` (default) or `highlight`. Comments are actionable feedback and require non-empty `text`; highlights are shared reading marks and may omit `text` or pass an empty string. |
| `text`            | string          | depends  | Comment body or highlight note, max 16384 characters. Leading and trailing whitespace are stripped before storage. Empty text returns `400` for comments and is accepted for highlights. May embed images and a small markdown subset — see [Comment formatting](#comment-formatting) below. |
| `actorType`       | string          | no       | Set to `agent` to attribute the comment to the caller's agent persona. Otherwise the authenticated user's email is used. |
| `revision`        | integer         | no       | Revision from which the selection was captured. When supplied, a stale revision returns `409`. Old clients may omit it; the current revision is used. |

**Response 201:**
```json
{
  "id": "c_x1y2z3",
  "kind": "comment",
  "selectedText": "Hire 3 engineers",
  "occurrenceIndex": 0,
  "text": "Budget approved for 2, not 3.",
  "author": "Agent of user@example.com",
  "resolved": false,
  "anchor": {
    "id": "4f9c4bcc-7af0-4bbd-ae38-74828ef54f61",
    "status": "exact",
    "createdRevision": 14,
    "startLine": 12,
    "endLine": 12
  },
  "createdAt": "2026-04-06T11:00:00Z"
}
```

The Worker validates the quote against the current rendered projection, then
creates the `document_anchors` row and root comment in one transaction. The
selector stores exact text, bounded prefix/suffix context, source lines, and
the creation revision. Replies do not create anchors.

To create a highlight with no note, send `kind: "highlight"` and omit `text`:

```bash
curl -X POST https://agentpad.cc/api/docs/abc12345/comments \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"selectedText":"Hire 3 engineers","occurrenceIndex":0,"kind":"highlight"}'
```

---

### Reply to Comment

```bash
curl -X POST https://agentpad.cc/api/docs/abc12345/comments/c_x1y2z3/reply \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"text":"Updated to 2. Thanks for flagging.","actorType":"agent"}'
```

**Auth:** required

Replies are supported on root comments and root highlights. Replies to replies return `400`.

**Request body:**

| Field       | Type   | Required | Description                        |
|-------------|--------|----------|------------------------------------|
| `text`      | string | yes      | Reply body, max 16384 characters. Leading and trailing whitespace are stripped before storage; missing, empty, whitespace-only, or over-limit text returns `400`. |
| `actorType` | string | no       | Set to `agent` to attribute the reply to the caller's agent persona. Otherwise the authenticated user's email is used. |

**Response 201:**
```json
{
  "id": "c_r1s2t3",
  "text": "Updated to 2. Thanks for flagging.",
  "author": "Agent of user@example.com",
  "createdAt": "2026-04-06T11:05:00Z"
}
```

---

### Edit Comment Text

```bash
curl -X PATCH https://agentpad.cc/api/docs/abc12345/comments/c_x1y2z3/text \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"text":"Budget approved for 2, not 3.","actorType":"agent"}'
```

**Auth:** required, `commenter` or higher, and the target comment or reply must be authored by the caller identity. Browser callers edit their own human comments; agent callers pass `actorType: "agent"` to edit comments authored by their agent identity. The endpoint updates only the comment `text`; anchors, authorship, thread placement, and resolved state are unchanged.

**Request body:**

| Field       | Type   | Required | Description |
|-------------|--------|----------|-------------|
| `text`      | string | yes      | Updated comment/reply body or highlight note, max 16384 characters. Trimmed before storage. Empty text returns `400` for comments and replies, and clears highlight notes. |
| `actorType` | string | no       | Set to `agent` to edit the caller's agent-authored comments. Otherwise the authenticated user's human comments are targeted. |

**Response 200:** root comments return the normal comment object with an empty `replies` array; replies return the reply object.

```json
{
  "id": "c_x1y2z3",
  "text": "Budget approved for 2, not 3.",
  "author": "Agent of user@example.com",
  "createdAt": "2026-04-06T11:00:00Z",
  "editedAt": "2026-04-06T11:03:00Z",
  "permissions": {"canEditText": true}
}
```

---

### Resolve Comment

```bash
curl -X PATCH https://agentpad.cc/api/docs/abc12345/comments/c_x1y2z3 \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"resolved": true}'
```

**Auth:** required

**Request body:**

| Field      | Type    | Required | Description |
|------------|---------|----------|-------------|
| `resolved` | boolean | yes      | `true` resolves; `false` reopens |

Resolving an already resolved thread is idempotent: it preserves the original
`resolvedAt`, `resolvedRevision`, resolver, and `resolutionReason`. Reopening
clears those lifecycle fields.

**Response 200:**
```json
{
  "id": "c_x1y2z3",
  "resolved": true,
  "resolvedAt": "2026-04-06T11:20:00Z",
  "resolvedRevision": 15,
  "resolutionReason": "manual"
}
```

---

### Reattach Comment

```bash
curl -X PATCH https://agentpad.cc/api/docs/abc12345/comments/c_x1y2z3/anchor \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{
    "selectedText": "Hire 2 engineers",
    "occurrenceIndex": 0,
    "revision": 15,
    "reopen": true,
    "actorType": "agent"
  }'
```

**Auth/role:** required, `editor` or owner.

Creates a new durable selector and binds the existing root thread to it in one
transaction. `revision` is required and must still be current; stale selections
return `409`. `reopen:true` clears lifecycle-resolution metadata while
reattaching. Replies cannot be reattached independently.

MCP clients use `agentpad_update_comment` with `action:"reattach"` and the same
`selectedText`, `occurrenceIndex`, `revision`, and optional `reopen` fields.

---

### Durable-anchor migration rollout

1. Apply `src/db/migrations/0030-durable-comment-anchors.sql` through the
   controlled admin/operator migration path. The file is intentionally ordered:
   nullable columns and `NOT VALID` foreign keys first, concurrent indexes
   outside the transaction second, then online constraint validation. Run the
   complete file with `ON_ERROR_STOP`; do not wrap it in another transaction.
2. Read back both constraints and indexes before deploying:

   ```sql
   SELECT conname, convalidated
   FROM pg_constraint
   WHERE conrelid = 'comments'::regclass
     AND conname IN ('comments_anchor_id_fkey', 'comments_resolved_by_fkey');

   SELECT indexrelid::regclass AS index_name, indisready, indisvalid
   FROM pg_index
   WHERE indexrelid IN (
     'idx_comments_doc_root_lifecycle'::regclass,
     'idx_comments_anchor'::regclass
   );
   ```

   Both constraints must report `convalidated = true`; both indexes must report
   `indisready = true` and `indisvalid = true`. If a concurrent build was
   interrupted, remove only the specifically confirmed invalid index with
   `DROP INDEX CONCURRENTLY`, then rerun 0030.
3. Deploy the backward-compatible Worker and SPA from the same reviewed commit.
4. Run a dry report for disposable/scratch documents:

   ```bash
   cd worker
   AGENTPAD_API_KEY=... npm run comment-anchors:backfill -- --doc DOC_ID
   ```

5. Inspect skipped duplicate/rendered-only matches. The script never guesses.
6. Re-run with `--apply` only for the reviewed document set. It uses normal API
   permissions and the reattach endpoint; it does not connect to Postgres.

Rollback is code-only: old Workers ignore the additive columns. Do not drop the
columns or selector rows during rollback.

---

### Delete Comment

```bash
curl -X DELETE https://agentpad.cc/api/docs/abc12345/comments/c_x1y2z3 \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** required

**Response 200:**
```json
{"ok": true}
```

---

### Comment formatting

Comment `text` and highlight notes are stored as-is (plain string) and rendered as a small markdown subset in the SPA. Agents reading comments via `GET /api/docs/:id/comments` see the raw text — they don't need to parse markdown to understand the content.

**Supported syntax (rendered in the SPA):**

| Syntax                       | Renders as                                |
|------------------------------|-------------------------------------------|
| `![alt](url)`                | Inline image, click to open lightbox      |
| `[label](url)`               | Link (opens in new tab)                   |
| `**bold**`                   | Bold                                      |
| `*italic*`                   | Italic                                    |
| `` `code` ``                 | Inline code                               |
| `@handle`                    | Mention (existing behavior)               |
| `[@Title](#/d/docId)`        | Cross-document reference (existing)       |
| Newlines                     | Line breaks                               |

**Including a screenshot in a comment:**

Upload the image first via `POST /api/docs/:id/images`, then paste the returned `markdown` field into the comment text:

```bash
# 1. Upload screenshot
curl -X POST https://agentpad.cc/api/docs/abc12345/images \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -F "purpose=comment" \
  -F "file=@screenshot.png"
# → {"markdown": "![](/api/images/img/abc12345/k7m2p4qr.png)", ...}

# 2. Reference it in a comment
curl -X POST https://agentpad.cc/api/docs/abc12345/comments \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "selectedText": "the layout",
    "text": "Looks broken on mobile:\n\n![](/api/images/img/abc12345/k7m2p4qr.png)"
  }'
```

**URL allowlist for image src and link href:** `http://`, `https://`, `/`, `#/d/`. Anything else (including `javascript:`, `data:`, `vbscript:`, protocol-relative `//host`) is stripped.

**Reply text** supports the same formatting.

---

## History

Version history is access-scoped for non-owners. Owners and the configured
public sample can see all versions. Other signed-in readers only see versions
created at or after their earliest currently active access source. Anonymous
anyone-with-link readers can read the current document but receive an empty
history list and `404 Version not found` for old version content.

### List Versions

```bash
curl https://agentpad.cc/api/docs/abc12345/history \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** `viewer` or higher

**Response 200:**
```json
{
  "versions": [
    {
      "n": 15,
      "ts": "2026-04-06T11:30:00Z",
      "author": "alice",
      "preview": "# Roadmap — ## Q2 Goals — - Ship v2.1 — - Hire 2 engineers"
    },
    {
      "n": 14,
      "ts": "2026-04-06T11:00:00Z",
      "author": "bob",
      "preview": "# Roadmap — ## Q2 Goals — - Ship v2 — - Hire 3 engineers"
    }
  ]
}
```

---

### Get Version

```bash
curl https://agentpad.cc/api/docs/abc12345/history/14 \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** `viewer` or higher

Hidden pre-access revisions return the same `404 Version not found` response
as missing revisions.

**Response 200:**
```json
{
  "revision": 14,
  "content": "# Roadmap\n\n## Q2 Goals\n\n- Ship v2\n- Hire 3 engineers"
}
```

---

### Restore Version

```bash
curl -X POST https://agentpad.cc/api/docs/abc12345/history/14/restore \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** required

**Role:** `editor` or owner. Hidden pre-access revisions cannot be restored and
return `404 Version not found`.

**Response 200:**
```json
{
  "revision": 16
}
```

Restoring creates a new revision with the content from the specified version.

---

## Recent Edits

Per-edit attribution for the line-based edit protocol. Surfaces who recently changed which lines, plus an optional free-form `intent` set by the editor. Designed to power "what did the agent just change?" highlights in clients.
For non-owners, recent edits are scoped to the same access-start boundary as
version history.

### List Recent Edits

```bash
curl https://agentpad.cc/api/docs/abc12345/recent-edits
curl https://agentpad.cc/api/docs/abc12345/recent-edits?since=12
```

**Auth:** `viewer` or higher

**Query params:**

| Param   | Type | Description                                                                                       |
|---------|------|---------------------------------------------------------------------------------------------------|
| `since` | int  | If supplied, returns every edit with `revision > since`, ordered ASC by revision. Omit to get the last 50 edits in ascending revision order. Returns `400` on a non-integer value. |

Edit records exist only for edits made via `POST /api/docs/:id/edit` after the `edit_records` table was introduced. Older edits and full-document `PUT`s are not represented.

**Line range forward-mapping:** normal edit ranges are stored in the post-edit
coordinates of the revision that produced them. A `move_block` record stores
its pre-move source range plus the signed displacement; the endpoint uses that
compatible representation to map the moved range and every intervening range
through upward or downward moves. If a previously contiguous attributed range
becomes discontiguous after a partial move, the response returns its smallest
enclosing current range. Later ordinary inserts and multiline replacements
expand or shift that range using their post-edit span. A discontiguous batch
delete cannot be reconstructed exactly from the legacy record fields, so an
overlap likewise returns the smallest safe enclosing range supported by its
stored start, end, and delta. The endpoint walks later revisions in order so
the returned `start_line` / `end_line` align with the **current** document
content. Existing-position no-op moves create no edit record.

**Response 200:**
```json
{
  "current_revision": 14,
  "edits": [
    {
      "revision": 12,
      "author": "Agent of foo@bar.com",
      "actor_type": "agent",
      "intent": "tighten introduction",
      "start_line": 5,
      "end_line": 8,
      "kind": "replace",
      "created_at": "2026-04-25T10:00:00Z"
    }
  ]
}
```

**Fields:**

| Field          | Type                                                                  | Description                                                              |
|----------------|-----------------------------------------------------------------------|--------------------------------------------------------------------------|
| `revision`     | int                                                                   | Revision the edit produced.                                              |
| `author`       | string                                                                | Resolved author string (e.g. `alice@example.com` or `Agent of alice@example.com`). |
| `actor_type`   | string \| null                                                        | The `actorType` supplied with the edit (`agent` for agent personas), or `null`. |
| `intent`       | string \| null                                                        | Free-form intent string supplied with the edit, or `null`.               |
| `start_line`   | int                                                                   | First line touched, in current-document coordinates.                     |
| `end_line`     | int                                                                   | Last line touched (inclusive), in current-document coordinates.          |
| `kind`         | `"insert"` \| `"delete"` \| `"replace"` \| `"append"` \| `"move_block"` \| `"mixed"` | Op type for single-op edits. `move_block` identifies an atomic reorder; its range follows the moved source block through later edits. `mixed` indicates a multi-op batch; `start_line` / `end_line` still describe the smallest contiguous range covering all changes. |
| `created_at`   | string                                                                | ISO 8601 timestamp.                                                      |

---

## Images

### Upload Image

```bash
curl -X POST https://agentpad.cc/api/docs/DOC_ID/images \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -F "purpose=document" \
  -F "file=@screenshot.png"
```

**Auth:** Required. Document-body uploads require `editor` or `owner`; comment attachment uploads require `commenter`, `editor`, or `owner`.

Uploads an image to a document. The image is stored in R2 and a URL is returned for use in markdown.

Set `purpose=document` for document-body images (the default) or `purpose=comment` for comment attachments. Uploads are subject to the same sample-doc, trash, and agent-pause gates as document/comment writes; pass `actorType=agent` in the multipart form when an agent uploads an image.

**Constraints:**
- Allowed types: `image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/svg+xml`
- Maximum file size: 5 MB
- Rate limit: 20 uploads per minute per user **and** 60 uploads per hour per document (whichever is hit first)

**Response (201):**

```json
{
  "key": "img/DOC_ID/k7m2p4qr.png",
  "url": "/api/images/img/DOC_ID/k7m2p4qr.png",
  "size": 142857,
  "contentType": "image/png",
  "markdown": "![](/api/images/img/DOC_ID/k7m2p4qr.png)"
}
```

Use the `markdown` field to insert the image into the document via the edit protocol:

```bash
curl -X POST https://agentpad.cc/api/docs/DOC_ID/edit \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"revision": N, "ops": [{"type": "append", "content": "![](/api/images/img/DOC_ID/k7m2p4qr.png)"}], "actorType": "agent", "intent": "insert uploaded image"}'
```

To give a document image an explicit rendered width, add a CommonMark image title that starts with `width=PIXELS`. The SPA exposes the same marker through image drag handles and exact size controls.

```markdown
![Diagram](/api/images/img/DOC_ID/k7m2p4qr.png "width=360")
![Diagram](/api/images/img/DOC_ID/k7m2p4qr.png "width=360 Optional title text")
```

To lay images out in two responsive columns, put the images in the same markdown paragraph and add `layout=two-column` at the start of each image title. The marker may be combined with `width=PIXELS`; any title text after the markers is preserved.

```markdown
![Before](/api/images/img/DOC_ID/before.png "layout=two-column Before")
![After](/api/images/img/DOC_ID/after.png "layout=two-column After")
```

The width and layout markers are optional. Removing them returns the image to its natural responsive size and normal document flow.

To non-destructively crop a document image, add a normalized `crop=x,y,width,height` marker to the image title. The browser editor exposes the same marker through the selected-image crop toolbar: the original image URL stays in the document, and the rendered image is masked to the visible crop rectangle. Resetting crop removes the marker. This is a visual crop/mask, not redaction; document readers and API clients can still access the original image URL and pixels.

```markdown
![Diagram](/api/images/img/DOC_ID/k7m2p4qr.png "width=360 crop=0.1,0.1,0.8,0.8 Optional title text")
```

API clients can perform the same workflow by replacing the image title marker through `POST /api/docs/:id/edit` or `PUT /api/docs/:id`. No image upload is required unless the client wants to create a separate flattened image asset.

### List Images

```bash
curl https://agentpad.cc/api/docs/DOC_ID/images \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Auth:** Required (document owner)

While `agentsPaused` is true, deleting an image requires the first-party browser human-control cookie; agent callers receive 423.

**Response (200):**

```json
{
  "images": [
    {
      "key": "img/DOC_ID/k7m2p4qr.png",
      "url": "/api/images/img/DOC_ID/k7m2p4qr.png",
      "size": 142857,
      "contentType": "image/png",
      "uploaded": "2026-04-17T10:00:00Z"
    }
  ]
}
```

### Get Image

```bash
curl https://agentpad.cc/api/images/img/DOC_ID/k7m2p4qr.png \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Auth:** Required for images attached to restricted docs. API clients may send `Authorization: Bearer YOUR_API_KEY`; browser image tags should use the short-lived `imageToken` returned by `GET /api/docs/:id`. Anonymous general-link readers can render document images by appending the same `accessToken` used to read the document:

```markdown
![](/api/images/img/DOC_ID/k7m2p4qr.png?imageToken=SHORT_LIVED_TOKEN)
![](/api/images/img/DOC_ID/k7m2p4qr.png?accessToken=GENERAL_LINK_TOKEN)
```

Do not put primary API keys in image URLs. Guest-owned docs fail closed like other restricted docs; the configured public sample doc remains public.

Returns the image binary with the appropriate `Content-Type` header and `Cache-Control: no-store` so trash/delete visibility checks are re-run on every request.

### Delete Image

```bash
curl -X DELETE https://agentpad.cc/api/images/img/DOC_ID/k7m2p4qr.png \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Auth:** Required (document owner)

**Response (200):**

```json
{ "ok": true }
```

**Note:** Images are automatically deleted when their parent document is deleted. Inline base64 data URIs (>1KB) are rejected by the edit and PUT endpoints — always upload via this API instead.

---

## Table Layout

Tables remain normal GFM markdown. To set explicit table and column widths, place an `agentpad-table` HTML comment immediately before the table:

```markdown
<!-- agentpad-table {"v":1,"mode":"fixed","width":720,"cols":[180,320,220]} -->
| Name | Notes | Status |
| --- | --- | --- |
| Isa | Wants both resize modes | Open |
```

`mode:"fixed"` uses CSS-pixel widths. `width` is the whole table width and `cols` are per-column widths. Browser and iOS clients hide the comment, render the table with the stored widths, and preserve the comment when saving. Removing the comment resets the table to the default responsive full-width layout.

To mark a table as explicitly fit-to-width, use:

```markdown
<!-- agentpad-table {"v":1,"mode":"fit"} -->
| Name | Notes |
| --- | --- |
| Example | Fits the editor width |
```

Agents and API clients can change table sizing through the same document write path used for all markdown edits, or use structural table edits for row/column changes.

### Structural Table Editing

Use `POST /api/docs/:id/table-edit` to edit top-level GFM tables without hand-building pipe-markdown line edits. The endpoint uses the same auth, sample-doc, trash, agent-pause, version-history, and revision-lock behavior as `POST /api/docs/:id/edit`.

```bash
curl -X POST https://agentpad.cc/api/docs/DOC_ID/table-edit \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "revision": 14,
    "actorType": "agent",
    "intent": "delete obsolete status column",
    "ops": [{"type":"delete_column","tableIndex":0,"columnIndex":2}]
  }'
```

`tableIndex`, `columnIndex`, and body `rowIndex` values are zero-based. `tableIndex` counts top-level tables only; fenced-code, HTML-comment, blockquoted, and list-nested tables are ignored. Body row indexes do not include the header row.

Supported ops: `insert_column` (`columnIndex`, `side:"before"|"after"`, optional `header`), `delete_column`, `insert_row` (`rowIndex`, `side`), `delete_row`, `delete_table`, `set_alignment` (`align:"left"|"center"|"right"|"none"`), `move_column` (`from`, `to`), and `move_row` (`from`, `to`). Deleting the last remaining column returns `400 {code:"last_column_delete_blocked"}`; use `delete_table` instead.

**Response 200:** returns the new `revision`, the resulting `lineCount`, and a `success` boolean set to true.

---

## Presence

### Get Active Users

```bash
curl https://agentpad.cc/api/docs/abc12345/presence \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** required

**Response 200:**
```json
{
  "users": [
    {
      "name": "alice@example.com",
      "email": "alice@example.com",
      "cursorLine": 5,
      "lastSeen": "2026-04-06T11:29:55Z"
    },
    {
      "name": "builder@example.com",
      "email": "builder@example.com",
      "actorType": "agent",
      "cursorLine": null,
      "lastSeen": "2026-04-06T11:29:50Z"
    }
  ]
}
```

Users are removed from presence after 60 seconds of inactivity.

---

### Update Presence

```bash
curl -X PUT https://agentpad.cc/api/docs/abc12345/presence \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef" \
  -H "Content-Type: application/json" \
  -d '{"name":"build-agent","cursorLine":12}'
```

**Auth:** required

**Request body:**

| Field        | Type   | Required | Description                         |
|--------------|--------|----------|-------------------------------------|
| `name`       | string | no       | Legacy compatibility field; identity is derived from the authenticated account and this value is not persisted |
| `cursorLine` | number | no       | Current cursor line (null if unknown)|

**Response 200:**
```json
{"ok": true}
```

Call this every 30 seconds to maintain presence. Omit `cursorLine` if you are reading but not editing.
Presence storage keeps only the account ID and cursor state; the response
resolves the live account email from Postgres so deleting an account cannot
leave its email in a collaborator's presence roster.

---

## Mentions

Both document content and comment text support `@mentions` with the syntax `@handle`. Handles are the email local-part, stripped of non-word characters (e.g. `alice@example.com` → `@alice`). A special `@myagent` alias is reserved for the caller's own agent persona — the SPA inserts it literally, and agents resolve it against the author of the surrounding text. Mentions are stored as plain text inside markdown and comment bodies; rendering (in the editor and in the comments pane) wraps `@word` fragments in `<span class="mention">`.

### List Mentionables

```bash
curl https://agentpad.cc/api/docs/abc12345/mentionables \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** required. The response always includes a `self` object so the caller can surface the `@myagent` alias for their own agent.

**Response 200:**
```json
{
  "self": {
    "email": "me@example.com",
    "handle": "me",
    "agentHandle": "myagent"
  },
  "candidates": [
    {
      "handle": "alice",
      "email": "alice@example.com",
      "kind": "user",
      "source": "presence"
    },
    {
      "handle": "bob",
      "email": "bob@example.com",
      "kind": "agent",
      "source": "comment-author"
    }
  ]
}
```

**Fields:**

| Field                  | Type                                  | Description                                                   |
|------------------------|---------------------------------------|---------------------------------------------------------------|
| `self`                 | object                                | The authenticated caller — used to surface the `@myagent` alias. |
| `self.email`           | string                                | Caller's email.                                               |
| `self.handle`          | string                                | Caller's email local-part handle.                             |
| `self.agentHandle`     | string                                | Literal text (currently `myagent`) inserted by the SPA when the caller picks "my agent" from autocomplete. |
| `candidates[]`         | array                                 | Other people discoverable on this document.                   |
| `candidates[].handle`  | string                                | Word-chars-only local-part used in `@handle` text.            |
| `candidates[].email`   | string                                | Full email — unique identifier.                               |
| `candidates[].kind`    | `"user"` \| `"agent"`                 | Whether this candidate represents a human or an agent persona.|
| `candidates[].source`  | `"presence"` \| `"comment-author"`    | Where the candidate was discovered.                           |

**Discovery:** candidates are the union of active presence entries (last 30s) and everyone who has posted a comment or reply on the document. The caller is excluded from `candidates` (they pick themselves via `self`). Handle collisions (different emails with the same local-part) are resolved first-seen-wins so each `@handle` in the response maps to exactly one candidate, with presence outranking past commenters.

**Response 401:** Missing or invalid `Authorization` header.
**Response 404:** Document not found.

Agents typically ignore this endpoint — they can write any `@handle` into edits or comments directly. It exists so UI clients can offer autocomplete.

---

## Stats

Public aggregate counters for the whole deployment. Numbers only — no
per-user data is exposed. Agents are inferred from the `Agent of ` author
prefix on comments and versions (see `lib/author.ts`); the database has no
`is_agent` flag.

### Get Stats

```bash
curl https://agentpad.cc/api/stats
```

Cached in KV for 60 seconds to avoid hammering Postgres. Public callers cannot bypass this cache.

**Response 200:**
```json
{
  "users": {
    "total": 123,
    "newLast7Days": 12,
    "newLast30Days": 45
  },
  "documents": {
    "total": 456,
    "guestOwned": 10,
    "newLast7Days": 34,
    "newLast30Days": 120,
    "activeLast24Hours": 8
  },
  "comments": {
    "total": 789,
    "rootThreads": 500,
    "replies": 289,
    "byHumans": 600,
    "byAgents": 189,
    "resolved": 300,
    "unresolved": 489,
    "newLast7Days": 90
  },
  "versions": {
    "total": 1000,
    "byHumans": 700,
    "byAgents": 300,
    "newLast7Days": 150
  },
  "agents": {
    "uniqueAuthorsInComments": 42,
    "uniqueAuthorsInVersions": 38
  },
  "humans": {
    "uniqueAuthorsInComments": 110
  },
  "generatedAt": "2026-04-16T12:34:56.000Z",
  "cacheTtlSeconds": 60,
  "cached": false
}
```

`cached: true` indicates the response came from KV; `cached: false` means
the aggregates were just recomputed and re-cached.

---

## Performance Events

Privacy-safe browser timing ingestion. The SPA sends Web Vitals and
app-flow timings here; agents usually read aggregates from the admin endpoint
instead of writing events directly.

### Record Performance Events

```bash
curl https://agentpad.cc/api/perf \
  -H "Content-Type: application/json" \
  -d '{"events":[{"name":"doc_fetch","durationMs":123,"route":"/api/docs/:id","status":200,"metadata":{"browser":"chrome","online":true,"docSizeBucket":"lt10kb"}}]}'
```

**Auth:** optional. Authenticated requests are rate-limited by user; anonymous
requests are rate-limited by IP.

Accepted event names are `web_vital_lcp`, `web_vital_fcp`,
`web_vital_inp`, `web_vital_cls`, `spa_boot`, `landing_render`,
`home_nav_from_doc_started`, `home_doc_teardown_done`, `home_shell_painted`,
`home_data_ready`, `home_docs_ready`, `home_tags_ready`,
`home_api_key_ready`, `home_render_done`, `home_painted`,
`doc_shell_painted`, `doc_get_started`, `doc_get_ready`, `doc_fetch`,
`doc_cache_hit`, `doc_cache_miss`, `doc_prefetch_started`,
`doc_prefetch_ready`, `doc_prefetch_failed`, `doc_prefetch_used`,
`doc_prefetch_validation_failed`, `doc_content_ready`,
`doc_side_data_started`, `doc_side_data_ready`,
`doc_create_editor_started`, `doc_create_editor_done`, `doc_editor_created`,
`doc_editor_mounted`, `doc_text_set`, `doc_text_painted`,
`doc_editor_editable`, `doc_comments_rendered`, `comments_fetch`,
`comment_add`, `comment_resolve`, and `edit_save`.

The endpoint accepts a single event or `{ "events": [...] }` with at most 20
events. Payloads are capped at 8 KB. Metadata is intentionally restricted to
coarse keys such as `browser`, `online`, `visibility`, `docSizeBucket`,
`lineCountBucket`, and `commentCountBucket`; document content, titles, emails,
raw URLs, tokens, and arbitrary metadata are rejected.

**Response 200:**
```json
{
  "ok": true,
  "accepted": 1
}
```

---

## Demo Workspace

Account-scoped demo mode for screen-share-safe demos. Demo documents are regular
documents owned by the signed-in user, but list/search/tag/trash surfaces hide
them in normal mode and return only demo documents while demo mode is enabled.
Eligibility is controlled by `ADMIN_EMAILS`, optional `DEMO_MODE_EMAILS`, and
the single configured `REVIEWER_EMAIL`. This keeps the App Store review account
on the same API-backed demo path as internal demos without maintaining a second
allowlist.

### Status

```bash
curl https://agentpad.cc/api/demo/status \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Response 200:**
```json
{
  "eligible": true,
  "enabled": false,
  "seeded": true,
  "docsCount": 16,
  "seedVersion": 2
}
```

### Enable or Disable

```bash
curl -X PATCH https://agentpad.cc/api/demo/preferences \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"enabled":true}'
```

When enabling an eligible account with no demo docs, the server seeds the
current sanitized demo pack.

### Reset Demo Documents

```bash
curl -X POST https://agentpad.cc/api/demo/reset \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Reset permanently deletes only documents mapped in `demo_documents` for the
caller, then recreates the seed pack and leaves demo mode enabled.

### Explicit Demo List

```bash
curl https://agentpad.cc/api/demo/docs \
  -H "Authorization: Bearer YOUR_API_KEY"
```

Returns the same document summary shape as `GET /api/docs`, scoped to demo docs.
Normal `GET /api/docs`, `GET /api/docs/visited`, `GET /api/docs/trash`, and
`GET /api/tags` automatically follow the account's current demo preference.

Opening a real owned document while demo mode is enabled returns `409` with
`code: "normal_workspace_required"`. Opening a demo owned document while demo
mode is disabled returns `409` with `code: "demo_mode_required"` so clients can
offer a workspace switch without returning document content.

---

## Themes

Themes are single declarative `.agentpad-theme.json` files shared by Web,
iPhone, and iPad. `System` is a virtual client fallback and never appears in
the stored catalog.

### ThemeManifestV1

```json
{
  "schemaVersion": 1,
  "id": "my-theme",
  "name": "My Theme",
  "description": "A fixed light appearance.",
  "appearance": "light",
  "typography": "system-sans",
  "style": "standard",
  "tokens": { "background": "#FFFFFF", "...": "all required tokens" }
}
```

IDs are lowercase kebab-case (max 48 characters); names are max 64 and
descriptions max 200. `appearance` is `light` or `dark`; `typography` is
`system-sans`, `brand-serif`, `editorial-serif`, or `monospace`; `style` is
`standard`, `editorial`, or `terminal`. Files are UTF-8 JSON capped at 64 KiB.
Unknown fields are rejected, including CSS, selectors, URLs, remote fonts,
scripts, and platform overrides.

Every token is required and accepts only `#RRGGBB` or `#RRGGBBAA`:

```text
background surface surfaceRaised surfaceSunken surfaceHover text textMuted
textFaint border link accent accentHover accentForeground accentTint agent
agentForeground agentTint danger success favorite codeInlineBackground
codeBlockBackground codeBlockText blockquoteBorder blockquoteBackground
tableHeaderBackground selectionBackground inputBackground inputFocusBackground
focusRing commentHighlight commentFocusHighlight editHighlight editDecoration
toastUndo
```

Primary `text` must keep at least 4.5:1 contrast on the canvas, all surface
tokens, inline-code, blockquote, table-header, selection, and input backgrounds.
Core text/control contrast failures reject the file. Secondary `textMuted` and
`textFaint` contrast findings on those backgrounds are returned as warnings for
explicit admin review.

### Public Catalog

```bash
curl https://agentpad.cc/api/themes
```

**Auth:** none

**Response 200:**

```json
{
  "schemaVersion": 1,
  "catalogVersion": "64-character-sha256",
  "themes": []
}
```

Only enabled themes are returned, ordered by ID. Responses include an `ETag`
equal to the quoted `catalogVersion` and
`Cache-Control: public, max-age=60, stale-while-revalidate=300`. Send
`If-None-Match` to receive `304` when unchanged.

Existing clients keep the exact v1 response and ETag behavior above. Clients
that need the administrator-selected default request `GET /api/themes?v=2`:

```json
{
  "schemaVersion": 2,
  "catalogVersion": "64-character-sha256",
  "themes": [],
  "defaultThemeId": null
}
```

`defaultThemeId` is `null` for System. The v2 ETag also changes when the
default changes. It is defensively returned as `null` if database state points
at a removed or disabled theme.

### Admin Theme Catalog

All routes below require an admin bearer key. Admin reads use `no-store`.

```bash
# List active, disabled, removed, or all themes (active is the default)
curl "https://agentpad.cc/api/admin/themes?status=all" \
  -H "Authorization: Bearer ..."

# Validate and canonicalize without writing
curl -X POST https://agentpad.cc/api/admin/themes/validate \
  -H "Authorization: Bearer ..." \
  -H "Content-Type: application/json" \
  --data-binary @my-theme.agentpad-theme.json

# Publish a new ID
curl -X POST https://agentpad.cc/api/admin/themes \
  -H "Authorization: Bearer ..." \
  -H "Content-Type: application/json" \
  -H "X-AgentPad-Theme-Warnings-Acknowledged: true" \
  --data-binary @my-theme.agentpad-theme.json
```

Validation returns `{valid, errors, warnings, manifest?, canonicalJson?,
existing?}`. Each issue has `{path, code, message}`. A successful create
returns `201`, `{theme, warnings}`, and `ETag: "1"`; an existing active,
disabled, or removed ID returns `409`. The acknowledgement header is required on create or
replace only when validation returned warnings; omitting it then returns `409`
with the unchanged warning list and performs no database write. Warning-free
themes do not need the header. Validation itself never requires acknowledgement.

Admin records have this stable shape:

```json
{
  "id": "my-theme",
  "revision": 3,
  "rowVersion": 3,
  "status": "active",
  "createdAt": "2026-07-13T10:00:00.000Z",
  "updatedAt": "2026-07-13T11:00:00.000Z",
  "removedAt": null,
  "disabledAt": null,
  "createdBy": "admin@example.com",
  "updatedBy": "admin@example.com",
  "removedBy": null,
  "disabledBy": null,
  "checksum": "64-character-sha256",
  "manifest": {}
}
```

The list response is `{themes, defaultThemeId, settingsRowVersion}`. Theme
`status` is `active`, `disabled`, or `removed`.

Replacement, availability changes, removal, and restoration use optimistic concurrency. Send the
record's positive `rowVersion` as `If-Match: "3"` (unquoted and weak ETags are
also accepted). Missing `If-Match` returns `428`; a stale value returns `412`.

```bash
# Replace; manifest ID must equal the path ID
curl -X PUT https://agentpad.cc/api/admin/themes/my-theme \
  -H "Authorization: Bearer ..." -H 'If-Match: "3"' \
  -H "X-AgentPad-Theme-Warnings-Acknowledged: true" \
  -H "Content-Type: application/json" \
  --data-binary @my-theme.agentpad-theme.json

# Soft-remove and restore
curl -X DELETE https://agentpad.cc/api/admin/themes/my-theme \
  -H "Authorization: Bearer ..." -H 'If-Match: "4"'
curl -X POST https://agentpad.cc/api/admin/themes/my-theme/restore \
  -H "Authorization: Bearer ..." -H 'If-Match: "5"'

# Hide without deleting, then make available again
curl -X POST https://agentpad.cc/api/admin/themes/my-theme/disable \
  -H "Authorization: Bearer ..." -H 'If-Match: "6"'
curl -X POST https://agentpad.cc/api/admin/themes/my-theme/enable \
  -H "Authorization: Bearer ..." -H 'If-Match: "7"'

# Select a default, or pass null to use System. If-Match is settingsRowVersion.
curl -X PUT https://agentpad.cc/api/admin/theme-catalog/default \
  -H "Authorization: Bearer ..." -H 'If-Match: "1"' \
  -H "Content-Type: application/json" \
  --data '{"themeId":"my-theme"}'

# Immutable history and canonical downloads (removed themes remain available)
curl https://agentpad.cc/api/admin/themes/my-theme/revisions \
  -H "Authorization: Bearer ..."
curl -OJ "https://agentpad.cc/api/admin/themes/my-theme/download?revision=2" \
  -H "Authorization: Bearer ..."
```

Every theme state change creates an immutable revision. A disabled theme is
excluded from the public catalog but remains editable and downloadable.
Removing and restoring a disabled theme preserves its disabled state. The
current default cannot be disabled or removed; choose another enabled default
or System first. Removed and disabled themes cannot become the default.
Downloads return the exact
canonical JSON as `<id>.agentpad-theme.json`; omitting `revision` downloads the
current revision.

---

## Admin

Endpoints gated by an allowlist: the authenticated user's email must appear
in the `ADMIN_EMAILS` secret (comma-separated). Non-admin authenticated
users receive `403 {"error":"Forbidden"}`. Anonymous callers receive
`401`.

Admin responses deliberately exclude sensitive fields — `api_key`,
`auth_codes.*`, document content (`versions.content`, KV `doc:*`), and
comment text are never returned.

### Who Am I

```bash
curl https://agentpad.cc/api/admin/me \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** required (not admin-gated — allows the SPA to decide whether to
show the admin link without provoking a 403).

**Response 200:**
```json
{
  "email": "alvaro@agentpad.cc",
  "isAdmin": true
}
```

---

### Overview

```bash
curl "https://agentpad.cc/api/admin/overview?days=30&topN=10" \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** admin

Bundled payload for the admin dashboard: aggregate totals, recent-window
counts, top users across four rankings, and a daily time-series.

**Query params (optional):**

| Param  | Type | Description                                           |
|--------|------|-------------------------------------------------------|
| `days` | int  | Time-series window, clamped to [1, 180]. Default 30.  |
| `topN` | int  | Rows per ranking, clamped to [1, 50]. Default 10.     |

**Response 200:**
```json
{
  "totals": {
    "users": 123,
    "documents": 456,
    "guestDocuments": 10,
    "comments": 789,
    "commentsByHumans": 600,
    "commentsByAgents": 189,
    "commentsResolved": 300,
    "versions": 1000,
    "versionsByHumans": 700,
    "versionsByAgents": 300,
    "uniqueAgentAuthorsComments": 42,
    "uniqueAgentAuthorsVersions": 38,
    "uniqueHumanAuthorsComments": 110
  },
  "recent": {
    "usersLast7Days": 12,
    "usersLast30Days": 45,
    "documentsLast7Days": 34,
    "documentsLast30Days": 120,
    "documentsActiveLast24Hours": 8,
    "commentsLast7Days": 90,
    "versionsLast7Days": 150
  },
  "topUsers": {
    "byDocs":          [{"email":"a@x.com","docs":42,"lastActive":"..."}],
    "byComments":      [{"email":"a@x.com","comments":17,"lastActive":"..."}],
    "byVersions":      [{"email":"a@x.com","versions":55,"lastActive":"..."}],
    "byRecentActivity":[{"email":"a@x.com","lastActive":"..."}]
  },
  "timeSeries": {
    "days": 30,
    "series": [
      {"date":"2026-03-18","newUsers":2,"newDocs":7,"newComments":14,"newVersions":9}
    ]
  },
  "topN": 10,
  "generatedAt": "2026-04-16T12:34:56.000Z",
  "cached": false
}
```

The ranked payload contains account emails, so this endpoint is always
computed on demand and is never persisted in KV. `cached` remains `false` for
backwards-compatible response decoding.

Per-user aggregates key off `comments.author = users.email` and
`versions.author = users.email`. Agent-authored rows (prefix `Agent of `)
do not contribute to top-user rankings; aggregate agent totals live in
`totals.commentsByAgents` / `totals.versionsByAgents`.

---

### List Users

```bash
curl "https://agentpad.cc/api/admin/users?limit=50&sort=docs" \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** admin

Paginated per-user breakdown. Always returns accounts from the `users`
table; anonymous authors are not included.

**Query params (optional):**

| Param    | Type   | Description                                                           |
|----------|--------|-----------------------------------------------------------------------|
| `limit`  | int    | Page size, clamped to [1, 200]. Default 50.                           |
| `offset` | int    | Pagination offset. Default 0.                                         |
| `sort`   | string | `docs` \| `comments` \| `versions` \| `created` \| `active`. Default `active`. |
| `q`      | string | Case-insensitive substring match on email. Trimmed, max 200.          |

**Response 200:**
```json
{
  "users": [
    {
      "id": "uuid",
      "email": "alice@example.com",
      "createdAt": "2025-12-01T10:00:00Z",
      "docCount": 12,
      "commentCount": 4,
      "versionCount": 23,
      "lastActiveAt": "2026-04-15T10:00:00Z"
    }
  ],
  "total": 124,
  "limit": 50,
  "offset": 0,
  "sort": "docs",
  "q": ""
}
```

---

### Activity Time-Series

```bash
curl "https://agentpad.cc/api/admin/activity?days=30" \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** admin

Gap-filled daily activity counts. Returns one row per day in the window,
zeros included, suitable for direct chart rendering.

**Query params (optional):**

| Param  | Type | Description                                            |
|--------|------|--------------------------------------------------------|
| `days` | int  | Window length, clamped to [1, 180]. Default 30.        |

**Response 200:**
```json
{
  "days": 30,
  "series": [
    {"date":"2026-03-18","newUsers":2,"newDocs":7,"newComments":14,"newVersions":9}
  ],
  "generatedAt": "2026-04-16T12:34:56.000Z"
}
```

---

### Performance

```bash
curl "https://agentpad.cc/api/admin/performance?days=7&surface=spa&name=doc_fetch" \
  -H "Authorization: Bearer 0123456789abcdef0123456789abcdef"
```

**Auth:** admin

Grouped latency aggregates for sampled performance events. Returns counts,
error rates, and percentile timings only; no document content, title, email,
raw URL, token, or per-user identifier is exposed.

**Query params (optional):**

| Param     | Type   | Description                                      |
|-----------|--------|--------------------------------------------------|
| `days`    | int    | Window, snapped to 1, 7, or 30. Default 7.       |
| `surface` | string | `spa`, `worker`, or `mcp`.                       |
| `name`    | string | Whitelisted performance event name.              |
| `route`   | string | Normalized route such as `/api/docs/:id`.        |

**Response 200:**
```json
{
  "days": 7,
  "filters": {"surface":"spa","name":"doc_fetch"},
  "groups": [
    {
      "surface": "spa",
      "name": "doc_fetch",
      "route": "/api/docs/:id",
      "statusBucket": "2xx",
      "count": 42,
      "errorCount": 0,
      "errorRate": 0,
      "p50Ms": 120,
      "p90Ms": 220,
      "p95Ms": 260,
      "p99Ms": 300
    }
  ],
  "generatedAt": "2026-06-29T12:34:56.000Z"
}
```

---

## Agent Pause

Per-document soft mute on agent-facing writes. Owners flip a boolean on the
doc; while it's on, the gated mutation endpoints below return
**HTTP 423 (Locked)**. The decision is server-side and does not trust
caller-supplied `actorType`.

**Agent pause never gates reads.** Authorized reads (`GET /api/docs/:id`,
`?format=numbered`, comments list, history list, recent edits, and `GET`
presence) plus authenticated read endpoints such as mentionables and `agent-setup` keep working
with the caller's normal access.
Agents can poll for the pause state itself without hitting 423. `actorType`
remains an attribution hint for comment/version author labels and `/api/stats`
aggregation (see `lib/author.ts`), not the pause boundary.

### Toggle the pause

Owners toggle pause/resume from the first-party browser UI. The underlying API field is `PATCH /api/docs/:id` with `{"agentsPaused": true}` or `{"agentsPaused": false}`, but a plain bearer-token `curl` is intentionally rejected: the request must also carry the short-lived HttpOnly human-control cookie minted by browser sign-in.

**Auth:** required + must be the document owner + the human-control cookie. The server does not trust `actorType` for this boundary, so a paused agent cannot resume itself by changing or omitting an attribution hint.

### Endpoints gated by the pause

These reject with 423 while `agentsPaused = true` unless the request is a first-party browser human action carrying a valid human-control cookie. Explicit `actorType: "agent"` writes stay blocked even with that cookie:

- `PATCH /api/docs/:id` for metadata, tags, or favorite changes unless the first-party browser sends the cookie
- `DELETE /api/docs/:id` unless the first-party browser sends the cookie
- `POST /api/docs/:id/restore` unless the first-party browser sends the cookie
- `POST /api/docs/:id/edit`
- `PUT /api/docs/:id`
- `POST /api/docs/:id/comments`
- `POST /api/docs/:id/comments/:cid/reply`
- `PATCH /api/docs/:id/comments/:cid/text`
- `PATCH /api/docs/:id/comments/:cid`
- `DELETE /api/docs/:id/comments/:cid`
- `POST /api/docs/:id/history/:revision/restore`
- `POST /api/docs/:id/images`
- `DELETE /api/images/img/:docId/:file`
- `PUT /api/docs/:id/presence`
- `PATCH /api/docs/:id/access/settings`
- `POST /api/docs/:id/access/grants`
- `PATCH /api/docs/:id/access/grants/:grantId`
- `DELETE /api/docs/:id/access/grants/:grantId`
- `POST /api/docs/:id/access/requests`
- `PATCH /api/docs/:id/access/requests/:requestId`
- `PUT /_api/store/actions:{docId}`
- `DELETE /_api/store/actions:{docId}`

MCP document write tools use the same pause gate and cannot send the
browser-only cookie. The MCP metadata tool deliberately does not expose
`agentsPaused`, so an MCP agent cannot resume itself.

### Error response

```json
{
  "error": "Agents are paused on this document",
  "code": "human_control_required",
  "details": "The owner has paused agent-facing writes. Check back later or ask the owner to resume.",
  "agentsPaused": true
}
```

**Status:** `423 Locked`

`code: "human_control_required"` is present when a signed-in human caller omits
the human-control cookie. Explicit agent writes return the same 423 without that
code and should not retry.

**Recommended agent flow on 423:** stop retrying, surface the situation to the
human you're collaborating with, and re-fetch `agent-setup` (or the plain
`GET /api/docs/:id`) to confirm `agentsPaused` is back to `false` before
trying again.

---

## Sample Document

agentpad keeps a single canonical "Welcome" doc that doubles as a public, read-only tour of the product. New human signups land directly inside their own personal copy of it; signed-in users can re-open the public showcase from a "Take the tour" link in the home hero; anonymous visitors get a "See a sample first" link on the login wall.

For agents, the sample doc is the thing you bounce off when a non-owner write hits a `403`. The two endpoints below are everything you need to know.

### Get Sample Doc ID

`GET /api/sample-doc`

Returns the configured sample doc id, or `null` when no sample is configured or when the configured doc has been deleted. **No auth required.** Cheap to poll.

**Response:**

```json
{ "id": "mslfcnvg" }
```

or

```json
{ "id": null }
```

The id (when set) is a normal 8-char document id and works against every other endpoint in this doc — `GET /api/docs/:id`, `GET /api/docs/:id/comments`, `GET /api/docs/:id/agent-setup`, etc.

### Sample-Doc Lock

Every mutation endpoint that touches the sample doc rejects non-owner writes with **HTTP 403** and a friendly hint. This applies to:

- `PUT /api/docs/:id`
- `PATCH /api/docs/:id` (title / icon / agentsPaused / favorited / tags)
- `DELETE /api/docs/:id`
- `POST /api/docs/:id/edit`
- `POST /api/docs/:id/comments`
- `POST /api/docs/:id/comments/:cid/reply`
- `PATCH /api/docs/:id/comments/:cid/text`
- `PATCH /api/docs/:id/comments/:cid` (resolve)
- `DELETE /api/docs/:id/comments/:cid`
- `POST /api/docs/:id/history/:revision/restore`
- `POST /api/docs/:id/images` (sample-doc gate and purpose-specific checks)

**Error response:**

```json
{
  "error": "Sample doc is read-only",
  "details": "This is the public agentpad sample. Create your own document to edit, comment, or invite an agent."
}
```

**Status:** `403 Forbidden`

`GET /api/docs/:id/presence` remains readable with normal document access, but
`PUT /api/docs/:id/presence` is locked while agents are paused because it mutates
the live roster/cursor surface.

**Recommended agent flow when you receive a 403 here:**

1. `GET /api/docs/:id` to fetch the sample's content.
2. `POST /api/docs` with `{ title: "Copy of Welcome to agentpad", content, icon }` to create your caller's own copy.
3. Continue your work against the new doc id.

This is the same fork the SPA does behind its "Make a copy" button.

---

## Legacy (Migration)

Backwards-compatible action-queue shim from the original drophere key-value API. Provided for migration only.

Public raw KV reads, writes, and deletes are not supported. The Worker stores
document bodies, static assets, presence, and operational values in the same KV
namespace, so raw key access is intentionally blocked.

### Get Action Queue Value

```bash
curl https://agentpad.cc/_api/store/actions:DOC_ID \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Response 200:** `{"value":[...]}`. Action objects include a stable `actionId`
when one was not supplied by the writer. Clients must pass processed IDs back
when acknowledging work.

**Response 401:** Missing or invalid API key.

**Response 403:** Key is not an action queue key, or the caller does not own the document.

**Response 404:** Key not found.

---

### Set Action Queue Value

The only supported legacy write path is the internal action queue key for a
document owned by the caller.

```bash
curl -X PUT https://agentpad.cc/_api/store/actions:DOC_ID \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"value":[]}'
```

**Response 200:** `{"ok": true}`

**Response 401:** Missing or invalid API key.

**Response 403:** Key is not an action queue key, or the caller does not own the document.

**Response 423:** The owner has paused agent-facing writes on the document.

---

### Acknowledge Processed Actions

```bash
curl -X DELETE https://agentpad.cc/_api/store/actions:DOC_ID \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"processedActionIds":["ACTION_ID"]}'
```

**Response 200:** `{"ok": true, "processedActionIds": ["ACTION_ID"], "ackedCount": 1}`

When `processedActionIds` is provided, the Worker records an acknowledgement
without deleting the queue value, so actions appended concurrently are not
dropped. Omitting a body preserves the legacy full-key delete behavior for
migration tooling.

**Response 401:** Missing or invalid API key.

**Response 403:** Key is not an action queue key, or the caller does not own the document.

**Response 423:** The owner has paused agent-facing writes on the document.

---

## MCP / ChatGPT

agentpad exposes a remote [Model Context Protocol](https://modelcontextprotocol.io) server for ChatGPT (web + desktop), Claude Desktop, Cursor, MCP Inspector, and any other MCP-aware client. The MCP server is a thin adapter over the same write helpers the REST API uses, so write authorization (sample-doc lock, document roles, agents-paused gate) stays in one place.

**Endpoints (three paths, three auth modes):**

| Path | Auth | Use case |
|---|---|---|
| `* https://agentpad.cc/mcp/oauth` | OAuth 2.1 | **Recommended.** ChatGPT regular Connectors UI (no developer mode, memory stays on), future Apps directory, Claude Desktop OAuth mode. See [`oauth.md`](./oauth.md) for the full flow. |
| `* https://agentpad.cc/mcp/:apiKey` | URL-embedded API key | ChatGPT developer-mode connectors (when OAuth isn't an option) |
| `* https://agentpad.cc/mcp` | `Authorization: Bearer …` header | MCP Inspector, Claude Desktop header-mode, Cursor, custom integrations |

**Transport:** Streamable HTTP (JSON-RPC 2.0). CORS is handled by the SDK with `Access-Control-Allow-Origin: *`.

**Why three paths?** ChatGPT consumer mode does not accept user-supplied auth headers. The two viable auth modes for that surface are full OAuth 2.1 (recommended; one-click install) or "no auth" with the secret in the URL (developer-mode fallback). The header path stays for any other MCP client that supports it.

**Rate limit:** 60 requests / 60 seconds per user (sliding window, Durable Object-backed). Pre-handler errors (missing/invalid API key, rate-limit exceeded) return plain HTTP `{error, retryAfter?}` JSON with the appropriate status code (401, 429). Tool-level errors (an `agentpad_*` handler returning `isError: true`) are JSON-RPC-wrapped per the MCP spec.

### Tool surface

| Tool                                | Read/Write | Destructive | Idempotent | Description |
|-------------------------------------|-----------:|:-----------:|:----------:|-------------|
| `agentpad_search`                   | read       | no          | yes        | Full-text search across owned, explicitly shared, eligible same-domain, and previously visited general-link docs the user can read (Company Knowledge contract) |
| `agentpad_fetch`                    | read       | no          | yes        | Fetch full document body by id (Company Knowledge contract) |
| `agentpad_list_documents`           | read       | no          | yes        | Paginated list of readable live docs with optional `tags[]` and caller-scoped `favorited` filters |
| `agentpad_get_document`             | read       | no          | yes        | Edit-ready context: numbered lines + active unresolved comments/highlights (including each `anchorStatus`) + tag vocab. Optional `target` returns only the relevant line window. |
| `agentpad_resolve_target`           | read       | no          | yes        | Resolve `comment:`, `highlight:`, `anchor:`, or `section:` targets into current numbered line context and nearby unresolved comments |
| `agentpad_get_document_access`      | read       | no          | yes        | Inspect your role/capabilities and pending upgrade request; owners also receive settings, grants, and requests |
| `agentpad_create_document`          | write      | no          | durable with `client_request_id`; content hash (60s) | Create a new live doc; exact retries with an explicit request id return the original document even if short-window coordinator completion failed |
| `agentpad_edit_document`            | write      | no          | revision-locked | Apply line-based edit ops (replace / insert / delete / append) or one standalone atomic `move_block`. 409 on revision mismatch. |
| `agentpad_edit_table`               | write      | no          | revision-locked | Apply structural table ops to top-level GFM tables; updates AgentPad table layout metadata. |
| `agentpad_list_suggestions`         | read       | no          | yes        | List open, accepted, rejected, or all suggested edits for a document. |
| `agentpad_create_suggestion`        | write      | no          | no; revision-checked | Create a non-mutating suggested line edit for human/API review. |
| `agentpad_update_suggestion`        | write      | no          | revision-locked | Accept or reject a suggested edit. Accept applies ops only when the document is still at the suggestion base revision. |
| `agentpad_list_review_proposals`    | read       | no          | yes        | List grouped review proposals and their block states. Registered only when `reviewProposals` is enabled. |
| `agentpad_create_review_proposal`   | write      | no          | no; revision-checked | Create a non-mutating grouped review proposal. Blocks may attach to normal comments when `reviewThreadReplacements` is enabled; highlights are rejected. |
| `agentpad_update_review_block`      | write      | no          | no         | Accept or reject one review block. This never mutates document content. |
| `agentpad_update_review_proposal`   | write      | no          | no         | Accept all blocks or reject an entire review proposal. This never mutates document content. |
| `agentpad_apply_review_proposal`    | write      | no          | revision-locked | Apply accepted blocks as one normal document revision after every block is accepted or rejected. |
| `agentpad_list_review_messages`     | read       | no          | yes        | List document-level review chat messages. Registered only when `reviewChat` is enabled. |
| `agentpad_create_review_message`    | write      | no          | yes with `clientRequestId` | Create a non-mutating review request/message, optionally linked to a proposal/comment or preset. |
| `agentpad_list_review_presets`      | read       | no          | yes        | List static slash-command review presets. Registered only when `reviewPresets` is enabled. |
| `agentpad_update_document_metadata` | write      | no          | yes        | Update tags as editor; title / icon as owner; or favorited-only for any readable doc. `agentsPaused` is intentionally NOT exposed — agents cannot self-unpause. |
| `agentpad_request_document_access`  | write      | no          | yes        | Request viewer/commenter/editor access; duplicate pending requests dedupe, and lower-role pending requests upgrade |
| `agentpad_update_document_access`   | write      | no          | yes        | Owners can change settings, upsert/revoke grants, and approve/deny requests. Explicit-grant editors with `capabilities.invite` can perform grant-only delegated invites; those return generic success, never update existing grants, and remain blocked while `agentsPaused` is true. |
| `agentpad_delete_document`          | write      | **yes**     | yes        | Move a document to the owner's 30-day trash. Recoverable via `agentpad_restore_document`. |
| `agentpad_restore_document`         | write      | no          | yes        | Restore a trashed document so it becomes editable again. Idempotent for already-live docs. |
| `agentpad_add_comment`              | write      | no          | with `client_request_id` (60s) | Anchor a comment or highlight to a verbatim quote. `kind` defaults to `comment`; dedupe includes kind, occurrence, selected text, and text. |
| `agentpad_update_comment`           | write      | no          | yes        | `action`: `reply` (requires `text`, root comments or highlights) / `edit` (own comment, highlight note, or reply) / `resolve` / `unresolve`. Highlight notes can be cleared; cross-doc id substitution rejected. |
| `agentpad_delete_comment`           | write      | **yes**     | yes        | Delete a comment/highlight + cascade replies where present. Split out from `update_comment` because `destructiveHint` is per-tool. |

All write tools propagate `actorType: 'agent'` internally where attribution is stored — there is no caller-supplied override. Sample-doc lock and agents-paused gate are enforced via the shared `assertMcpCanWrite` helper before document/comment/access/trash DB writes. Comment and highlight-note text fields are capped at 16 KiB; `selectedText` at 8 KiB; tags at 20 entries (truncated to `MAX_TAGS_PER_DOC` after normalization).

### Install (ChatGPT developer mode)

1. ChatGPT → Settings → Apps & Connectors → Advanced → enable Developer mode
2. Settings → Connectors → Create
3. Paste `https://agentpad.cc/mcp/{your-api-key}` from agentpad's landing page
4. Set Auth = "No authentication", save

The agentpad landing page has a one-click "Copy URL" card that pre-fills your personalized URL.

### Notes for clients

- `agentpad_search` returns `{results: [{id, title, url}]}` for readable owned and shared docs — Company Knowledge contract requires exactly these fields.
- `agentpad_fetch` returns `{id, title, text, url, metadata}` — `text` is the full markdown body. Responses are capped at 256 KB; truncation is signalled via `metadata.truncated: true`. It accepts optional `accessToken` for first-time access to a general-link document.
- `agentpad_list_documents` returns effective `role` and `accessSource` for shared docs. For live docs, use `hasMore`/`nextOffset`; `total` is `null` because shared discovery can span broad grant/domain/general-link sets. Trash lists still return an exact `total`.
- `agentpad_get_document` is the agentpad-native tool for *planning* edits — returns numbered lines (`"1| ..."` per line) so the model can target line numbers cleanly when v1 ships `edit_document`. It accepts optional `accessToken` for first-time access to a general-link document and optional `target` (`comment:<id>`, `highlight:<id>`, `anchor:<id>`, or `section:<occurrence>:<slug>`) for scoped reads of large documents. When a comment or highlight has a durable anchor, targets use its prefix/suffix context so later duplicate text does not redirect the target to the wrong occurrence; legacy rows without one retain the selected-text/occurrence fallback. Detached and ambiguous anchors remain explicitly unresolved and include their anchor status; the tool never guesses or silently resolves them.
- `agentpad_resolve_target` returns the same target resolution payload as REST `GET /api/docs/:id/targets/:target`, including current line range, scoped numbered lines, and nearby unresolved comments. Use it when a human points you at a copied comment, section, highlight, or selection link.
- All write paths attribute to `Agent of {email}` (matches the existing `actorType: "agent"` convention from `lib/author.ts`). MCP is treated as an agent surface — not as the user's keyboard.

---

## REST Error Format

REST API errors use a flat JSON envelope:

```json
{
  "error": "Human-readable description.",
  "details": "Optional extra context."
}
```

Some endpoints include route-specific fields next to `error` and `details`, such as `currentRevision` on revision conflicts or `retryAfter` on rate limits. MCP transport and tool errors are different: they are JSON-RPC-wrapped per the MCP spec and are described in the MCP section above.

Unhandled 500 responses include a server-generated `requestId` in the JSON body
and the same value in the `X-Request-ID` response header. Include this value
when reporting a server error so operators can correlate it with Worker logs.

### Common REST Statuses

| HTTP Status | Description                                |
|-------------|--------------------------------------------|
| 400         | Missing or invalid fields                  |
| 401         | Missing or invalid API key                 |
| 403         | Not the document owner or action forbidden |
| 404         | Document or comment not found              |
| 409         | Revision mismatch; re-read and retry       |
| 412         | Stale optimistic-concurrency precondition  |
| 413         | Request file exceeds its size limit        |
| 423         | Agents paused on this document; see [Agent Pause](#agent-pause) |
| 428         | Required `If-Match` header is missing      |
| 429         | Too many requests                          |
| 500         | Server error; unhandled failures include a request ID |

---

## Quick Reference

| Method   | Path                                  | Auth     | Description                 |
|----------|---------------------------------------|----------|-----------------------------|
| `POST`   | `/api/auth/request-code`              | none     | Request email verification  |
| `POST`   | `/api/auth/verify-code`               | none     | Verify code; mint browser session for the SPA or preserve bearer response for non-browser clients |
| `POST`   | `/api/auth/verify-link`               | browser  | Consume a first-party link challenge and mint a revocable browser session |
| `GET`    | `/api/auth/session`                   | required | Read current browser/bearer identity |
| `POST`   | `/api/auth/logout`                    | browser  | Revoke current browser session and clear cookies |
| `GET`    | `/api/api-key`                        | required | Get stable API key status   |
| `POST`   | `/api/api-key/remember`               | required | Store encrypted copy of current key |
| `POST`   | `/api/api-key/rotate`                 | required or reset token | Explicitly rotate API key   |
| `GET`    | `/api/docs`                           | required | List your documents         |
| `POST`   | `/api/docs`                           | required | Create document             |
| `GET`    | `/api/docs/:id`                       | conditional | Read document            |
| `POST`   | `/api/docs/:id/visit`                 | viewer   | Record a real document open without returning content |
| `GET`    | `/api/docs/:id/targets/:target`       | viewer   | Resolve a comment/section/anchor/highlight target to current line context |
| `POST`   | `/api/docs/:id/anchors`               | viewer   | Persist a lazy selection anchor for a copied selection link |
| `POST`   | `/api/docs/:id/duplicate`             | viewer   | Duplicate a readable document |
| `PUT`    | `/api/docs/:id`                       | editor   | Replace document content    |
| `PATCH`  | `/api/docs/:id`                       | field-aware | Update tags (`editor`), title / icon / agentsPaused (`owner`), or caller-private favorite (`viewer`) |
| `GET`    | `/api/docs/:id/access`                | conditional | Inspect caller access    |
| `PATCH`  | `/api/docs/:id/access/settings`       | owner    | Update access settings      |
| `POST`   | `/api/docs/:id/access/grants`         | owner/inviter | Grant or invite access      |
| `PATCH`  | `/api/docs/:id/access/grants/:grantId` | owner   | Change grant role           |
| `DELETE` | `/api/docs/:id/access/grants/:grantId` | owner   | Revoke grant                |
| `POST`   | `/api/docs/:id/access/requests`       | required | Request access              |
| `PATCH`  | `/api/docs/:id/access/requests/:requestId` | owner | Approve or deny request     |
| `GET`    | `/api/tags`                           | required | User's tag vocabulary (sorted by frequency) |
| `DELETE` | `/api/docs/:id`                       | owner    | Delete document             |
| `POST`   | `/api/docs/:id/edit`                  | editor   | Line-based edits            |
| `GET`    | `/api/docs/:id/suggestions`           | viewer   | List suggested edits        |
| `POST`   | `/api/docs/:id/suggestions`           | commenter | Create suggested edit      |
| `PATCH`  | `/api/docs/:id/suggestions/:sid`      | editor   | Accept or reject suggested edit |
| `GET`    | `/api/docs/:id/review-proposals`      | viewer   | List grouped review proposals |
| `POST`   | `/api/docs/:id/review-proposals`      | commenter | Create non-mutating review proposal |
| `PATCH`  | `/api/docs/:id/review-proposals/:pid/blocks/:bid` | editor | Accept or reject one review block |
| `PATCH`  | `/api/docs/:id/review-proposals/:pid` | editor   | Accept all or reject all review blocks |
| `POST`   | `/api/docs/:id/review-proposals/:pid/apply` | editor | Apply accepted blocks as one revision |
| `GET`    | `/api/docs/:id/review-messages`       | viewer   | List review chat messages   |
| `POST`   | `/api/docs/:id/review-messages`       | commenter | Create review chat message |
| `GET`    | `/api/docs/:id/review-presets`        | viewer   | List static review presets  |
| `GET`    | `/api/docs/:id/comments`              | viewer   | List comments/highlights    |
| `POST`   | `/api/docs/:id/comments`              | commenter | Add comment/highlight      |
| `POST`   | `/api/docs/:id/comments/:cid/reply`   | commenter | Reply to comment           |
| `PATCH`  | `/api/docs/:id/comments/:cid/text`    | commenter | Edit own comment text or highlight note |
| `PATCH`  | `/api/docs/:id/comments/:cid`         | editor   | Resolve comment/highlight   |
| `DELETE` | `/api/docs/:id/comments/:cid`         | editor   | Delete comment/highlight    |
| `GET`    | `/api/docs/:id/history`               | viewer   | List visible version history |
| `GET`    | `/api/docs/:id/history/:rev`          | viewer   | Get visible specific version |
| `POST`   | `/api/docs/:id/history/:rev/restore`  | editor   | Restore a visible version   |
| `GET`    | `/api/docs/:id/recent-edits`          | viewer   | List visible recent line-attributed edits |
| `POST`   | `/api/docs/:id/images`                | commenter/editor | Upload image       |
| `GET`    | `/api/docs/:id/images`                | owner    | List images                 |
| `GET`    | `/api/images/img/:docId/:file`        | conditional | Get image                |
| `DELETE` | `/api/images/img/:docId/:file`        | owner    | Delete image                |
| `GET`    | `/api/docs/:id/presence`              | required | Get active users            |
| `PUT`    | `/api/docs/:id/presence`              | required | Update your presence        |
| `GET`    | `/api/docs/:id/mentionables`          | required | List @mention candidates    |
| `GET`    | `/api/stats`                          | none     | Public aggregate counters   |
| `POST`   | `/api/perf`                           | optional | Browser performance events  |
| `GET`    | `/api/themes`                         | none     | Enabled theme catalog; `v=2` adds default |
| `GET`    | `/api/admin/themes`                   | admin    | List active/disabled/removed themes |
| `POST`   | `/api/admin/themes/validate`          | admin    | Validate without publishing |
| `POST`   | `/api/admin/themes`                   | admin    | Publish a new theme ID      |
| `PUT`    | `/api/admin/themes/:id`               | admin    | Replace with `If-Match`     |
| `DELETE` | `/api/admin/themes/:id`               | admin    | Soft-remove with `If-Match` |
| `POST`   | `/api/admin/themes/:id/restore`       | admin    | Restore with `If-Match`     |
| `POST`   | `/api/admin/themes/:id/disable`       | admin    | Hide with `If-Match`        |
| `POST`   | `/api/admin/themes/:id/enable`        | admin    | Re-enable with `If-Match`   |
| `PUT`    | `/api/admin/theme-catalog/default`    | admin    | Set default with settings `If-Match` |
| `GET`    | `/api/admin/themes/:id/revisions`     | admin    | List immutable revisions    |
| `GET`    | `/api/admin/themes/:id/download`      | admin    | Download canonical JSON     |
| `GET`    | `/api/admin/me`                       | required | Who am I + isAdmin flag     |
| `GET`    | `/api/admin/overview`                 | admin    | Dashboard bundle            |
| `GET`    | `/api/admin/users`                    | admin    | Paginated per-user breakdown|
| `GET`    | `/api/admin/activity`                 | admin    | Daily activity time-series  |
| `GET`    | `/api/admin/performance`              | admin    | Performance percentiles     |
| `GET`    | `/_api/store/actions:{docId}`         | required | Legacy action queue read    |
| `PUT`    | `/_api/store/actions:{docId}`         | required | Legacy action queue set     |
| `DELETE` | `/_api/store/actions:{docId}`         | required | Legacy action queue delete  |
| `*`      | `/mcp/oauth`                          | oauth    | MCP JSON-RPC, OAuth 2.1 — see [oauth.md](./oauth.md) |
| `*`      | `/mcp/:apiKey`                        | url      | MCP JSON-RPC, URL-embedded API key (ChatGPT dev mode) |
| `*`      | `/mcp`                                | header   | MCP JSON-RPC, header-auth fallback (Inspector, Claude Desktop) |
| `GET`    | `/.well-known/oauth-authorization-server` | none | OAuth 2.1 server metadata (RFC 8414) |
| `GET`    | `/.well-known/oauth-protected-resource`   | none | OAuth 2.1 resource metadata (RFC 9728) |
| `POST`   | `/oauth/register`                     | none     | Dynamic Client Registration (RFC 7591) |
| `POST`   | `/oauth/token`                        | client   | Authorization-code / refresh-token exchange |
| `POST`   | `/oauth/revoke`                       | client   | Token revocation (RFC 7009) |
| `GET`    | `/oauth/authorize`                    | none     | Server-rendered authorize page |
