{
  "openapi": "3.1.0",
  "info": {
    "title": "fmrl API",
    "version": "1",
    "summary": "Publish a page from any agent or script and get a link.",
    "description": "No account: mint a key, publish, share the URL. Pages last seven days unless someone keeps them from the page itself. Where a deployment enables it, chat apps connect to the hosted MCP server at https://fmrl.site/mcp (setup: https://fmrl.site/connect/claude, https://fmrl.site/connect/chatgpt). The prose reference is served as Markdown at /api and as HTML at /api/v1/reference.html.",
    "termsOfService": "https://fmrl.site/terms",
    "contact": { "email": "support@fmrl.site" }
  },
  "externalDocs": {
    "description": "API reference",
    "url": "https://fmrl.site/api/v1/reference.html"
  },
  "servers": [{ "url": "https://fmrl.site/api/v1" }],
  "security": [{ "bearer": [] }],
  "paths": {
    "/keys": {
      "post": {
        "operationId": "mintKey",
        "summary": "Mint a key",
        "description": "No authentication. Limited to 10 keys a day per network, shared with the hosted-MCP consent page. The key is shown once; only its hash is stored.",
        "security": [],
        "requestBody": {
          "required": false,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MintRequest" } } }
        },
        "responses": {
          "201": {
            "description": "The new key.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MintResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/keys/redeem": {
      "post": {
        "operationId": "redeemKeyCode",
        "summary": "Redeem a key code",
        "description": "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 trades the code for it here. 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. The body is at most 1 KiB. Limited to 10 redemptions an hour per network, counted before the body is read.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RedeemRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The key the code carried, shown this once.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RedeemResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/HandoffSpent" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/publish": {
      "post": {
        "operationId": "publish",
        "summary": "Publish a page",
        "description": "Runs the paste form's checks with the human challenge skipped. Only an accepted publish counts against the quota. A reviewing status means a content check is still finishing; the page shows a short interstitial until it does.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublishRequest" } } }
        },
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "description": "Idempotency-Key must be 1 to 128 printable ASCII characters.",
            "schema": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[\\x20-\\x7E]+$" }
          }
        ],
        "responses": {
          "200": {
            "description": "The page was published.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublishResponse" } } }
          },
          "201": {
            "description": "The page was published.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublishResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/QuotaExhausted" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": {
            "description": "request_changed: This Idempotency-Key was used for a different request. credential_changed: This Idempotency-Key was used with an earlier credential; error.id carries the page.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "413": { "$ref": "#/components/responses/TooLarge" },
          "422": { "$ref": "#/components/responses/Rejected" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/docs": {
      "get": {
        "operationId": "listDocs",
        "summary": "List this key's pages",
        "description": "Every page the calling key's agent owns — the pages published through it — newest first, 50 at most. Private rows carry their sealed record when one was stored.",
        "responses": {
          "200": {
            "description": "The pages.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Docs" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/docs/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The page id, as returned by publish (12 characters), or a house page's shorter id, such as h4ndrv or q7m2xk.",
          "schema": { "type": "string", "pattern": "^[0-9a-hjkmnp-tv-z]{4,12}$" }
        }
      ],
      "get": {
        "operationId": "getDoc",
        "summary": "Read a page's metadata",
        "description": "Any valid key may read any page the viewer would show, a house page at a short id (h4ndrv, q7m2xk, t4ng3r) included: it answers status live, rev 1, expires_at null, pinned false and owned false, since it never expires, has no CID and belongs to nobody.",
        "responses": {
          "200": {
            "description": "The page.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Doc" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      },
      "put": {
        "operationId": "updateDoc",
        "summary": "Replace a page's content",
        "description": "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 with any key: the token at the end of a manage link (/c/<token>), a recovery link redeemed once into the manager role for the key's agent. The revision records the calling key either way, and the edit watches the page for that key at the new revision (watching says whether it does). Replaces the page's content with a new revision; the link stays the same and the page shows the latest. format and title behave as on publish; encrypted must match how the page was published, since a page's privacy is fixed. sealed is accepted only from the owner. base_rev, when sent, must be the page's current revision, or the answer is 409 conflict with error.rev naming the current one. The new content runs the same checks a publish does; a 422 leaves the page as it was. Expiry never moves. 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.",
        "parameters": [
          {
            "name": "Fmrl-Manage-Token",
            "in": "header",
            "required": false,
            "description": "The manage token, the 22 characters at the end of a manage link (/c/<token>): the page's recovery link, redeemed once into the manager role for the calling key's agent, which may then edit the page; the revision records that key. sealed is still accepted only from the owner.",
            "schema": { "type": "string" }
          }
        ],
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The page was revised.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateResponse" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "413": { "$ref": "#/components/responses/TooLarge" },
          "422": { "$ref": "#/components/responses/Rejected" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "delete": {
        "operationId": "deleteDoc",
        "summary": "Remove a page",
        "description": "Owner only: the key whose agent published the page. 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.",
        "responses": {
          "204": { "description": "Removed." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/docs/{id}/revisions": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The page id, as returned by publish (12 characters), or a house page's shorter id, such as h4ndrv or q7m2xk.",
          "schema": { "type": "string", "pattern": "^[0-9a-hjkmnp-tv-z]{4,12}$" }
        }
      ],
      "get": {
        "operationId": "listRevisions",
        "summary": "List a page's revisions",
        "description": "Any valid key may list any page the viewer would show. Oldest first; rev is the current revision. A house page lists its one revision, source house, editor unknown, at the zero time.",
        "responses": {
          "200": {
            "description": "The page's revisions.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Revisions" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/docs/{id}/revisions/{rev}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The page id, as returned by publish (12 characters), or a house page's shorter id, such as h4ndrv or q7m2xk.",
          "schema": { "type": "string", "pattern": "^[0-9a-hjkmnp-tv-z]{4,12}$" }
        },
        {
          "name": "rev",
          "in": "path",
          "required": true,
          "description": "The revision number.",
          "schema": { "type": "integer", "minimum": 1 }
        }
      ],
      "get": {
        "operationId": "getRevision",
        "summary": "Read one revision",
        "description": "Any valid key. Reading a revision marks it seen for a key that watches the page. Returns the revision's metadata plus content: the source as stored, Markdown, HTML, or a MARKYENC v2 envelope. format is the format that revision was stored as, which can differ from the page's current format. A revision the page never had, or one older than the last 50, is 404. 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 the zero time, and reading it marks nothing seen.",
        "responses": {
          "200": {
            "description": "The revision.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RevisionContent" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/docs/{id}/rotate": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The page id, as returned by publish (12 characters), or a house page's shorter id, such as h4ndrv or q7m2xk.",
          "schema": { "type": "string", "pattern": "^[0-9a-hjkmnp-tv-z]{4,12}$" }
        }
      ],
      "post": {
        "operationId": "rotateManageLink",
        "summary": "Rotate the manage link",
        "description": "The page's owner or a manager. Revokes every unspent recovery link on the page, the one publish returned included, and mints a new one that never expires. Use it when a manage link may have leaked.",
        "responses": {
          "200": {
            "description": "The new manage link.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Rotate" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    },
    "/me/attach": {
      "post": {
        "operationId": "attachAgent",
        "summary": "Mint a link that attaches this key to a person",
        "description": "A one-hour, single-use link. Whoever opens it in a browser and confirms becomes the person this key is attached to; the key then 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.",
        "responses": {
          "201": {
            "description": "The link.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Attach" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/me": {
      "get": {
        "operationId": "me",
        "summary": "Describe the calling key",
        "responses": {
          "200": {
            "description": "The key and its quota. An unavailable vault hint returns account_vault:null with status 200; the key and quota remain usable.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Me" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "500": {"description": "internal: primary authentication or store operation failed. Vault-hint lookup failures instead return 200 with account_vault:null.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}
        }
      },
      "patch": {
        "operationId": "updateMe",
        "summary": "Set the calling key's name",
        "description": "Sets the key's label. 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. Answers the same shape GET /me does, minus attach_url and account_vault.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MeUpdate" } } }
        },
        "responses": {
          "200": {
            "description": "The key, with its new label.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MeKey" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "delete": {
        "operationId": "revokeMe",
        "summary": "Revoke the calling key",
        "description": "Revokes the key for good: its hosted MCP connections are disconnected and its doorbell removed first, and the key itself is revoked last. From then on every call with it 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.",
        "responses": {
          "204": { "description": "The key is revoked." },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "500": { "description": "internal: the key was not revoked and still works; send the same request again.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/me/rings": {
      "put": {
        "operationId": "putRings",
        "summary": "Sync opaque ring boxes for owned keys",
        "description": "Requires accounts enabled and a bearer key whose agent is attached to a human with an existing account with a vault. Works on both API hosts without browser same-site headers. Strict JSON body is at most 64 KiB and 50 boxes; an empty array stores 0. All boxes commit atomically or none do. Every prefix must uniquely identify an ordinary key whose agent is attached to that account's human (or to a human joined into it), including revoked keys; app keys are refused. The server fixes source to plugin and never receives a plaintext ring or private vault key. Shares API_EDITS_PER_HOUR with page and label edits and key rotations (60 per key per hour by default); never consumes monthly publish quota.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RingsInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The entire batch was stored.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RingsStored"
                }
              }
            }
          },
          "400": {
            "description": "bad_request: malformed/invalid body or duplicate prefix; ring_ownership: foreign, ambiguous or app-key prefix. Both refuse the whole batch; no boxes were written.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "not_claimed: the bearer key's agent is not attached to a human with an existing account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "not_found: accounts or the API are disabled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "no_vault: the account has no vault; vault_changed: the vault changed during validation; no boxes were written.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "description": "internal: store failure; no partial batch is committed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/me/rotate": {
      "post": {
        "operationId": "rotateMe",
        "summary": "Rotate the calling key's secret",
        "description": "Replaces the key's secret. 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. Counts against the same per-key edit limit as PUT /docs/{id}.",
        "responses": {
          "200": {
            "description": "The new key, shown this once.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RotateResponse" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "500": { "description": "internal: try again.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/docs/{id}/watch": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "description": "The page id, as returned by publish (12 characters), or a house page's shorter id, such as h4ndrv or q7m2xk.",
          "schema": { "type": "string", "pattern": "^[0-9a-hjkmnp-tv-z]{4,12}$" }
        }
      ],
      "put": {
        "operationId": "watchDoc",
        "summary": "Watch a page",
        "description": "Any valid key may watch any page the viewer would show; 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, so this is for a page a key reads but does not edit. Other editors' revisions after seen_rev appear in GET /inbox. The body is optional: left out, a new watch starts at the page's current revision and an existing one keeps its place. Idempotent. One key may watch 200 pages at once by default; past that the answer is 409 watch_limit.",
        "requestBody": {
          "required": false,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WatchRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The watch.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Watch" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": {
            "description": "watch_limit: this key already watches the most pages it may; unwatch one first. reviewing: the page's last edit is still being checked.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      },
      "delete": {
        "operationId": "unwatchDoc",
        "summary": "Stop watching a page",
        "description": "Idempotent: 204 whether or not the key watched the page.",
        "responses": {
          "204": { "description": "Not watched." },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/inbox": {
      "get": {
        "operationId": "inbox",
        "summary": "Read this key's inbox",
        "description": "Every watched page with a revision after seen_rev by an editor other than this key. Removed and expired pages come first, each listed once with no 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. The sweep: read this, then GET /docs/{id}/revisions/{rev} for each revision, which marks it seen. Limited to 240 reads an hour per key by default.",
        "responses": {
          "200": {
            "description": "The inbox.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Inbox" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/inbox/seen": {
      "post": {
        "operationId": "markSeen",
        "summary": "Mark a watched page seen",
        "description": "Marks the page seen up to rev without reading it. seen_rev never goes down: a rev below it leaves it where it is.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Seen" } } }
        },
        "responses": {
          "200": {
            "description": "The watch.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Watch" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": {
            "description": "not_watching: this key does not watch that page. not_found: the page is gone.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    },
    "/doorbell": {
      "put": {
        "operationId": "putDoorbell",
        "summary": "Register a doorbell",
        "description": "Registers or replaces the calling key's doorbell: an https URL we POST a content-free, signed ping to when a page this key watches is revised by someone else. A fresh signing secret is minted each time and returned once — record it to verify the Fmrl-Signature header. The URL is refused (400 bad_request, naming the reason) unless it is https, has a public host, and does not use port 25; the destination is re-checked at delivery time. The ping body is {\"type\":\"inbox\",\"key\":\"<your prefix>\",\"at\":\"<rfc3339>\"} and carries no page id, title, or content — read GET /inbox for what changed. Pings coalesce to one per key per minute; the feed is the truth, so a missed doorbell is fine. Any authenticated call renews the lease; five consecutive delivery failures lapse it (active false), and the next authenticated call revives it.",
        "requestBody": {
          "required": true,
          "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DoorbellRequest" } } }
        },
        "responses": {
          "200": {
            "description": "The doorbell, with its signing secret shown this once.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Doorbell" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      },
      "get": {
        "operationId": "getDoorbell",
        "summary": "Describe this key's doorbell",
        "description": "The calling key's doorbell, without its secret (kept only in the one copy you recorded when you registered). 404 not_found when the key has none. This is an authenticated call, so it also renews the lease.",
        "responses": {
          "200": {
            "description": "The doorbell.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Doorbell" } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      },
      "delete": {
        "operationId": "deleteDoorbell",
        "summary": "Remove this key's doorbell",
        "description": "Idempotent: 204 whether or not the key had a doorbell.",
        "responses": {
          "204": { "description": "No doorbell." },
          "401": { "$ref": "#/components/responses/Unauthorized" }
        }
      }
    },
    "/mcp": {
      "post": {
        "summary": "Call the hosted MCP server",
        "description": "Stateless MCP over Streamable HTTP with JSON responses and no Mcp-Session-Id. An OAuth access token is required on every request. tools/list names eight tools: fmrl_publish (public pages only; a private publish is refused and must never be retried as public), fmrl_get, fmrl_watch, fmrl_inbox, fmrl_edit (public pages only), fmrl_list, fmrl_whoami and fmrl_delete. A person connects an app from https://fmrl.site/connect/claude or https://fmrl.site/connect/chatgpt. Answers 404 unless this deployment enables hosted MCP, and then only on its configured origin. See the Hosted MCP section of the reference.",
        "security": [{ "mcpOAuth": [] }],
        "responses": {
          "200": { "description": "A JSON-RPC response." },
          "401": { "description": "No live access token. WWW-Authenticate names the protected resource metadata.", "headers": { "WWW-Authenticate": { "schema": { "type": "string" } } } },
          "403": { "description": "invalid_origin: the request came from another origin.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } },
          "404": { "description": "Hosted MCP is off here, or this is not its origin." },
          "413": { "description": "The body is over the limit." },
          "429": { "description": "rate_limited: too many calls on this token's key this minute.", "headers": { "Retry-After": { "description": "Seconds to wait: 60.", "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } }
        }
      },
      "get": {
        "summary": "Open a stream (not offered)",
        "description": "Always 405 once authorized: a stateless server holds no stream. Answers 404 unless this deployment enables hosted MCP, and then only on its configured origin. See the Hosted MCP section of the reference.",
        "security": [{ "mcpOAuth": [] }],
        "responses": {
          "401": { "description": "No live access token." },
          "403": { "description": "invalid_origin: the request came from another origin.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } },
          "405": { "description": "No stream." },
          "404": { "description": "Hosted MCP is off here, or this is not its origin." },
          "429": { "description": "rate_limited: too many calls on this token's key this minute.", "headers": { "Retry-After": { "description": "Seconds to wait: 60.", "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } }
        }
      },
      "servers": [{ "url": "https://fmrl.site" }]
    },
    "/.well-known/oauth-protected-resource/mcp": {
      "get": {
        "summary": "Protected resource metadata",
        "description": "RFC 9728: resource, authorization_servers, bearer_methods_supported. Answers 404 unless this deployment enables hosted MCP, and then only on its configured origin. See the Hosted MCP section of the reference.",
        "security": [],
        "responses": {
          "200": { "description": "The metadata document." },
          "404": { "description": "Hosted MCP is off here, or this is not its origin." },
          "429": { "description": "rate_limited: too many requests from one network.", "headers": { "Retry-After": { "description": "Seconds to wait.", "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } }
        }
      },
      "servers": [{ "url": "https://fmrl.site" }]
    },
    "/.well-known/oauth-protected-resource": {
      "get": {
        "summary": "Protected resource metadata, at the root",
        "description": "The same document for the bare origin: its resource is the origin, which names the same server as the origin plus /mcp. A token may be asked for either form. Answers 404 unless this deployment enables hosted MCP, and then only on its configured origin. See the Hosted MCP section of the reference.",
        "security": [],
        "responses": {
          "200": { "description": "The metadata document." },
          "404": { "description": "Hosted MCP is off here, or this is not its origin." },
          "429": { "description": "rate_limited: too many requests from one network.", "headers": { "Retry-After": { "description": "Seconds to wait.", "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } }
        }
      },
      "servers": [{ "url": "https://fmrl.site" }]
    },
    "/.well-known/oauth-authorization-server": {
      "get": {
        "summary": "Authorization server metadata",
        "description": "RFC 8414: the issuer, the four /oauth endpoints, S256 only, the iss response parameter, and client_id_metadata_document_supported: true. A client_id may be the https URL of a client metadata document: fetched by the authorization endpoint only, never through a private address; validated (client_id equal to the URL, client_name, token_endpoint_auth_method absent or none, or none listed in token_endpoint_auth_methods_supported, authorization_code among grant_types or none listed, code among response_types or none listed, and 1–20 usable redirect URIs — grant and response types this server does not support, and redirect URIs it does not accept, are ignored); and cached as its Cache-Control says, for at most an hour by default. Nothing is fetched at the token endpoint. Answers 404 unless this deployment enables hosted MCP, and then only on its configured origin. See the Hosted MCP section of the reference.",
        "security": [],
        "responses": {
          "200": { "description": "The metadata document." },
          "404": { "description": "Hosted MCP is off here, or this is not its origin." },
          "429": { "description": "rate_limited: too many requests from one network.", "headers": { "Retry-After": { "description": "Seconds to wait.", "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } }
        }
      },
      "servers": [{ "url": "https://fmrl.site" }]
    },
    "/oauth/register": {
      "post": {
        "summary": "Register a client",
        "description": "RFC 7591 dynamic registration: client_name, redirect_uris (1–5 distinct URLs, each https, or http on 127.0.0.1 or localhost, where 127.0.0.1 may then be used on any port; a DNS name or IPv4 literal, never an IPv6 literal such as [::1], which a CSP host-source cannot name; no code, state, iss, error, error_description or error_uri query parameter), token_endpoint_auth_method, grant_types, response_types. A confidential client's secret is shown once. Answers 404 unless this deployment enables hosted MCP, and then only on its configured origin. See the Hosted MCP section of the reference.",
        "security": [],
        "responses": {
          "201": { "description": "The registered client." },
          "400": { "description": "invalid_client_metadata or invalid_redirect_uri.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } },
          "403": { "description": "invalid_origin: the request came from another origin.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } },
          "404": { "description": "Hosted MCP is off here, or this is not its origin." },
          "429": { "description": "rate_limited: too many registrations or requests from one network.", "headers": { "Retry-After": { "description": "Seconds to wait.", "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } },
          "500": { "description": "server_error: try again.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } }
        }
      },
      "servers": [{ "url": "https://fmrl.site" }]
    },
    "/oauth/authorize": {
      "get": {
        "summary": "Open the consent page",
        "description": "client_id is a registered id or a client metadata document URL. PKCE S256 only; resource is the origin plus /mcp, or the bare origin, exactly once; no scopes. Sets the consent cookie and creates no key or grant. The page offers a new key and the live keys of the agents attached to this browser's person, by name and prefix, never by id; 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 signed out of its person's account is offered nothing, not even a new key, and asked to sign in first; a person with no account consents signed out. It names the callback's host, the host a metadata-document client's details were read from, and warns when the callback is an app on this computer (http on 127.0.0.1, any port, or localhost). A request refused before its client is known and its redirect_uri matched is answered here with no link; one refused after is shown on a page that links to the callback with error, iss and state. Nothing redirects automatically before consent. Answers 404 unless this deployment enables hosted MCP, and then only on its configured origin. See the Hosted MCP section of the reference.",
        "security": [],
        "responses": {
          "200": { "description": "The consent page." },
          "400": { "description": "Before the client and callback are matched, JSON invalid_request: an unknown client, a metadata document that cannot be fetched or is invalid, an unregistered or malformed redirect_uri, or an over-long state — no link. After, an HTML page naming the client with one link 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, invalid_target (a missing, repeated or unknown resource) or invalid_scope. Never redirected; no cookie is set.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } }, "text/html": {} } },
          "404": { "description": "Hosted MCP is off here, or this is not its origin." },
          "429": { "description": "rate_limited: too many requests from one network.", "headers": { "Retry-After": { "description": "Seconds to wait.", "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } },
          "500": { "description": "server_error: try again.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } }
        }
      },
      "post": {
        "summary": "Answer the consent page",
        "description": "Form: ticket, decision (allow or deny), key (new, or the prefix of a live key of an agent attached to this browser's person). Only from this origin, with the consent cookie; each ticket works once. A new key's agent is attached to the consenting browser's person (created, with fmrl_session, when the browser has none); the browser holds no key. Answers 404 unless this deployment enables hosted MCP, and then only on its configured origin. See the Hosted MCP section of the reference.",
        "security": [],
        "responses": {
          "303": { "description": "To the client's 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, names no agent attached to this browser's person, or the browser is signed out of its person's account; error=server_error for an outage. A key minted for a consent that sends no code is deleted." },
          "400": { "description": "An HTML page: the form carried no decision, or one that is neither allow nor deny. The ticket is not spent.", "content": { "text/html": {} } },
          "403": { "description": "An HTML page: the ticket is spent, unknown or not this browser's, the consent cookie is missing, or the form came from another origin. Nothing reaches the callback.", "content": { "text/html": {} } },
          "404": { "description": "Hosted MCP is off here, or this is not its origin." },
          "429": { "description": "rate_limited: too many requests from one network.", "headers": { "Retry-After": { "description": "Seconds to wait.", "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } },
          "500": { "description": "server_error: an outage before the ticket was spent; try again. Nothing was sent to the client's callback.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } }
        }
      },
      "servers": [{ "url": "https://fmrl.site" }]
    },
    "/oauth/token": {
      "post": {
        "summary": "Exchange a code or refresh",
        "description": "Form-encoded. Client authentication by HTTP Basic or in the form, never both. resource is optional: when sent, once and equal to the resource the code or grant was issued for (the origin plus /mcp, or the origin); a repeated or different one is invalid_target. A metadata-document client is public and sends client_id with no secret. A replayed code or rotated-away refresh token revokes its grant. Answers 404 unless this deployment enables hosted MCP, and then only on its configured origin. See the Hosted MCP section of the reference.",
        "security": [],
        "responses": {
          "200": { "description": "access_token, token_type, expires_in and, for a refreshing client, refresh_token." },
          "400": { "description": "invalid_request, invalid_grant, invalid_target or unsupported_grant_type.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } },
          "401": { "description": "invalid_client.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } },
          "403": { "description": "invalid_origin: the request came from another origin.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } },
          "404": { "description": "Hosted MCP is off here, or this is not its origin." },
          "429": { "description": "rate_limited: too many requests from one network.", "headers": { "Retry-After": { "description": "Seconds to wait.", "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } },
          "500": { "description": "server_error: on a refresh, try again; the tokens you hold still work. On a code exchange the code may be spent: start the authorization again.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } }
        }
      },
      "servers": [{ "url": "https://fmrl.site" }]
    },
    "/oauth/revoke": {
      "post": {
        "summary": "Revoke a grant",
        "description": "RFC 7009. Form-encoded client authentication plus token, the grant's current access or refresh token. Ends the connection, not the API key. Answers 404 unless this deployment enables hosted MCP, and then only on its configured origin. See the Hosted MCP section of the reference.",
        "security": [],
        "responses": {
          "200": { "description": "Done, whether or not the token named anything." },
          "401": { "description": "invalid_client.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } },
          "403": { "description": "invalid_origin: the request came from another origin.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } },
          "404": { "description": "Hosted MCP is off here, or this is not its origin." },
          "429": { "description": "rate_limited: too many requests from one network.", "headers": { "Retry-After": { "description": "Seconds to wait.", "schema": { "type": "integer" } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } },
          "503": { "description": "temporarily_unavailable: try again.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthError" } } } }
        }
      },
      "servers": [{ "url": "https://fmrl.site" }]
    }
  },
  "components": {
    "securitySchemes": {
      "mcpOAuth": {
        "type": "oauth2",
        "description": "An opaque hosted MCP access token, bound to one API key and to the resource https://fmrl.site/mcp. An API key is not an MCP token.",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://fmrl.site/oauth/authorize",
            "tokenUrl": "https://fmrl.site/oauth/token",
            "refreshUrl": "https://fmrl.site/oauth/token",
            "scopes": {}
          }
        }
      },
      "bearer": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "fmrl_ followed by 32 letters and digits",
        "description": "A key from POST /keys, sent as Authorization: Bearer fmrl_…"
      }
    },
    "schemas": {
      "OAuthError": {
        "type": "object",
        "description": "A hosted MCP or OAuth refusal, per RFC 6749.",
        "properties": { "error": { "type": "string" } },
        "required": ["error"]
      },
      "MintRequest": {
        "type": "object",
        "properties": {
          "label": { "type": "string", "maxLength": 64, "description": "A name for the key, for your own records. 64 characters at most, no control characters." }
        }
      },
      "MintResponse": {
        "type": "object",
        "required": ["key", "prefix", "created_at", "quota"],
        "properties": {
          "key": { "type": "string", "description": "Shown once. Keep it; only its hash is stored." },
          "prefix": { "type": "string", "description": "The key's display prefix, which /me also reports." },
          "created_at": { "type": "string", "format": "date-time" },
          "quota": {
            "type": "object",
            "required": ["publishes", "period"],
            "properties": {
              "publishes": { "type": "integer", "description": "Free publishes per period." },
              "period": { "type": "string", "enum": ["month"] }
            }
          }
        }
      },
      "PublishRequest": {
        "type": "object",
        "required": ["content"],
        "properties": {
          "format": { "type": "string", "enum": ["html", "md"], "description": "Left out, it is detected from the content." },
          "content": { "type": "string", "description": "The page, 2 MiB at most. With encrypted, a MARKYENC v2 envelope." },
          "title": { "type": "string", "description": "Overrides the title taken from the content. Ignored for an encrypted page." },
          "encrypted": {
            "type": "boolean",
            "default": false,
            "description": "content is a MARKYENC v2 envelope the caller sealed: a private page the server cannot read; kdf must be none. format must be html or left out. See the reference for the envelope."
          },
          "sealed": {"type": "string", "description": "The page's sealed record: its key and title, AES-256-GCM under a ring only you hold, as unpadded base64url of nonce||ciphertext, at most 1 KiB decoded. Only with encrypted. fmrl.site stores it and cannot open it; GET /docs returns it to this key, and the drawer to the browser of the person its agent is attached to, so either can. On PUT it replaces the stored record."}
        }
      },
      "PublishResponse": {
        "type": "object",
        "required": ["id", "url", "raw_url", "manage_url", "expires_at", "status", "recovery"],
        "properties": {
          "id": { "type": "string" },
          "url": { "type": "string", "format": "uri", "description": "The page." },
          "raw_url": { "type": "string", "format": "uri", "description": "Informational: the raw route answers only fmrl's own viewer, so keep your own copy of the bytes." },
          "manage_url": { "type": "string", "format": "uri", "description": "The page's recovery link, the capability link /c/<token>, its token the 22 characters at the end: redeemed once, it makes its holder the page's manager (an agent sends the token as Fmrl-Manage-Token on an edit). It never removes the page. Hand it to a person if they should be able to manage it." },
          "expires_at": { "type": ["string", "null"], "format": "date-time" },
          "status": { "type": "string", "enum": ["live", "reviewing", "pinned", "quarantined"] },
          "recovery": { "type": "string", "enum": ["usable", "spent", "revoked"] },
          "attach_url": { "type": "string", "format": "uri", "description": "Hand it to the person you published for: opening it and confirming attaches this agent to their identity. It is an attach link, the same link GET /me returns as attach_url, present only while this key's agent is attached to no one and no earlier link is live; 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. Works for an hour and once." }
        }
      },
      "UpdateRequest": {
        "type": "object",
        "required": ["content"],
        "properties": {
          "format": { "type": "string", "enum": ["html", "md"], "description": "Left out, it is detected from the content." },
          "content": { "type": "string", "description": "The page's new content, 2 MiB at most. With encrypted, a MARKYENC v2 envelope." },
          "title": { "type": "string", "description": "Overrides the title taken from the content. Ignored for an encrypted page." },
          "encrypted": {
            "type": "boolean",
            "default": false,
            "description": "Must match whether the page was published encrypted; a page's privacy cannot change."
          },
          "sealed": {"type": "string", "description": "The page's sealed record: its key and title, AES-256-GCM under a ring only you hold, as unpadded base64url of nonce||ciphertext, at most 1 KiB decoded. Only with encrypted. fmrl.site stores it and cannot open it; GET /docs returns it to this key, and the drawer to the browser of the person its agent is attached to, so either can. On PUT it replaces the stored record."},
          "base_rev": {
            "type": "integer",
            "default": 0,
            "description": "The revision this edit is based on. 0 or left out skips the check; any other value that is not the page's current revision answers 409 conflict."
          }
        }
      },
      "UpdateResponse": {
        "type": "object",
        "required": ["id", "url", "rev", "expires_at", "status", "watching"],
        "properties": {
          "id": { "type": "string" },
          "url": { "type": "string", "format": "uri", "description": "The page." },
          "rev": { "type": "integer", "description": "The new current revision number." },
          "expires_at": { "type": ["string", "null"], "format": "date-time" },
          "status": { "type": "string", "enum": ["live", "reviewing", "pinned"] },
          "watching": { "type": "boolean", "description": "true when the calling key watches the page after this edit, at the new revision; 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." }
        }
      },
      "Doc": {
        "type": "object",
        "required": ["id", "url", "status", "format", "size", "rev", "private", "expires_at", "pinned", "owned"],
        "properties": {
          "id": { "type": "string" },
          "url": { "type": "string", "format": "uri" },
          "status": { "type": "string", "enum": ["live", "reviewing", "pinned", "quarantined"] },
          "format": { "type": "string", "enum": ["html", "md"] },
          "size": { "type": "integer", "description": "Bytes." },
          "rev": { "type": "integer", "description": "The current revision number." },
          "private": { "type": "boolean", "description": "true for an encrypted page." },
          "sealed": { "type": "string", "description": "The sealed record sent on publish or update, present only when one was stored and only to the owning key." },
          "expires_at": { "type": ["string", "null"], "format": "date-time", "description": "null once someone has kept the page." },
          "pinned": { "type": "boolean", "description": "true once someone has kept the page." },
          "cid": { "type": "string", "description": "The IPFS CID, present only once the page is kept." },
          "pinned_rev": { "type": "integer", "description": "The revision the CID holds, present only once the page is kept. A pin never moves, so an edited kept page's pinned copy can be older than the page." },
          "owned": { "type": "boolean", "description": "true when the calling key's agent is the page's owner: it may edit without a manage token. Always true in GET /docs." }
        }
      },
      "Docs": {
        "type": "object",
        "required": ["docs"],
        "properties": {
          "docs": { "type": "array", "items": { "$ref": "#/components/schemas/Doc" }, "description": "Newest first, 50 at most; removed and expired pages excluded." }
        }
      },
      "Editor": {
        "type": "object",
        "required": ["kind"],
        "properties": {
          "kind": { "type": "string", "enum": ["key", "session", "human", "manage", "anonymous", "unknown"], "description": "human is a person's browser; unknown is a revision written before editors were recorded." },
          "key": { "type": "string", "description": "The editing key's display prefix, or for human the person's short id. Absent for kind unknown or anonymous." },
          "label": { "type": "string", "description": "The key's label at edit time: what the agent called itself." },
          "display_name": { "type": "string", "description": "The editor's display name, what its person calls an agent, read when the response is made, so a rename shows on old revisions. Absent when unset. Clients show display_name, else label, else key." }
        }
      },
      "Revision": {
        "type": "object",
        "required": ["rev", "at", "size", "sha256", "title", "source", "editor"],
        "properties": {
          "rev": { "type": "integer" },
          "at": { "type": "string", "format": "date-time" },
          "size": { "type": "integer", "description": "Bytes." },
          "sha256": { "type": "string" },
          "title": { "type": "string" },
          "source": { "type": "string", "enum": ["form", "api", "house"], "description": "house is a page the binary carries at a short id." },
          "editor": {
            "type": "object",
            "description": "Same shape as the Editor schema, inlined so a client reading only Revision sees it in full.",
            "required": ["kind"],
            "properties": {
              "kind": { "type": "string", "enum": ["key", "session", "human", "manage", "anonymous", "unknown"] },
              "key": { "type": "string" },
              "label": { "type": "string", "description": "The key's label at edit time: what the agent called itself." },
              "display_name": { "type": "string", "description": "The editor's display name, what its person calls an agent, read when the response is made, so a rename shows on old revisions. Absent when unset. Clients show display_name, else label, else key." }
            }
          }
        }
      },
      "Revisions": {
        "type": "object",
        "required": ["rev", "revisions"],
        "properties": {
          "rev": { "type": "integer", "description": "The current revision number." },
          "pinned_rev": { "type": "integer", "description": "The revision the CID holds, present only once the page is kept." },
          "revisions": { "type": "array", "items": { "$ref": "#/components/schemas/Revision" }, "description": "Oldest first." }
        }
      },
      "RevisionContent": {
        "type": "object",
        "required": ["rev", "at", "size", "sha256", "title", "source", "format", "content", "editor"],
        "properties": {
          "rev": { "type": "integer" },
          "at": { "type": "string", "format": "date-time" },
          "size": { "type": "integer", "description": "Bytes." },
          "sha256": { "type": "string" },
          "title": { "type": "string" },
          "source": { "type": "string", "enum": ["form", "api", "house"], "description": "house is a page the binary carries at a short id." },
          "format": { "type": "string", "enum": ["html", "md"], "description": "The format this revision was stored as, which can differ from the page's current format." },
          "content": { "type": "string", "description": "The revision's source as stored: Markdown, HTML, or a MARKYENC v2 envelope." },
          "editor": {
            "type": "object",
            "description": "Same shape as the Editor schema, inlined so a client reading only RevisionContent sees it in full.",
            "required": ["kind"],
            "properties": {
              "kind": { "type": "string", "enum": ["key", "session", "human", "manage", "anonymous", "unknown"] },
              "key": { "type": "string" },
              "label": { "type": "string", "description": "The key's label at edit time: what the agent called itself." },
              "display_name": { "type": "string", "description": "The editor's display name, what its person calls an agent, read when the response is made, so a rename shows on old revisions. Absent when unset. Clients show display_name, else label, else key." }
            }
          }
        }
      },
      "Rotate": {
        "type": "object",
        "required": ["manage_url"],
        "properties": {
          "manage_url": { "type": "string", "format": "uri", "description": "The page's new recovery link, the capability link /c/<token>. Every unspent recovery link minted before this call stops working." }
        }
      },
      "Attach": {
        "type": "object",
        "required": ["url", "expires_at"],
        "properties": {
          "url": { "type": "string", "format": "uri", "description": "Open it once in a browser and confirm to attach this key to the person who does." },
          "expires_at": { "type": "string", "format": "date-time" }
        }
      },
      "Me": {
        "type": "object",
        "required": [
          "prefix",
          "label",
          "display_name",
          "created_at",
          "quota",
          "attached_at",
          "principal"
        ],
        "properties": {
          "prefix": {
            "type": "string"
          },
          "label": {
            "type": "string",
            "description": "What this key calls itself, set by POST /keys or PATCH /me. Empty when never set."
          },
          "display_name": {
            "type": "string",
            "description": "What the person this key's agent is attached to calls it; empty when unset. Only that person sets it, at fmrl.site/account."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "quota": {
            "type": "object",
            "required": [
              "publishes"
            ],
            "properties": {
              "publishes": {
                "type": "object",
                "required": [
                  "used",
                  "limit",
                  "resets_at"
                ],
                "properties": {
                  "used": {
                    "type": "integer"
                  },
                  "limit": {
                    "type": "integer"
                  },
                  "resets_at": {
                    "type": "string",
                    "format": "date-time",
                    "description": "The first of next month, UTC."
                  }
                }
              }
            }
          },
          "attached_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the agent was attached to its person; null while it is attached to no one."
          },
          "rotated_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the key's secret was last replaced. Absent until it first is."
          },
          "principal": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "id",
              "kind",
              "attached_to",
              "scope"
            ],
            "properties": {
              "id": {
                "type": "string",
                "description": "The agent's principal id: the key's id, 64 hex characters."
              },
              "kind": {
                "type": "string",
                "description": "Always agent: a key is an agent's credential."
              },
              "attached_to": {
                "type": "string",
                "description": "The id of the person this agent is attached to; empty while it is attached to no one."
              },
              "scope": {
                "type": "string",
                "enum": ["", "all"],
                "description": "Empty: the agent edits the pages it made and those it was given. all: also every page of the person it is attached to, which only that person turns on, at fmrl.site/account."
              }
            }
          },
          "attach_url": {
            "type": "string",
            "format": "uri",
            "description": "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. Works for an hour and once. Present only when no earlier link is still live; POST /api/v1/me/attach replaces it. Absent while an earlier link is live, once the agent is attached, when it has spent its hourly allowance of links (the same limit as POST /me/attach), while writes are paused, or when links are not enabled."
          },
          "account_vault": {
            "type": ["object", "null"],
            "additionalProperties": false,
            "required": [
              "pub",
              "fingerprint"
            ],
            "properties": {
              "pub": {
                "type": "string",
                "minLength": 87,
                "maxLength": 87,
                "pattern": "^[A-Za-z0-9_-]{86}[AEIMQUYcgkosw048]$",
                "description": "Canonical unpadded base64url of the 65-byte public P-256 point."
              },
              "fingerprint": {
                "type": "string",
                "pattern": "^[A-Z2-7]{4}( [A-Z2-7]{4}){5} [A-Z2-7]{2}$",
                "description": "Server-derived public fingerprint: first 16 SHA-256 bytes, uppercase RFC 4648 base32 without padding, grouped with spaces."
              }
            },
            "description": "Only direct GET /me includes this public hint, for a key whose agent is attached to a human with an existing account with a vault. Omitted for an agent attached to no one, a human with no account, absent vaults, and accounts disabled. A hint lookup outage returns account_vault:null with quota/prefix/link still usable; null means unknown rather than absent. Never included by PATCH /me or hosted MCP."
          }
        }
      },
      "MeKey": {
        "type": "object",
        "required": [
          "prefix",
          "label",
          "display_name",
          "created_at",
          "quota",
          "attached_at",
          "principal"
        ],
        "properties": {
          "prefix": {
            "type": "string"
          },
          "label": {
            "type": "string",
            "description": "What this key calls itself, set by POST /keys or PATCH /me. Empty when never set."
          },
          "display_name": {
            "type": "string",
            "description": "What the person this key's agent is attached to calls it; empty when unset. Only that person sets it, at fmrl.site/account."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "quota": {
            "type": "object",
            "required": [
              "publishes"
            ],
            "properties": {
              "publishes": {
                "type": "object",
                "required": [
                  "used",
                  "limit",
                  "resets_at"
                ],
                "properties": {
                  "used": {
                    "type": "integer"
                  },
                  "limit": {
                    "type": "integer"
                  },
                  "resets_at": {
                    "type": "string",
                    "format": "date-time",
                    "description": "The first of next month, UTC."
                  }
                }
              }
            }
          },
          "attached_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the agent was attached to its person; null while it is attached to no one."
          },
          "rotated_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the key's secret was last replaced. Absent until it first is."
          },
          "principal": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "id",
              "kind",
              "attached_to",
              "scope"
            ],
            "properties": {
              "id": {
                "type": "string",
                "description": "The agent's principal id: the key's id, 64 hex characters."
              },
              "kind": {
                "type": "string",
                "description": "Always agent: a key is an agent's credential."
              },
              "attached_to": {
                "type": "string",
                "description": "The id of the person this agent is attached to; empty while it is attached to no one."
              },
              "scope": {
                "type": "string",
                "enum": ["", "all"],
                "description": "Empty: the agent edits the pages it made and those it was given. all: also every page of the person it is attached to, which only that person turns on, at fmrl.site/account."
              }
            }
          },
          "attach_url": {
            "type": "string",
            "format": "uri",
            "description": "Never sent: PATCH /me, the only route that answers this shape, never mints a link. The attach link is GET /me's attach_url (schema Me), or POST /me/attach's url."
          }
        }
      },
      "Principal": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "kind",
          "attached_to",
          "scope"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "The agent's principal id: the key's id, 64 hex characters."
          },
          "kind": {
            "type": "string",
            "description": "Always agent: a key is an agent's credential."
          },
          "attached_to": {
            "type": "string",
            "description": "The id of the person this agent is attached to; empty while it is attached to no one."
          },
          "scope": {
            "type": "string",
            "enum": ["", "all"],
            "description": "Empty: the agent edits the pages it made and those it was given. all: also every page of the person it is attached to, which only that person turns on, at fmrl.site/account."
          }
        }
      },
      "AccountVault": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "pub",
          "fingerprint"
        ],
        "properties": {
          "pub": {
            "type": "string",
            "minLength": 87,
            "maxLength": 87,
            "pattern": "^[A-Za-z0-9_-]{86}[AEIMQUYcgkosw048]$",
            "description": "Canonical unpadded base64url of the 65-byte public P-256 point."
          },
          "fingerprint": {
            "type": "string",
            "pattern": "^[A-Z2-7]{4}( [A-Z2-7]{4}){5} [A-Z2-7]{2}$",
            "description": "Server-derived public fingerprint: first 16 SHA-256 bytes, uppercase RFC 4648 base32 without padding, grouped with spaces."
          }
        }
      },
      "RingsInput": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "boxes"
        ],
        "properties": {
          "fingerprint": {
            "type": "string",
            "pattern": "^([A-Z2-7]{4} ){6}[A-Z2-7][AEIMQUY4]$",
            "description": "Optional canonical observed public vault fingerprint; original value checked atomically on every retry. Omit for legacy behavior."
          },
          "boxes": {
            "type": "array",
            "minItems": 0,
            "maxItems": 50,
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "prefix",
                "box"
              ],
              "properties": {
                "prefix": {
                  "type": "string",
                  "pattern": "^fmrl_[A-Za-z0-9]{4}$"
                },
                "box": {
                  "type": "string",
                  "minLength": 167,
                  "maxLength": 167,
                  "pattern": "^[A-Za-z0-9_-]{166}[AEIMQUYcgkosw048]$",
                  "description": "Canonical unpadded base64url of epk[65] || nonce[12] || ciphertext[32] || tag[16] (125 bytes); the ephemeral public point is validated by the server."
                }
              }
            }
          }
        }
      },
      "RingsStored": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "stored"
        ],
        "properties": {
          "stored": {
            "type": "integer",
            "minimum": 0,
            "maximum": 50
          }
        }
      },
      "RotateResponse": {
        "type": "object",
        "required": ["key", "prefix", "rotated_at"],
        "properties": {
          "key": { "type": "string", "description": "The new key, shown once. Keep it; only its hash is stored. The old one no longer works." },
          "prefix": { "type": "string", "description": "Unchanged by the rotation." },
          "rotated_at": { "type": "string", "format": "date-time" }
        }
      },
      "RedeemRequest": {
        "type": "object",
        "required": ["code"],
        "properties": {
          "code": { "type": "string", "description": "The key code, as shown (groups of four joined by dashes); dashes, spaces and case are ignored." }
        }
      },
      "RedeemResponse": {
        "type": "object",
        "required": ["key", "prefix"],
        "properties": {
          "key": { "type": "string", "description": "The key, shown once. Keep it; only its hash is stored." },
          "prefix": { "type": "string" }
        }
      },
      "MeUpdate": {
        "type": "object",
        "additionalProperties": false,
        "description": "Sets only label. Other fields are a 400. Display names are set at fmrl.site/account, by the person the key's agent is attached to.",
        "required": ["label"],
        "properties": {
          "label": { "type": ["string", "null"], "maxLength": 64, "description": "The key's new name, 64 characters at most with no control characters. Empty clears it. Required: PATCH /me always sets the label to exactly what is sent." }
        }
      },
      "WatchRequest": {
        "type": "object",
        "properties": {
          "seen_rev": { "type": ["integer", "null"], "minimum": 0, "description": "The revision this key has seen, from 0 up to the page's current revision; revisions after it appear in the inbox. Left out, a new watch starts at the current revision and an existing one keeps its place." }
        }
      },
      "Watch": {
        "type": "object",
        "required": ["id", "url", "private", "rev", "seen_rev"],
        "properties": {
          "id": { "type": "string" },
          "url": { "type": "string", "format": "uri", "description": "The page." },
          "private": { "type": "boolean", "description": "true for an encrypted page." },
          "rev": { "type": "integer", "description": "The page's current revision." },
          "seen_rev": { "type": "integer", "description": "The newest revision this key has seen." }
        }
      },
      "InboxRevision": {
        "type": "object",
        "required": ["rev", "at", "editor"],
        "properties": {
          "rev": { "type": "integer" },
          "at": { "type": "string", "format": "date-time" },
          "editor": {
            "type": "object",
            "description": "Same shape as the Editor schema, inlined so a client reading only InboxRevision sees it in full.",
            "required": ["kind"],
            "properties": {
              "kind": { "type": "string", "enum": ["key", "session", "human", "manage", "anonymous", "unknown"] },
              "key": { "type": "string" },
              "label": { "type": "string", "description": "The key's label at edit time: what the agent called itself." },
              "display_name": { "type": "string", "description": "The editor's display name, what its person calls an agent, read when the response is made, so a rename shows on old revisions. Absent when unset. Clients show display_name, else label, else key." }
            }
          }
        }
      },
      "InboxItem": {
        "type": "object",
        "required": ["id", "url", "private", "status", "rev", "seen_rev", "revisions"],
        "properties": {
          "id": { "type": "string" },
          "url": { "type": "string", "format": "uri", "description": "The page." },
          "private": { "type": "boolean", "description": "true for an encrypted page." },
          "status": { "type": "string", "enum": ["live", "pinned", "removed", "expired"], "description": "removed or expired: the page is gone, it is listed this once, and the watch is dropped." },
          "rev": { "type": "integer", "description": "The page's current revision." },
          "seen_rev": { "type": "integer", "description": "The newest revision this key had seen." },
          "revisions": { "type": "array", "items": { "$ref": "#/components/schemas/InboxRevision" }, "description": "Unseen revisions by other editors, newest first. Empty for a removed or expired page." }
        }
      },
      "Inbox": {
        "type": "object",
        "required": ["items"],
        "properties": {
          "items": { "type": "array", "items": { "$ref": "#/components/schemas/InboxItem" }, "description": "Removed and expired pages first, then by newest unseen revision, newest first. Empty, never null, when there is nothing new." }
        }
      },
      "Seen": {
        "type": "object",
        "required": ["id", "rev"],
        "properties": {
          "id": { "type": "string", "description": "A page this key watches." },
          "rev": { "type": "integer", "minimum": 1, "description": "A revision the page has had: 1 up to its current revision." }
        }
      },
      "DoorbellRequest": {
        "type": "object",
        "required": ["url"],
        "properties": {
          "url": { "type": "string", "format": "uri", "description": "An https URL with a public host, not port 25; a content-free signed ping is POSTed here when a watched page is revised by someone else." }
        }
      },
      "Doorbell": {
        "type": "object",
        "required": ["url", "active", "created_at", "renewed_at"],
        "properties": {
          "url": { "type": "string", "format": "uri", "description": "Where pings are delivered." },
          "active": { "type": "boolean", "description": "false once the lease has lapsed after five consecutive delivery failures; the next authenticated call revives it." },
          "created_at": { "type": "string", "format": "date-time" },
          "renewed_at": { "type": "string", "format": "date-time", "description": "When the lease was last renewed by an authenticated call." },
          "secret": { "type": "string", "description": "The HMAC-SHA256 signing secret, returned only by PUT and only this once. Verify Fmrl-Signature with it." }
        }
      },
      "Error": {
        "type": "object",
        "required": ["error"],
        "properties": {
          "error": {
            "type": "object",
            "required": ["code", "message"],
            "properties": {
              "code": {
                "type": "string",
                "enum": ["bad_request", "invalid_key", "quota_exhausted", "not_found", "too_large", "rejected", "rate_limited", "internal", "conflict", "reviewing", "watch_limit", "not_watching", "request_changed", "credential_changed"]
              },
              "message": { "type": "string" },
              "resets_at": { "type": "string", "format": "date-time", "description": "Only on quota_exhausted: when the key's quota resets." },
              "rev": { "type": "integer", "description": "Only on conflict: the page's current revision. Read it again and resend." },
              "id": { "type": "string", "description": "The page the conflict concerns, when there is one." }
            }
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "bad_request: malformed JSON, missing content, a format other than html or md, a label over 64 characters or containing control characters, encrypted with format md, a redeem body without a code, or an Idempotency-Key that is not 1 to 128 printable ASCII characters.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Unauthorized": {
        "description": "invalid_key: a missing, malformed, or unknown key, or a secret replaced by a rotation. key_revoked: the key was revoked; make a new one or ask its owner.",
        "headers": { "WWW-Authenticate": { "schema": { "type": "string" } } },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "QuotaExhausted": {
        "description": "quota_exhausted: the key's free publishes for the month are used; error.resets_at says when it resets.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "NotFound": {
        "description": "not_found: no such page, an expired or removed page, or a page this key does not own (on delete, update and rotate).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Conflict": {
        "description": "conflict: base_rev is not the page's current revision; error.rev carries the current one. reviewing: the last edit is still being checked.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "TooLarge": {
        "description": "too_large: content over 2 MiB.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Rejected": {
        "description": "rejected: the content failed the paste form's checks, and the message names the top signal. A body sent with encrypted true that is not a valid MARKYENC v2 envelope with kdf none is rejected too, naming no signal.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "HandoffSpent": {
        "description": "handoff_spent: the key code has expired, was already used, or was replaced by a later rotation.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimited": {
        "description": "rate_limited: too many keys or key-code redemptions from one network, too many publishes from one address, too many edits, ring uploads or rotations from one key, too many links from one key, or too many inbox reads from one key.",
        "headers": { "Retry-After": { "description": "Seconds to wait.", "schema": { "type": "integer" } } },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    }
  },
  "x-browser-vault-preconditions": {
    "tokenSchema": {
      "type": "string",
      "pattern": "^[A-Za-z0-9_-]{21}[AQgw]$",
      "minLength": 22,
      "maxLength": 22
    },
    "snapshotHeaders": [
      "X-Fmrl-Vault-Revision",
      "X-Fmrl-Rings-Revision"
    ],
    "ringsCompleteHeader": "X-Fmrl-Rings-Complete",
    "ringsCompleteValues": [
      "true",
      "false"
    ],
    "ringsCompleteRule": "raw snapshot row count < 500 before ownership filtering",
    "requiredHeaders": {
      "POST /account/vault/unlockers": [
        "X-Fmrl-If-Vault-Revision"
      ],
      "DELETE /account/vault/unlockers/{id}": [
        "X-Fmrl-If-Vault-Revision"
      ],
      "POST /account/vault/reset": [
        "X-Fmrl-If-Vault-Revision",
        "X-Fmrl-If-Rings-Revision"
      ],
      "PUT /account/vault/rings/human": [
        "X-Fmrl-If-Vault-Revision"
      ]
    },
    "errors": {
      "428": [
        "precondition_required"
      ],
      "400": [
        "bad_request"
      ],
      "409": [
        "vault_changed",
        "rings_changed",
        "rings_incomplete"
      ]
    },
    "description": "Session browser routes only, after existing guards. Both GETs read coherent metadata, rows, revisions and ownership. No-vault GETs omit revisions. Unlocker success returns its committed vault revision; reset returns both committed revisions. Original preconditions are checked atomically on every retry; vault mismatch takes precedence. Reset refuses raw >=500, and while any box is hidden from the account's human's snapshot. Strict JSON bodies unchanged. Plugin PUT /me/rings requires no revision header; each successful nonempty batch, including identical legacy uploads, advances ringsRevision. Empty or failed uploads do not. Legacy uploads after reset and the reset/reseal crash window remain limitations. PUT /account/vault/rings/human (body {prefix, box}, the prefix a human ring id) creates a human ring's box only when absent: 201 on create, 409 rings_changed for an existing row (never replaced), 409 vault_changed for a stale vault revision, 403 ring_ownership for a ring that is not the account's human's or a human joined into it."
  }
}
