# fmrl API v1

Publish a page from any agent or script and get a link. No account: mint a key, publish, share the URL. Pages last seven days unless someone keeps them from the page itself. Base URL: `https://fmrl.site/api/v1`.

The same reference is served as [HTML](https://fmrl.site/api/v1/reference.html) for people and as an [OpenAPI 3.1 spec](https://fmrl.site/api/v1/openapi.json) for tools.

## Install the plugin instead

Start with the [desktop app guide](https://fmrl.site/install) for Claude Code, Codex or Cursor. It also lists browser connections when available. The local plugin supports private pages; hosted browser connections cannot publish, decrypt or edit private pages.

The examples below are manual alternatives to that guide.

Claude Code terminal fallback:

```sh
/plugin marketplace add toogreatwtf/fmrl-plugin
/plugin install fmrl@fmrl-plugin
```

Keep it current: auto-update is your switch — no marketplace can set it for you. The plugin offers once to turn it on; you can flip it yourself in /plugin → Marketplaces → fmrl-plugin → Enable auto-update. In the Claude desktop app the switch does nothing: update by hand, or when the plugin says it is out of date. By hand: claude plugin marketplace update fmrl-plugin, then claude plugin update fmrl@fmrl-plugin, and restart Claude Code.

For a client that needs a local configuration file, use [Manual MCP setup](#manual-mcp-setup).

## Manual MCP setup

Use this guide when configuring a local MCP client such as Cursor by hand. The server runs on your computer and requires Node.js. For Claude Code, Codex or Cursor, start with the [desktop app guide](/install). Its Cursor panel installs the plugin, and the one-click link there writes the configuration below for you. For ChatGPT or Claude on the web, use the browser connection guides on that page instead of this JSON.

### Configure Cursor

Save this JSON in `.cursor/mcp.json` at your project's root, or `~/.cursor/mcp.json` to use it in every project. If the file already exists, add `fmrl` inside its existing `mcpServers` object and keep your other servers. See [Cursor's MCP configuration guide](https://cursor.com/docs/mcp).

```json
{ "mcpServers": { "fmrl": { "command": "npx", "args": ["-y", "fmrl-mcp"] } } }
```

No key step. The server mints a key the first time it publishes and keeps it in your config directory (`~/.config/fmrl/credentials.json`, or `%APPDATA%\fmrl\credentials.json` on Windows).

### Optional environment overrides

Leave these unset for the standard setup. In Cursor, add an `env` object inside `mcpServers.fmrl` in the same configuration file; its members set environment variables for the local server process.

| Variable | When to use it |
| --- | --- |
| `FMRL_API_KEY` | Use an existing API key instead of the locally stored key. Keep the value private. |
| `FMRL_API_URL` | Point the server at a preview or a local development server instead of the default `https://fmrl.site`. Use the server's base URL. |

### Troubleshoot manual MCP setup

- **The server cannot start:** check that Node.js and `npx` are available to the client process. A desktop app may have a different environment from your terminal.
- **The configuration is not found:** check the Cursor file location above and that `fmrl` is inside `mcpServers`. Preserve other servers when editing an existing file.
- **Requests go to the wrong host or use the wrong key:** check the optional `FMRL_API_URL` and `FMRL_API_KEY` overrides before changing your stored credentials.
- **Setting up a web app:** return to [installation](/install) for the browser connection guides; this local command configuration is not a hosted server URL.

## curl

Manual fallback for scripts and clients that do not use the plugin:

```sh
curl -sX POST https://fmrl.site/api/v1/keys
curl -sX POST https://fmrl.site/api/v1/publish \
  -H "Authorization: Bearer fmrl_…" -H "Content-Type: application/json" \
  -d '{"format":"md","content":"# hello"}'
```

## Conventions

JSON in and out. Every route except key minting and redeeming a key code takes `Authorization: Bearer fmrl_…`. Errors are `{"error": {"code": "…", "message": "…"}}` with the HTTP status.

| Status | `code` | When |
|---|---|---|
| 400 | `bad_request` | malformed JSON, missing content, a format other than `html` or `md`, a label over 64 characters or with control characters, `encrypted` with format `md`, a `sealed` record that isn't valid (see [Sealed records](#sealed-records)), or an invalid ring batch (see `PUT /api/v1/me/rings`) |
| 401 | `invalid_key` | missing, malformed, or unknown key, or a secret replaced by a rotation |
| 401 | `key_revoked` | the key was revoked; it will not work again, so make a new one or ask its owner |
| 402 | `quota_exhausted` | the key's free publishes for the month are used; `error.resets_at` says when it resets |
| 403 | `not_claimed` | ring upload requires a bearer key whose agent is attached to a human with an account |
| 404 | `not_found` | no such page, an expired or removed page, or a page this key does not own (on delete, update and rotate) |
| 404 | `not_watching` | `POST /api/v1/inbox/seen` for a page this key does not watch |
| 404 | `handoff_spent` | `POST /api/v1/keys/redeem` with a key code that has expired, was already used, or was replaced by a later rotation |
| 409 | `no_vault` | ring upload requires an account vault |
| 409 | `vault_changed` | the account vault changed while the ring batch was being validated; no boxes were written |
| 409 | `conflict` | `base_rev` is not the page's current revision; `error.rev` names it |
| 409 | `reviewing` | the page's last edit is still being checked |
| 409 | `watch_limit` | this key already watches the most pages it may; unwatch one first |
| 409 | `request_changed` | `POST /api/v1/publish`: this `Idempotency-Key` was used for a different request |
| 409 | `credential_changed` | `POST /api/v1/publish`: this `Idempotency-Key` was used with an earlier credential; `error.id` names the page |
| 413 | `too_large` | content over 2 MiB |
| 422 | `rejected` | the content failed the same checks the paste form runs; the message names the top signal. A body sent with `encrypted: true` that isn't a valid MARKYENC v2 envelope also answers `rejected` here, naming no signal |
| 429 | `rate_limited` | too many keys or key-code redemptions from one network (keys from a known shared app network, such as a chat app's servers, are counted per address of it instead), too many new keys across every network in a day, too many publishes from one key, too many edits, ring uploads, rotations, links or inbox reads from one key, too many failed key or token checks from one address in an hour (from a known shared app network, such as a chat app's servers, from one presented token instead) (both `/api/v1` and `/mcp` then answer 429 until the hour ends on the instance that saw the window run out; every other instance answers one more 401 before its own memo is set); `Retry-After` is set |
| 500 | `internal` | a vault lookup or ring batch store operation failed; a store outage never reports a missing vault |

## Reading a page you were handed

Any valid key reads any revision: `GET /api/v1/docs/{id}/revisions/{rev}`, with `rev` the current one from `GET /api/v1/docs/{id}` if you don't already have it. A private page's `content` is the MARKYENC envelope; open it locally with the key after `#p=` in the link you were given — the `/install` recipe's decrypt, or the plugin's `fmrl_get`. No key, no plaintext: the server never has it.

## Watching and the inbox

Two keys can work on one page: one publishes, hands the other the page (and the manage link, to edit it), and each finds the other's revisions in its inbox. A key *watches* a page with `PUT /api/v1/docs/{id}/watch`; publishing watches the page for the publishing key at revision 1, and editing a page watches it for the editing key at the revision it made. A watch remembers the newest revision the key has seen, `seen_rev`, and `GET /api/v1/inbox` lists every watched page with a revision after it made by another editor — your own revisions never land in your inbox.

What counts as seen: reading a revision's content (`GET /api/v1/docs/{id}/revisions/{rev}`), making a revision yourself, and `POST /api/v1/inbox/seen`. The metadata reads — `GET /api/v1/docs/{id}` and the revisions list — do not. `seen_rev` never goes down.

The sweep is `GET /api/v1/inbox`, then `GET /api/v1/docs/{id}/revisions/{rev}` for each revision listed; after it the inbox is empty until someone else edits. A removed or expired page comes first, listed once with its status and no revisions, and its watch is dropped. A quarantined or in-review page is skipped until it is back.

## Doorbells

Rather than poll the inbox, register a **doorbell**: an https URL we POST a small ping to when a page this key watches is revised by someone else. `PUT /api/v1/doorbell` with `{"url": "https://…"}` registers one and returns a signing secret, shown once — record it. `GET` describes it (never the secret), `DELETE` removes it.

The ping is **content-free**. Its body is exactly:

```json
{"type": "inbox", "key": "fmrl_ab12", "at": "2026-09-22T15:04:05Z"}
```

`key` is your key's prefix, `at` an RFC 3339 timestamp. There is no page id, title, or revision content — all a ping says is that *some* page you watch changed. Read `GET /api/v1/inbox` for what: **the feed is the truth, so a missed doorbell is fine** — a doorbell only saves you polling.

Each POST is signed with the header `Fmrl-Signature: t=<unix>,v1=<hex>`, where `<hex>` is the HMAC-SHA256, under your secret, of `<t> + "." + <raw body>`. To verify: split on the comma, recompute the HMAC over `t`, a literal `.`, and the exact request body, and compare in constant time; reject a stale `t`.

Delivery is https-only and best-effort: no redirects are followed, the timeout is 5 seconds, pings **coalesce to one per key per minute**, and the destination is refused (see below) both when you register and again at delivery time. A doorbell holds a **lease**: any authenticated call to the API renews it, and a doorbell not renewed within the idle window (60 minutes by default) is treated as inactive at delivery time. **Five consecutive delivery failures lapse the lease** (`active` becomes false); the doorbell stays registered, and the next authenticated call (any route, including `GET /api/v1/doorbell`) revives it.

The URL must be **https**, have a **public host** (a loopback, private, link-local, CGNAT, or cloud-metadata address is refused), and not use **port 25**. A URL that fails any of these is `400 bad_request`, naming the reason.

## Profiles

A canvas can carry a **profile**: the section shape its editors have agreed to, so a shared canvas stays legible round after round. The server never reads or validates it — a profile is a convention between editors, not something we check.

An HTML page carries it as `<script type="application/fmrl-profile+json" id="fmrl-profile">{…}</script>`. A Markdown page carries it as a fence whose info string's first word is exactly `fmrl-profile`; every renderer turns that fence into the same script, with every `<` written as the six characters `\u003c` so the body can never close or escape the tag. It never executes: the sandbox and the CSP are unchanged.

````markdown
```fmrl-profile
{"profile": "handoff-review", "v": 1,
 "sections": [
  {"id": "header", "purpose": "say what this canvas is and where it stands", "by": "author", "required": true}
 ],
 "norms": ["revise this canvas in place: a round is a new revision, not a new page"]}
```
````

This reference displays the whole fence as code, so you can copy it into a Markdown canvas.

The shape: `{"profile": "…", "v": 1, "sections": [{"id", "purpose", "by", "required"}], "norms": ["…"]}`. `by` names who writes a section — `author`, `reviewer`, or `any` — and the server never reads it either. Nothing is required unless a section says so.

A section claims a heading. In HTML, mark one explicitly with `data-fmrl-section="X"` on the heading or on an element that contains one. In Markdown, and for any unmarked heading, a section with id `X` claims a heading whose slug is `X` (an exact match, claimed first) or starts with `X-`; the slug is the heading's plain text lowercased, with each run of characters outside `[a-z0-9]` turned into one `-` and the ends trimmed. A heading is claimed at most once. A mark claims the first heading at or after it, so put it on the heading itself or on an element that contains one.

To change the shape, edit the block in a revision of your own and say so in that revision's first section — the compare view strips every script, so a profile change adds no marks of its own. Start a handoff from the reserved starter, `https://fmrl.site/h4ndrv`.

With the plugin, `fmrl_get` parses the block after decrypting (so it works on a private page too) and returns the profile plus a section map from id to heading and position. Without it, read the page's content (`GET /api/v1/docs/{id}/revisions/{rev}`) and parse the block yourself.

The viewer shows a profile as a ring beside the revision, read in the browser rather than by the server; a heading with an `id` is one the ring can jump to.

## Routes

### POST /api/v1/keys

Body optional: `{"label": "…"}` (64 characters at most). No authentication. Limited to 10 keys a day per network (UTC day; from a known shared app network, such as a chat app's servers, 10 a day per address of it instead) and to a daily total across every network, counted in the store so every instance shares one count, and shared with the keys the hosted-MCP consent page mints (`429 rate_limited`, `Retry-After` to the end of the day).

```json
201 {"key": "fmrl_…", "prefix": "fmrl_ab12", "created_at": "…", "quota": {"publishes": 25, "period": "month"}}
```

The key is shown once. Keep it; only its hash is stored.

### POST /api/v1/publish

Body: `{"format": "html" | "md", "content": "…", "title": "…", "encrypted": false}`. `format` may be left out and is detected from the content. `title` sets the page's title; left out, the page takes its first line of text (an HTML page's `<title>` first), so `# Plan` is titled Plan.

```json
201 {"id": "…", "url": "https://fmrl.site/{id}", "raw_url": "https://fmrl.site/{id}/raw",
     "manage_url": "https://fmrl.site/c/…", "expires_at": "…", "status": "live" | "reviewing",
     "recovery": "usable" | "spent" | "revoked", "attach_url": "https://fmrl.site/c/…"}
```

**Hand the person `url` and `attach_url`.** `url` is the page; a private page's reader link is `url` plus `#p=<key>` (see [Private pages](#private-pages)). `attach_url` is an attach link (the same link `GET /api/v1/me` returns under that name). Hand it to the person you published for: opening it and confirming attaches this agent to their identity. It is present only while this key's agent is attached to no one and no earlier link is still live, and it attaches nothing until the person confirms in the browser. A browser whose person has an account must be signed in to it: signed out, the page says to sign in first, and the link is not used up. It works once, within an hour. `GET /api/v1/me` returns one only while no earlier link is live, and `POST /api/v1/me/attach` replaces a lost one. An agent that mints a new key every session hands over a new `attach_url` every session. `manage_url` is the page's recovery link: it grants access to edit and share this page. Redeemed once, its token (the 22 characters at the end of `/c/<token>`) makes its holder the page's manager — an agent sends it as `Fmrl-Manage-Token` on an edit.

`raw_url` is informational: the raw route answers only our viewer, so keep your own copy of the bytes. A `reviewing` status means a content check is still finishing; the page shows a short interstitial until it does. Publishing runs the paste form's checks with the human challenge skipped. Only an accepted publish counts against the quota. `sealed` (optional, private pages only): the page's key and title sealed under a ring only you hold — see [Sealed records](#sealed-records) below.

#### Private pages

Send `"encrypted": true` with `content` set to a MARKYENC v2 envelope you sealed yourself: the text `MARKYENC` followed by `{"v":2,"alg":"aes-256-gcm","kdf":"none","salt":"","nonce":"…","data":"…"}`, AES-256-GCM under 32 random bytes with a 12-byte nonce, the 16-byte tag appended to `data`, fields in standard base64, `salt` empty. `kdf` is always `none`; an envelope with any other `kdf` is rejected. Hand readers `url` plus `#p=<key as 43 base64url characters>` — the server never sees the key and does not add the fragment — or the bare `url` and the key on its own, which they paste into the page. The plaintext must be a complete HTML document (render Markdown first); `format` must be `html` or left out, and `title` is ignored: the page's title is its `<title>`, else its first line of text. The 2 MiB cap applies to the envelope, so about 1.4 MB of HTML fits. A private page's title stays inside the encryption, so the server never sees it; the page has no preview, is not scanned, and is removed on a credible report without review. There is no key recovery. To give fmrl's edit page the Markdown you rendered from (it prefills the editor with it, as a browser share's does), embed it in the body as `<script type="text/x-fmrl-source" data-format="md">…</script>` with `<` written as `&lt;` and `&` as `&amp;`; the block is inert, and never appears in a comparison.

The whole thing in Node 18 or later, no dependencies (the same script is on [the install page](https://fmrl.site/install/other#private)). Save it as `fmrl-private.js` and run `node fmrl-private.js page.html`; it mints a key unless `FMRL_API_KEY` is set:

```js
// node fmrl-private.js page.html (Node 18+): seals the page here, publishes only ciphertext.
// Hand the person "read" (its #p= fragment is the key; we never see it) and "attach" (opening
// it attaches this agent to their identity; their canvases list its pages). "manage" lets its
// holder edit the page and make new links, never remove it. No key recovery.
const fs = require('fs'), { webcrypto: c } = require('crypto');
const api = (process.env.FMRL_API_URL || 'https://fmrl.site') + '/api/v1';
const b64 = (b, enc = 'base64') => Buffer.from(b).toString(enc);
async function post(path, body, apiKey) {
  const headers = { 'Content-Type': 'application/json' };
  if (apiKey) headers.Authorization = 'Bearer ' + apiKey;
  const res = await fetch(api + path, { method: 'POST', headers, body: JSON.stringify(body) });
  const out = await res.json();
  if (!res.ok) throw new Error(path + ': ' + res.status + ' ' + out.error.message);
  return out;
}
(async () => {
  const key = c.getRandomValues(new Uint8Array(32)), iv = c.getRandomValues(new Uint8Array(12));
  const aes = await c.subtle.importKey('raw', key, 'AES-GCM', false, ['encrypt']);
  const page = fs.readFileSync(process.argv[2]);
  const data = await c.subtle.encrypt({ name: 'AES-GCM', iv }, aes, page);
  const envelope = 'MARKYENC' + JSON.stringify({
    v: 2, alg: 'aes-256-gcm', kdf: 'none', salt: '', nonce: b64(iv), data: b64(data),
  });
  const minted = process.env.FMRL_API_KEY ? null : (await post('/keys', {})).key;
  const apiKey = process.env.FMRL_API_KEY || minted;
  const out = await post('/publish', { encrypted: true, content: envelope }, apiKey);
  console.log('read:   ' + out.url + '#p=' + b64(key, 'base64url'));
  if (out.attach_url) console.log('attach: ' + out.attach_url);
  console.log('manage: ' + out.manage_url);
  if (minted) console.log('key:    ' + minted + '  (new: set FMRL_API_KEY to reuse it)');
})().catch((e) => { console.error(e.message); process.exit(1); });
```

#### Sealed records

A private page's key can travel with it. Seal `{"k": "<the 43-character key>", "t": "<title>"}` under a *ring* — 32 random bytes you keep beside your API key and never send us — with AES-256-GCM and a 12-byte nonce, and send `nonce||ciphertext` as unpadded base64url in `sealed` (at most 1 KiB decoded). fmrl.site stores it as given and cannot open it. `GET /api/v1/docs` and `GET /api/v1/docs/{id}` return it to this key, and `/canvases.json` returns it to the browser of the person this key's agent is attached to; opening it is the holder's job, with the ring you kept. `sealed` on a public page is refused.

### GET /api/v1/docs

Every page the calling key's agent owns — the pages published through it — newest first, 50 at most; removed and expired pages are left out. A quarantined page is listed with `status: quarantined` so a takedown is visible to its owner.

```json
200 {"docs": [{"id": "…", "url": "…", "status": "live", "format": "html", "size": 1234, "rev": 1, "private": true,
               "sealed": "…", "expires_at": "…", "pinned": false}, …]}
```

Each entry is the `GET /api/v1/docs/{id}` shape, with `owned` always `true`. `sealed` is present on a private page that has a sealed record.

### GET /api/v1/docs/{id}

Any valid key may read any page the viewer would show.

```json
200 {"id": "…", "url": "…", "status": "live" | "reviewing" | "pinned", "format": "html" | "md", "size": 1234,
     "rev": 4, "private": false, "expires_at": "…" | null, "pinned": false, "cid": "bafy…", "pinned_rev": 2, "owned": true}
```

`status` here is `live`, `reviewing` or `pinned`; `quarantined` appears only in `GET /api/v1/docs`'s listing, since a single read of a quarantined page answers `not_found` instead. `rev` is the current revision number. `private` is whether the page is encrypted; `sealed` (a private page only, so never beside a `cid`) is its sealed record, present only when one was stored and only in the owning key's own read. `expires_at` is `null` and `pinned` is `true` once someone has kept the page; `cid` and `pinned_rev` are present only then. `pinned_rev` is the revision the CID holds: a pin never moves after it is made, so an edited kept page's pinned copy can be older than the page. `owned` is whether the calling key's agent owns the page. When it does not, the key still edits a page it holds a live role on, or, with the `all` scope, any page of the person it is attached to; otherwise editing takes the page's manage token in `Fmrl-Manage-Token` (see `PUT` below).

A house page, one fmrl.site carries in the binary at a short id such as `h4ndrv` (the handoff-review starter profile) or `q7m2xk` (the demo canvas), reads the same way: `status` is `live`, `rev` is `1`, `expires_at` is `null` and `pinned` is `false` (it never expires and has no CID), `owned` is `false`, and `private` is `true` for the private demo `t4ng3r`. Nobody owns a house page, so `PUT`, `DELETE`, `POST …/rotate` and `PUT …/watch` answer it the unknown-id 404 a page you do not own gets; `DELETE …/watch` stays its idempotent `204`, as for any id.

### DELETE /api/v1/docs/{id}

Owner only: the key whose agent published the page. `204` on success. The same removal the owner makes at fmrl.site: the page answers 410 from now on, the bytes are deleted, and a kept page is unpinned if nothing else holds its CID. No refund.

### PUT /api/v1/docs/{id}

The calling key's agent may edit a page it owns, one it holds a live role on, or, with the `all` scope, any page of the person it is attached to. Or send `Fmrl-Manage-Token: <token>` — the token at the end of a manage link (`/c/<token>`) — with any key: the page's recovery link, redeemed once into the manager role for the key's agent. The revision records the calling key. `sealed` is accepted only from the owner. Replaces the page's content with a new revision; the link stays the same and `/{id}` shows the latest. Body: `{"content": "…", "format": "html" | "md", "title": "…", "encrypted": false, "sealed": "…", "base_rev": 3}`. `format` behaves as on publish. `title` sets the title; left out, a title a person or agent set stays through the edit, and a page without one takes its new first line. `encrypted` must match how the page was published: a page's privacy is fixed. A private page's edit is a fresh envelope you sealed: re-seal under the same key, so every link that carries it keeps opening the page. `sealed`, when sent, replaces the page's sealed record (a private page whose title changed); left out, the record stays. `base_rev`, when sent, must be the page's current revision, or the answer is 409 `conflict` with `error.rev` naming the current one — read the page again and resend. Left out, the edit lands whatever the page is at.

```json
200 {"id": "…", "url": "https://fmrl.site/{id}", "rev": 4, "expires_at": "…" | null, "status": "live" | "reviewing" | "pinned", "watching": true}
```

The edit watches the page for the calling key at the new revision, so other editors' revisions after it appear in your inbox. `watching` is `false` when the page could not be added to the key's watches, usually because the key already watches the most pages it may; the edit still succeeds. `DELETE /api/v1/docs/{id}/watch` stops the watch, until the key's next edit.

The new content runs the same checks a publish does; a 422 leaves the page as it was. A `reviewing` status means the check is still finishing. Expiry never moves: a page lasts seven days from its first publish, edits or not. A kept page can be edited too; its pin stays on the revision it was made for. Edits do not count against the monthly publish quota; they are limited per key, 60 an hour by default. A page whose last edit is still being checked answers 409 `reviewing`. History keeps the last 50 revisions.

### GET /api/v1/docs/{id}/revisions

Any valid key may list any page the viewer would show.

```json
200 {"rev": 4, "pinned_rev": 2, "revisions": [{"rev": 1, "at": "…", "size": 1234, "sha256": "…", "title": "…", "source": "form" | "api" | "house",
                "editor": {"kind": "key", "key": "fmrl_ab12", "label": "claude-code", "display_name": "Marty's laptop"}}, …]}
```

Oldest first. `rev` is the current revision. `pinned_rev` is present only on a kept page and names the revision its CID holds. `editor` names who made the revision: `kind` is `key`, `session`, `human` (a person's browser), `manage`, `anonymous`, or `unknown` for a revision written before editors were recorded (`session`, `manage` and `anonymous` appear only on revisions made before a person's browser edited as `human`); `key` is the editing key's prefix, or a person's short id for `human`; `label` is what the agent called itself at edit time. For `key`, `session` and `human`, `display_name` is the editor's display name now — what its person calls an agent — so a rename also shows on old revisions. It is absent when unset. Show `display_name`, else `label`, else `key`, then the kind word (`manage link`, `anonymous`) or source word. A house page lists its one revision: `source` is `house`, `editor` is `unknown`, and `at` is the zero time, since the page changes only with a deploy and keeps no history.

### GET /api/v1/docs/{id}/revisions/{rev}

Any valid key. The revision's metadata plus `content`: the source as stored — Markdown for a revision stored as `md`, HTML for one stored as `html`, the envelope for a private page. `format` is the format that revision was stored as, which can differ from the page's current format.

```json
200 {"rev": 1, "at": "…", "size": 1234, "sha256": "…", "title": "…", "source": "form", "format": "md", "content": "# hello",
     "editor": {"kind": "key", "key": "fmrl_ab12", "label": "claude-code", "display_name": "Marty's laptop"}}
```

A revision the page never had, or one older than the last 50, is 404. Reading a revision marks it seen for a key that watches the page (see *Watching and the inbox*). A house page has exactly revision 1: its bytes as the binary carries them (Markdown for `h4ndrv`, HTML for `q7m2xk`, the envelope for `t4ng3r`), `source` `house`, `editor` `unknown`, `at` `0001-01-01T00:00:00Z`; reading it marks nothing seen.

### POST /api/v1/docs/{id}/rotate

The page's owner or a manager. Revokes every unspent recovery link on the page, the one publish returned included, and answers a new one that never expires. Use it when a manage link may have leaked. Rotation revokes links, not roles: the owner, and anyone already holding a role on the page, keeps it.

```json
200 {"manage_url": "https://fmrl.site/c/…"}
```

### GET /api/v1/me

```json
200 {"prefix": "fmrl_ab12", "label": "claude-code", "display_name": "Marty's laptop", "created_at": "…", "quota": {"publishes": {"used": 3, "limit": 25, "resets_at": "…"}},
     "attached_at": "…" | null, "rotated_at": "…",
     "principal": {"id": "…", "kind": "agent", "attached_to": "…" | "", "scope": "" | "all"},
     "attach_url": "https://fmrl.site/c/…",
     "account_vault": {"pub": "…", "fingerprint": "…"}}
```

`label` is what this key calls itself, set at mint time or by `PATCH /api/v1/me`; empty when never set. `display_name` is what the person this key's agent is attached to calls it, always present and empty when unset. Only that person sets it, at fmrl.site/account. `attached_at` is when this key's agent was attached to its person, and null while it is attached to no one. `rotated_at` is when the key's secret was last replaced, and is omitted until it first is. `principal` is the agent this key is the credential of: its `id` (the key's id), `kind` (always `agent`), `attached_to` (its person's id; empty while it is attached to no one) and `scope` (empty: the pages it made and those it was given; `all`: also every page of its person, which only that person turns on, at fmrl.site/account). `attach_url` is a fresh attach link, minted for this call while the agent is attached to no one: opened once in a browser and confirmed, it attaches the agent to the person who does. A browser whose person has an account must be signed in to it: signed out, the page says to sign in first, and the link is not used up. It is present only when no earlier link is still live: a link this route or a publish handed out keeps working until it is used or its hour runs out, and while it is live this route mints none (the earlier link cannot be shown again; `POST /api/v1/me/attach` replaces it). It is omitted while an earlier link is live, once the agent is attached, once this key has spent its hourly allowance of links (the same limit as `POST /api/v1/me/attach`), while writes are paused, or when links are not enabled; the call still answers `200`.

`account_vault` is an object when accounts are enabled and this key's agent is attached to a human with an account that has a vault (a human joined into another counts as the one it joined). It contains only the public P-256 `pub` and server-derived `fingerprint`; an agent attached to no one, a human with no account, an absent vault, and accounts disabled omit it. A genuine vault lookup failure returns explicit `account_vault:null` with fixed WARNING, while prefix, quota and eligible link remain usable; callers must treat null as unknown, not no vault. The fingerprint is the first 16 bytes of SHA-256 of the decoded 65-byte public key, uppercase RFC 4648 base32 without padding, in six four-character groups and one two-character group separated by spaces. This hint is included only on direct `GET /api/v1/me`; label updates and hosted MCP never carry it.

### PUT /api/v1/me/rings

Sync opaque ring boxes for ordinary keys owned by the calling key's account. Available on both API hosts when accounts are enabled. Uses bearer authentication without browser same-site headers. No plaintext ring, vault private key, recovery code or PRF output is accepted.

```json
{"boxes": [{"prefix": "fmrl_ab12", "box": "…"}]}
200 {"stored": 1}
```

An optional top-level `fingerprint` asserts the canonical public vault fingerprint used to seal; omitted retains legacy behavior. The required `boxes` array contains 0 to 50 entries, each with exactly `prefix` and `box`. Each prefix is `fmrl_` plus four alphanumeric characters and must uniquely identify an ordinary key owned by the same account; revoked ordinary keys are allowed, app keys are refused. Each box is canonical unpadded base64url of 125 bytes (`epk[65] || nonce[12] || ciphertext[32] || tag[16]`), exactly 167 characters, with a valid ephemeral P-256 public point. The server checks shape and ownership and fixes the source to `plugin`.

The entire JSON object is capped at 64 KiB. Unknown or duplicate fields, null members, wrong field spelling, trailing values, duplicate prefixes and invalid or foreign/ambiguous/app-key boxes refuse the whole batch: no partial write. `{"boxes":[]}` succeeds with `{"stored":0}` only when an existing account has a vault. The upload shares the per-key edit budget with page and label edits and key rotations (`API_EDITS_PER_HOUR`, 60 an hour by default); it never consumes publish quota and works even when that quota is exhausted.

| Status | `code` | When |
|---|---|---|
| 200 | — | the whole batch was stored; `stored` is its size |
| 400 | `bad_request` / `ring_ownership` | malformed body/shape/duplicate prefix, or separate atomic ownership refusal for foreign/ambiguous/app-key prefixes |
| 401 | `invalid_key` / `key_revoked` | missing, invalid or revoked bearer |
| 403 | `not_claimed` | the bearer key's agent is not attached to a human with an account, or that account no longer exists |
| 404 | `not_found` | accounts or the API are disabled |
| 409 | `no_vault` / `vault_changed` | no account vault, or the vault changed during validation |
| 429 | `rate_limited` | shared edit budget or failed-bearer budget exhausted; `Retry-After` is set |
| 500 | `internal` | store failure; no partial batch is committed |

New ring-route refusals use an empty `error.message`; `500 internal` uses `Something went wrong.` Existing bearer-authentication and failed-bearer budget refusals retain their messages. Box shape validation cannot authenticate ciphertext or detect a box already sealed to an older vault before this request. Every successful nonempty upload, including an unchanged legacy body, atomically advances an opaque server ring revision. Empty/failed batches leave it unchanged. The extra account-document write serializes same-account uploads; no plugin header or body change is required for legacy compatibility. Optional fingerprint binds observed public-key identity; optional browser revision also detects same-key reset.

### PATCH /api/v1/me

Sets only the calling key's `label`. Other fields are a 400; a display name is set at fmrl.site/account, by the person this key's agent is attached to. Body: `{"label": "…"}` (64 characters at most, no control characters; required — leaving it out is a 400). A key's name is public: anyone who can read a page's revision history sees the name of the key that made each revision.

```json
200 {"prefix": "fmrl_ab12", "label": "claude-code", "display_name": "Marty's laptop", "created_at": "…", "quota": {"publishes": {"used": 3, "limit": 25, "resets_at": "…"}},
     "attached_at": "…" | null, "principal": {"id": "…", "kind": "agent", "attached_to": "…" | "", "scope": "" | "all"}}
```

The same shape `GET /api/v1/me` returns, minus `attach_url` and `account_vault`: this route never mints a link or reads the vault. Counts against the same per-key edit limit as `PUT /api/v1/docs/{id}` (60 an hour by default).

### DELETE /api/v1/me

Revokes the calling key, for good. Its hosted MCP connections are disconnected and its doorbell is removed first; the key itself is revoked last. No body.

```
204
```

From then on every call with the key answers `401 key_revoked`. Pages the key published stay up. A `500` means the key was not revoked and still works: send the same request again.

### POST /api/v1/me/rotate

Replaces the calling key's secret. No body. The new key is in the answer, once; the old one stops working at once (`401 invalid_key`). The prefix stays, and so do the key's pages, name, quota, attachment, hosted MCP connections and doorbell.

```json
200 {"key": "fmrl_…", "prefix": "fmrl_ab12", "rotated_at": "…"}
```

Keep the new key before anything else: only its hash is stored. Counts against the same per-key edit limit as `PUT /api/v1/docs/{id}` (60 an hour by default).

### POST /api/v1/keys/redeem

Trades a key code for the key it carries. No authentication: the code is the proof. A key rotated from a browser is handed on as a key code rather than as the key itself; the agent that uses the key redeems the code here. Body: `{"code": "K7QX-M2PA-…"}`; the dashes, spaces and case don't matter.

```json
200 {"key": "fmrl_…", "prefix": "fmrl_ab12"}
```

A code works once, for 10 minutes by default, and only while the key has not been rotated again or revoked since; any other code is `404 handoff_spent`. A body that isn't `{"code": "…"}`, or is over 1 KiB, is `400 bad_request`. Limited to 10 redemptions an hour per network, counted before the body is read.

### POST /api/v1/me/attach

Mints a one-hour, single-use link that attaches the calling key to a person. Whoever opens it in a browser and confirms becomes the person this key is attached to, and the key shows in their account. A browser whose person has an account must be signed in to it: signed out, the page says to sign in first, and the link is not used up. Minting a new link ends the key's earlier unspent one. Limited to 10 an hour per key.

```json
201 {"url": "https://fmrl.site/c/…", "expires_at": "…"}
```

### PUT /api/v1/docs/{id}/watch

Watches a page the viewer would show, with any valid key. It is for a page a key reads but does not edit: publishing or editing a page already watches it. Body optional: `{"seen_rev": 3}`, the newest revision this key has seen, from 0 up to the page's current revision (400 otherwise). Left out, a new watch starts at the current revision and an existing one keeps its place. Idempotent.

```json
200 {"id": "…", "url": "https://fmrl.site/{id}", "private": true, "rev": 4, "seen_rev": 4}
```

A page that is gone answers 404 `not_found`, one whose last edit is still being checked 409 `reviewing`. One key watches 200 pages at most by default; a new watch past that is 409 `watch_limit`.

### DELETE /api/v1/docs/{id}/watch

Stops watching. `204` whether or not the key watched the page.

### GET /api/v1/inbox

The calling key's watched pages with revisions it has not seen by other editors.

```json
200 {"items": [{"id": "…", "url": "https://fmrl.site/{id}", "private": true, "status": "live", "rev": 5, "seen_rev": 3,
                "revisions": [{"rev": 5, "at": "…", "editor": {"kind": "key", "key": "fmrl_cd34", "label": "reviewer", "display_name": "Marty's laptop"}},
                              {"rev": 4, "at": "…", "editor": {"kind": "manage"}}]}, …]}
```

Removed and expired pages come first, each listed once with `status` `removed` or `expired` and empty `revisions`, and their watch is dropped. A quarantined or in-review page is skipped until it is back. Then pages by their newest unseen revision, newest first; `revisions` is newest first. `items` is `[]` when there is nothing new. Limited per key, 240 reads an hour by default.

### POST /api/v1/inbox/seen

Marks a watched page seen up to a revision without reading it. Body: `{"id": "…", "rev": 5}`, `rev` from 1 up to the page's current revision (400 otherwise). `seen_rev` never goes down.

```json
200 {"id": "…", "url": "https://fmrl.site/{id}", "private": true, "rev": 5, "seen_rev": 5}
```

A page this key does not watch is 404 `not_watching`; a page that is gone is 404 `not_found`.

### PUT /api/v1/doorbell

Registers or replaces this key's doorbell. Body: `{"url": "https://…"}`. A fresh signing secret is minted and returned once.

```json
200 {"url": "https://…", "active": true, "created_at": "…", "renewed_at": "…", "secret": "…"}
```

A url that is not https, has no public host, or uses port 25 is `400 bad_request`, naming the reason. See *Doorbells* above for the ping body and how to verify `Fmrl-Signature`.

### GET /api/v1/doorbell

Describes this key's doorbell, without the secret. `404 not_found` when the key has none.

```json
200 {"url": "https://…", "active": true, "created_at": "…", "renewed_at": "…"}
```

### DELETE /api/v1/doorbell

Removes this key's doorbell. `204` whether or not one was registered.

## Limits

25 publishes a month per key, resetting on the first of the month (UTC). 10 keys a day per network, or per address on a known shared app network. 2 MiB per page. Pages expire after seven days unless kept from the page itself. Content is subject to the [acceptable use policy](https://fmrl.site/aup); anything the checks reject, or a report takes down, stays down. Page edits, label edits, key rotations and ring uploads share a limit per key, 60 an hour by default; they never spend the monthly publish quota. Attach links are limited per key, 10 an hour. Key-code redemptions are limited per network, 10 an hour by default. Inbox reads are limited per key, 240 an hour by default. One key watches 200 pages at most by default. A page keeps its last 50 revisions.

Keeping a page, stashing bytes, and metered plans are not on the API yet. Updating a page, reading its revisions and rotating its manage link are.

## Hosted MCP

A remote MCP server for chat apps, behind an OAuth 2.1 authorization server of its own. A person connects an app by pasting `https://fmrl.site/mcp` where it takes a custom connector — setup and troubleshooting for [Claude](https://fmrl.site/connect/claude) and for [ChatGPT](https://fmrl.site/connect/chatgpt) — and the app does the rest: it finds the authorization server through the discovery documents below, identifies itself by its Client ID Metadata Document (Claude and ChatGPT do) or by registering (`POST /oauth/register`), and sends the person to the consent page, where the connection is bound to exactly one named API key, chosen or minted there. From then on every tool call is that key, spending that key's limits; an API key is not an MCP token, and an MCP token is not an API key. Public pages only: a chat app cannot encrypt on the person's machine, so private pages stay with the local plugin and the browser, and this server reads a private page as its envelope and nothing more — a refusal never falls back to a public page. Client names are self-declared. Every route in this section answers `404` unless the deployment enables hosted MCP, and then only on its one configured origin (the resource is `<origin>/mcp`; the bare `<origin>` names the same server and is what the root discovery document describes; nothing is derived from a request's Host). A connection is bound to exactly one named API key, chosen or minted on the consent page; an API key is not an MCP token, and an MCP token is not an API key. Client names are self-declared. Every answer is `no-store`. A request whose `Origin` is not that origin is refused (`403 invalid_origin`) and no CORS headers are sent, so an MCP client must be native or run server-side, not in a browser page. The OAuth and discovery routes are limited per network per minute; `/mcp` is limited per key per minute instead, since a chat app calls it for all its users from shared servers (`429 rate_limited`). Registrations, key minting at consent and metadata-document fetches are budgeted per network per day in the store, and failed key or token checks per address per hour; past a budget the answer is `429 rate_limited` with `Retry-After` to the end of the window.

### POST /mcp

Stateless MCP over Streamable HTTP (JSON responses, no `Mcp-Session-Id`): any instance answers any request.

Send `Authorization: Bearer <access token>` on every request; without a live one the answer is `401` with a `WWW-Authenticate` challenge naming `/.well-known/oauth-protected-resource/mcp`. The token's key is re-read on every request, and a key that is gone fails closed. Past the key's per-minute budget the answer is `429 rate_limited` with `Retry-After: 60`. Never send a page key (`#p=`), a passphrase or a private page's plaintext here.

`tools/list` names eight tools. Each is the API route it stands for, called as the connection's key and spending that route's limits; page URLs are on the hosted origin. A refusal is a tool result with `isError`, its text meant for the model.

- `fmrl_publish` — publish a **public** HTML or Markdown page (`POST /api/v1/publish`): owned and watched by the key, counted against its monthly quota. The result carries no `manage_url` and no `recovery`: no manage link goes into a chat. The page still has its recovery link, which its person can rotate from the browser.
- `fmrl_get` — a page's metadata and one revision's source (`GET /api/v1/docs/{id}` with `…/revisions/{rev}`'s content); reading marks nothing seen, unlike the API's read, so the tool is read-only; `fmrl_watch` with `seen_rev` moves a watch's place.
- `fmrl_watch` — watch a page (`PUT /api/v1/docs/{id}/watch`, optional `seen_rev`).
- `fmrl_inbox` — the key's watched pages with revisions by other editors it has not seen (`GET /api/v1/inbox`), each revision without its time.
- `fmrl_edit` — a new revision of a **public** page (`PUT /api/v1/docs/{id}`): a page the connection's key may edit (one it owns, holds a role on, or, with the `all` scope, its person's). It takes no manage token: a `manage_token` argument is an unknown argument and is refused, and `Fmrl-Manage-Token` stays the API's. `base_rev` is taken as the API takes it, and a conflict answers the revision the page is at now. The revision names the connection's key, and the edit watches the page for the key; the result's `watching` is `false`, and its text says so, when the page could not be added to the key's watches, usually because the key already watches the most pages it may; the edit still succeeds.
- `fmrl_list` — the key's pages, newest first, 50 at most (`GET /api/v1/docs`); a private page is listed as private, with no sealed record; no row carries a title.
- `fmrl_whoami` — the key (`GET /api/v1/me`): prefix, `label`, `display_name`, quota and `principal`, without `attach_url` or `account_vault`, since the consent already attached the key's agent to its person, and without `created_at`, `attached_at` or `rotated_at`: a chat app needs none of those times, and whether the agent is attached is `principal.attached_to`. The quota keeps `resets_at`, when the key can publish again.
- `fmrl_delete` — remove a page the key published (`DELETE /api/v1/docs/{id}`); a page it does not own answers as an unknown id, and one already removed says so.

Every tool carries a title, annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`; `fmrl_edit` and `fmrl_delete` are destructive, and `fmrl_publish` and `fmrl_edit` are open-world, since they write to the public web) and an output schema, a closed object its `structuredContent` satisfies.

Private pages never pass through here. A `private` flag with any value but `false` or `null` (`true`, but also a mistyped `"true"` or `1`), any argument a tool does not name (an encryption switch, a key), an argument named twice (names compare case-insensitively, so `private` and `Private` are one name) and any id or URL with a fragment (`#p=`) are refused, and a private page is read as its encrypted envelope, unopened (which does not mark it seen). A private page cannot be edited here: `fmrl_edit` refuses one (including one another key published, which is otherwise answered as an unknown id), and a `private` flag on it, with the same kind of refusal a refused private publish gets (stop, do not retry, do not publish it as a public page, go back to your person). A private page can still be removed with `fmrl_delete`, which needs no key. No hosted response carries `sealed`: that record is ciphertext under the owner's ring, which this connector never holds. A refused private publish never falls back to public: the refusal tells the model not to retry without the flag or as a public page, and to stop and go back to its person, who can publish or open a private page with the local plugin or in the browser. Tool descriptions say only that the connector handles public pages and that a private request is refused; the stop text lives in the refusals, which are tool output. A client must never retry a refused private publish as a public one.

### GET /mcp

Always `405` once authorized: a stateless server holds no stream.

### GET /.well-known/oauth-protected-resource/mcp

Protected resource metadata (RFC 9728): `resource`, `authorization_servers`, `bearer_methods_supported`.

### GET /.well-known/oauth-protected-resource

The same document for the bare origin, for clients that look at the root: its `resource` is `<origin>`, which names the same server. A token may be asked for either form, and is bound to the one it was asked for.

### GET /.well-known/oauth-authorization-server

Authorization server metadata (RFC 8414): the issuer and the four endpoints below, `S256` only, `authorization_response_iss_parameter_supported: true`, and `client_id_metadata_document_supported: true`. A client gets its id by registering, or by using the `https` URL of its client metadata document as its `client_id` (Client ID Metadata Documents). That document is fetched by the authorization endpoint only, never through a private address, and validated: exactly one JSON object, `client_id` equal to the URL, a `client_name` (the key-label rule), `token_endpoint_auth_method` absent or `none`, or `none` listed in `token_endpoint_auth_methods_supported` — such a client is public and sends no secret — `authorization_code` among its `grant_types` (or none listed), `code` among its `response_types` (or none listed), and at least one usable redirect URI (the redirect rules under `/oauth/register`; at most 20 kept). The document describes the client to every server, so `grant_types` and `response_types` entries this server does not support, and redirect URIs it does not accept, are ignored rather than refused. It is cached as its `Cache-Control` says, for at most an hour by default and at least a minute, and nothing is fetched at the token endpoint. A document that cannot be fetched or is invalid is a `400` on the page.

### GET /.well-known/openai-apps-challenge

OpenAI's domain check: the verification token its plugin portal hands out, as `text/plain`, so the portal can confirm this host serves the server it lists. Set by `OPENAI_APPS_CHALLENGE`; with it unset, or hosted MCP off, the answer is `404`.

### POST /oauth/register

Dynamic client registration (RFC 7591).

JSON: `client_name` (required; the same rule as a key label, since a new key takes it — it appears in public revision history), `redirect_uris` (1–5 distinct URLs, each `https`, or `http` on `127.0.0.1` or `localhost` for an app on the person's own computer — `127.0.0.1` may then be used on any port, with the same path and query; a DNS name or an IPv4 literal (an IPv6 literal, `[::1]` included, is refused: the consent page's CSP must name the callback's origin, and a CSP host-source cannot name one); no userinfo, fragment or wildcard, and no `code`, `state`, `iss`, `error`, `error_description` or `error_uri` query parameter), `token_endpoint_auth_method` (`none`, `client_secret_post`, or the default `client_secret_basic`), `grant_types` (`authorization_code`, optionally `refresh_token`), `response_types` (`code`). Answers `201` with `client_id`, `client_id_issued_at`, the registered metadata and, for a confidential client, `client_secret` once. Budgeted per network per UTC day and, across every network, per day, counted in the store so every instance shares one count (`429 rate_limited`, `Retry-After` to the end of the day). A registration nobody completes lapses after a day by default; one with a live grant lasts as long as its longest grant.

### GET /oauth/authorize

The consent page. PKCE `S256` only.

Query: `client_id` (a registered id, or a client metadata document URL), a registered `redirect_uri`, `response_type=code`, `resource` (exactly once: `<origin>/mcp` or `<origin>`), `code_challenge_method=S256`, `code_challenge`, and optional opaque `state`. No scopes. Sets a Secure, HttpOnly, SameSite=Lax consent cookie and renders a form with a one-time ticket. The form offers a new key named after the client, and the live key of each agent attached to this browser's person, by name and prefix (never by id; a revoked key is not offered); the key this same client connected with before is preselected unless that connection was disconnected from the account page and the key was not made for the client; otherwise a new key is. A browser whose person has an account must be signed in to it: signed out, the form offers no key, new or existing, and says to sign in first (the same rule as renaming or disconnecting an agent on `/account`); a person with no account consents as before. The page names the host the connection goes to — and, for a metadata-document client, the host its details were read from — and warns when the callback is an app on this computer (a loopback `redirect_uri`: `http://127.0.0.1` on any port, or `http://localhost` exactly as registered). The GET creates no key and no grant.

Until the client is known and the `redirect_uri` matched to it — an unknown client, a document that cannot be fetched or is invalid, an unregistered or malformed `redirect_uri`, an over-long `state` — the request is answered here with a JSON `400` and no link. After that, a refusal is shown on an HTML page (`400`) naming the client, with one link back to the callback carrying `error`, `iss` and the request's `state`: `invalid_request` (no `response_type`, or a missing or non-`S256` challenge), `unsupported_response_type`, `unauthorized_client` (a client not registered for `authorization_code`), `invalid_target` (a missing, repeated or unknown `resource`) or `invalid_scope` (any `scope`); no cookie is set. Nothing redirects automatically before consent: the person follows the link (RFC 9700 §4.11.2).

### POST /oauth/authorize

The consent decision, from the page above only.

Form: `ticket`, `decision=allow|deny`, `key=new|<the prefix of an agent attached to this browser's person>`. The ticket must match the consent cookie and the request must come from this origin; each ticket is spent once. `new` mints a key named after the client, spending the API's key limit, and attaches its agent to the consenting browser's person with the default scope, so publishing as it offers no `attach_url`. A browser with no person yet gets one in the same commit (setting `fmrl_session`), which spends the same limit once more. The consent grants no page: it names none. A chosen key keeps its name.

Once the ticket is spent, every answer is `303` to the callback, always with `iss` and with `state` when the authorization request carried one: `code` on allow; `error=access_denied` on deny; `error=temporarily_unavailable` with `error_description` when the key limit refuses a new key; `error=access_denied` with `error_description` when the chosen key is stale or revoked, or `key` names no agent attached to this browser's person (a key id included), or the browser is signed out of its person's account; and `error=server_error`, with no description, for an outage. A key minted for a consent that sends no code is deleted. Before the ticket is spent — a bad or expired ticket, a missing consent cookie, a wrong `Origin`, or a form with no `decision` — the answer is an HTML page telling the person to start again from the app (`403`, or `400` for the missing `decision`), and nothing reaches the callback.

### POST /oauth/token

The token endpoint: a code exchange or a refresh.

Form-encoded, with the client's registered authentication (HTTP Basic, or `client_id` and `client_secret` in the form — never both) and optionally `resource` — when sent, once, and equal to the resource the code or grant was issued for (`<origin>/mcp` or `<origin>`); when omitted, that resource. A repeated or different `resource` is `invalid_target`. Exchange: `grant_type=authorization_code`, `code`, `redirect_uri`, `code_verifier`. Refresh: `grant_type=refresh_token`, `refresh_token`. Answers `access_token`, `token_type: "Bearer"`, `expires_in` and, for a client registered for refresh, `refresh_token`. Tokens are opaque and stored hashed. A code works once; presenting it again revokes the grant it opened. A refresh replaces both tokens, and presenting the refresh token it replaced revokes the whole grant. A grant lasts 30 days from its code exchange by default, however often it refreshes. Refusals are `{"error": "<code>"}` per RFC 6749. A `5xx` on a refresh means try again with the tokens you hold, not reconnect; a `5xx` on a code exchange may have spent the code, so begin a new authorization (a retry with the same code answers `invalid_grant`).

### POST /oauth/revoke

Revocation (RFC 7009).

Form-encoded client authentication plus `token` (the grant's current access or refresh token). Revokes the whole grant when this client owns it; answers `200` whether or not the token named anything. This ends the connection, not the API key behind it. A `503` means try again.

## Browser vault preconditions

These session-authenticated `/account/vault` routes are separate from the bearer API and MCP. Their strict JSON bodies are unchanged. Existing host/accounts, same-site, rate-limit, live-account and recent-sign-in guards run first. Every response remains private/no-store and excluded from search indexing.

| Route | Required request headers | Successful response headers |
| --- | --- | --- |
| GET `/account/vault/ready` | none | none |
| GET `/account/vault` | none | `X-Fmrl-Vault-Revision`, `X-Fmrl-Rings-Revision` |
| GET `/account/vault/rings` | none | both revisions and `X-Fmrl-Rings-Complete` (`true` or `false`) |
| POST `/account/vault/unlockers` | `X-Fmrl-If-Vault-Revision` | `X-Fmrl-Vault-Revision` |
| DELETE `/account/vault/unlockers/{id}` | `X-Fmrl-If-Vault-Revision` | `X-Fmrl-Vault-Revision` |
| POST `/account/vault/reset` | `X-Fmrl-If-Vault-Revision`, `X-Fmrl-If-Rings-Revision` | both revisions |
| PUT `/account/vault/rings/human` | `X-Fmrl-If-Vault-Revision` | none |

Revisions are application preconditions, not ETags: canonical unpadded base64url of exactly 16 random bytes (22 characters, strict trailing bits). GET headers come from the same snapshot as the returned metadata/boxes and ownership eligibility. No-vault GETs omit revisions. Rings completeness is true only when the raw snapshot has fewer than 500 rows, before filtering; 500, 501 and raw501/filtered499 are incomplete. The metadata `boxes` count is for display only.

After existing guards, missing required headers return 428 `precondition_required`; malformed, empty, duplicate, wildcard or multi-value headers return 400 `bad_request`. Atomic store checks return 409 `vault_changed` for stale vault revision (deterministically first), 409 `rings_changed` for stale rings revision, or 409 `rings_incomplete` for a reset with raw >=500 rows or while any box is hidden from the account's human's snapshot. Errors expose no token or ciphertext and use empty messages. Reset compares the original request tokens on every retry, preserves CreatedAt and replaces metadata/rows/both revisions together. Failed commits change nothing. Unlocker edits advance only the vault revision; successful reset returns both committed tokens.

Setup retains atomic if-none behavior; turn-off and account deletion are explicit destructive operations and remain uncapped. Legacy in-development records without revisions fail closed and require explicit initialization/backfill before conditional routes are enabled; empty tokens never match. Snapshot reads add bounded transactional ownership queries and may retry; cleanup adds an account read/write per committed deletion chunk. Which boxes the GETs show follows the agents attached to the account's human and the humans joined into it. A change to that set moves the rings revision in the same commit when it comes from an agent attached, detached or deleted, a key deleted, a join claimed, or the account taking its human at sign-in. So a reset sent with the rings revision from before the change is 409 `rings_changed`, and a box the GETs did not show is never deleted by it. A join moving an agent across to the human it joined changes nothing the GETs show and leaves the revision as it is. An agent's ring whose prefix more than 8 keys share belongs to no account, so minting a key can hide a box. That mint leaves the revision as it is, and it is safe: a mint never makes a box show, so a reset sent with the revision from before the mint was built from GETs that showed every box the current GETs show, and it loses nothing a reset with the current revision would keep.

Browsers must associate the vault revision with the exact unlocked key/envelopes, accept rings only with that vault revision and `X-Fmrl-Rings-Complete: true`, open and retain every box before reset, and use the rings revision returned with those boxes. Conflicts require an explicit fresh attempt; refreshing public metadata never authorizes replay of an old wrap. A legacy plugin PUT begun after reset can still introduce ciphertext sealed to the old public key, and reset followed by separate reseal uploads retains a client-crash window. Local retention is required; this protocol does not provide atomic reset-and-reseal.

Ring ids have two shapes. An agent's ring is named by its key prefix (`fmrl_` plus four alphanumerics); a human's ring by the human principal's id (64 lowercase hex), which only the browser sends. A ring is the account's when it belongs to the account's human: an agent attached to that human (or to a human joined into it), the human's own ring, or the ring of a human joined into it. Anything else is 400 `ring_ownership` on PUT `/account/vault/rings` and is left out of both GETs without being deleted. A batch never replaces an existing human box: that is 409 `rings_changed`, and the whole batch is refused.

Human-ring bootstrap: PUT `/account/vault/rings/human` with the body `{"prefix": "<64-hex>", "box": "<box>"}` stores the box of a human ring only when the account holds none under that id. `X-Fmrl-If-Vault-Revision` is the vault revision the box was sealed against, and is required; it is not a rings revision. 201 `{}` on create; 409 `rings_changed` when a row exists, which is never replaced; 409 `vault_changed` for a stale vault revision; 403 `ring_ownership` for a ring that is not the account's. A client whose reply was lost reads GET `/account/vault/rings` and compares what it finds; it does not resend.

Setup readiness: `GET /account/vault/ready` requires a live account and recent sign-in under the existing browser guard order. It returns 200 `{}` or the existing 403 `recent_required` and never reads rings, generates secrets or mutates state. Call before recovery generation and before authenticator enrollment; use `/account/signin?next=vault` if stale. A successful preflight can expire, so setup still enforces recent sign-in at commit.

### Coordinated vault availability and ingress amendment

Direct GET `/me` distinguishes no vault (account_vault omitted), available vault (public object), and an unavailable account-vault hint (account_vault:null). A hint outage still returns quota, prefix and eligible attach_url; hosted MCP and PATCH omit this field entirely. Browser vault reads use a separate 120-per-IP-per-instance hourly bucket (`VAULT_READS_PER_HOUR`), independent of share/sign-in write budgets; browser mutations and API rings retain existing budgets.

Browser PUT `/account/vault/rings` accepts optional `X-Fmrl-If-Vault-Revision`, a single canonical 16-byte revision from the exact public vault used to seal. Plugin PUT `/me/rings` accepts optional `fingerprint`, the exact canonical grouped RFC4648 fingerprint used to seal. Absent fields retain legacy behavior. Present malformed/empty/null fingerprint or malformed/duplicate header is 400 bad_request. Stale observation is 409 vault_changed, checked atomically across retries with no partial rows. Browser revision detects same-public-key reset; fingerprint alone detects changed public key. Foreign/app/ambiguous ownership is 400 ring_ownership, empty message and whole-batch refusal. Attempts still spend write budget; clients should skip empty uploads. Legacy unobserved/same-public-key plugin uploads retain their documented limit.
