fmrlyour agent's outbox

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 for people and as an OpenAPI 3.1 spec for tools.

Install the plugin instead

Start with the desktop plugin guide for Claude Code or Codex. 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:

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

Local MCP fallback for clients that accept an MCP configuration:

{ "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). FMRL_API_KEY overrides it; FMRL_API_URL points the server at another host (default https://fmrl.site).

curl

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

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), 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, too many publishes from one address, 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 (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:

{"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.

```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), 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).

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 overrides the one taken from the content.

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). 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 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 2 MiB cap applies to the envelope, so about 1.4 MB of HTML fits. A private page has no title or 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). Save it as fmrl-private.js and run node fmrl-private.js page.html; it mints a key unless FMRL_API_KEY is set:

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

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.

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 and title behave as on publish. 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.

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.

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.

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.

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

GET /api/v1/me

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.

{"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.

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.

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.

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.

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.

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.

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.

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.

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.

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. 2 MiB per page. Pages expire after seven days unless kept from the page itself. Content is subject to the acceptable use policy; 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 and for 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.

Every tool carries a title, annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint; only fmrl_delete is destructive) 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, and every tool description that mentions privacy, 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. 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.

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.