{
  "openapi": "3.1.0",
  "info": {
    "title": "Stacktree API",
    "version": "1.0.0",
    "summary": "Agent-first private-by-default HTML hosting: host a web page from any agent.",
    "description": "Stacktree hosts web pages for AI agents: turn generated HTML (a report, dashboard, landing page or static site) into a live, shareable webpage on a permanent private link. Publish, manage, and share static HTML sites from AI agents. Supports anonymous publishing for one-shot artefacts, API-key auth for long-lived agent jobs, and OAuth 2.1 with Dynamic Client Registration for in-product agent connectors (claude.ai, ChatGPT custom connectors, etc.).  ## Versioning and deprecation The API is major version 1. Every unversioned path is also served under the `/v1/` prefix (`/v1/sites` = `/sites`); pin `/v1/` if you want the version in the URL. Breaking changes only ever ship under a new prefix (`/v2/`), never in place. Deprecations are signalled in-band with `Deprecation` and `Sunset` response headers (RFC 8594) at least 90 days before removal, and listed in the changelog at https://stacktr.ee/changelog. Nothing is deprecated today.  ## Rate limits Rate-limited surfaces return standard headers on every response: `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (seconds until the rolling window frees), the combined `RateLimit` field, and `RateLimit-Policy`. A `429` additionally carries `Retry-After` in seconds. Current windows: anonymous publishes and per-plan daily publish caps roll over 24 h; OAuth dynamic client registration is per-IP per hour. Read the caps from `GET /me` rather than hardcoding them.  See /llms.txt for the agent-readable overview, and /docs for human docs.",
    "contact": {
      "name": "Stacktree",
      "url": "https://stacktr.ee",
      "email": "gm@stacktr.ee"
    },
    "license": {
      "name": "Proprietary"
    },
    "termsOfService": "https://stacktr.ee/terms",
    "x-guidance": "Stacktree hosts generated HTML (reports, dashboards, landing pages, static sites) as private, shareable web pages. Anonymous: POST /sites with a multipart `file` field to get a private 24h URL, no auth. One permanent page, no key: POST /publish and pay $0.50 over x402 or MPP. Persistent: pay $1 once over x402 at POST /provision to receive an stk_live_ API key, then POST /sites with `Authorization: Bearer <key>`. That key starts on free-tier limits (3 pages in total, each expiring after 7 days, passcodes included); POST /unlock buys more. Each response returns the private URL. Gate a site with a passcode or email domain, set an expiry, or attach your own domain via the /sites and /custom-domains routes. Plan ceilings return HTTP 402 with a plan_* error code, described in the 402 responses below."
  },
  "servers": [
    {
      "url": "https://api.stacktr.ee",
      "description": "Production API host (current major version; /v1 is an identical alias)"
    }
  ],
  "externalDocs": {
    "description": "Human-readable docs",
    "url": "https://stacktr.ee/docs"
  },
  "x-service-info": {
    "categories": [
      "web",
      "storage"
    ],
    "docs": {
      "homepage": "https://stacktr.ee",
      "apiReference": "https://api.stacktr.ee/openapi.json",
      "llms": "https://stacktr.ee/llms.txt"
    }
  },
  "x-discovery": {
    "ownershipProofs": [
      "0x3ff8b9344028603569a7eab333dc9e50c4b62a1056c0814dfcf109d30f2bb44841a0e694a1697db648082192a71ca3b76c7cee3d8c7c696c0ae1f38ef8a8996f1c"
    ]
  },
  "components": {
    "securitySchemes": {
      "bearerApiKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "API key (stk_live_…)",
        "description": "Long-lived API key, created from app.stacktr.ee, bought over x402 at POST /provision, or minted headlessly via the RFC 8628 device-code flow (POST /api-keys/device-code). Pass as `Authorization: Bearer stk_live_…`. Accepted on every REST route AND on the MCP transport at POST /mcp."
      },
      "walletSignature": {
        "type": "apiKey",
        "in": "header",
        "name": "Authorization",
        "description": "Keyless auth for the wallet that PAID for a page (x402 pay-per-publish): POST /wallet-auth/challenge {\"wallet\":\"0x…\"}, personal_sign (EIP-191) the returned message with that wallet, then send `Authorization: Wallet challenge=WAUTH-…,sig=0x…`. Single-use, 5-minute TTL, EVM EOA only. Scope: PUT /sites/{id} on pages carrying that payer_wallet (adopted pages while the wallet stays account-linked)."
      },
      "claimToken": {
        "type": "apiKey",
        "in": "header",
        "name": "Authorization",
        "description": "Keyless auth for an UNCLAIMED page, using the `claim_token` that page's own publish response returned: send `Authorization: Claim <claim_token>`. Works on any rail (no wallet, no signature), which is what a Solana x402 payer and a free anonymous publisher have. Scope is deliberately one verb on one page: PUT /sites/{idOrSlug} replaces its content and nothing else, never another page, never claiming, deletion or settings. It stops working the moment the page is claimed (claiming rotates the token into the account), when the page expires, and for a blocked actor. Anonymous updates are rate limited per IP (RateLimit headers on every response) and require a direct client connection. Treat the token as being as sensitive as the page: anyone holding it can replace the content behind a link already sent to a client."
      },
      "oauth2": {
        "type": "oauth2",
        "description": "OAuth 2.1 with PKCE + Dynamic Client Registration (RFC 7591). Used by claude.ai connectors and other agent runtimes.",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://api.stacktr.ee/oauth/authorize",
            "tokenUrl": "https://api.stacktr.ee/oauth/token",
            "refreshUrl": "https://api.stacktr.ee/oauth/token",
            "scopes": {
              "sites:read": "List and read site metadata.",
              "sites:write": "Create, update, and delete sites."
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Stable machine-readable error code."
          },
          "error_description": {
            "type": "string",
            "description": "Human-readable detail (optional)."
          }
        },
        "example": {
          "error": "auth required"
        }
      },
      "Site": {
        "type": "object",
        "required": [
          "id",
          "visibility",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Stable site identifier."
          },
          "slug": {
            "type": "string",
            "nullable": true,
            "description": "Public subdomain slug, if the site is public."
          },
          "visibility": {
            "type": "string",
            "enum": [
              "unlisted",
              "public",
              "burn"
            ],
            "description": "Privacy mode."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Canonical URL to view the site."
          },
          "created_at": {
            "type": "integer",
            "description": "Unix epoch seconds."
          },
          "updated_at": {
            "type": "integer",
            "description": "Unix epoch seconds."
          },
          "expires_at": {
            "type": "integer",
            "nullable": true,
            "description": "Unix epoch seconds; null = no expiry. Clamped to the plan ceiling at write time (Free: 7 days, anonymous: 24 hours), so this can come back sooner than you asked for."
          },
          "expires_at_iso": {
            "type": "string",
            "nullable": true,
            "description": "The same instant as `expires_at`, RFC 3339 UTC, second granularity (e.g. \"2026-09-08T12:00:00Z\"). Present so a client can show a deadline without reconstructing it from the epoch."
          },
          "ttl_seconds": {
            "type": "integer",
            "nullable": true,
            "description": "Seconds from now until the page stops serving. Null when it does not expire; 0 when it is already out of time. Never negative."
          },
          "expiry_clamped": {
            "type": "boolean",
            "description": "True when the lifetime THIS request asked for was longer than the plan allows and `expires_at` is the shortened reality. Always present, false included. False on reads, which ask for no lifetime. Asking for a page that never expires on a capped plan is refused (409 `expiry_clamped`) rather than clamped, unless `accept_clamp` is sent."
          },
          "expiry_ceiling_hours": {
            "type": "integer",
            "nullable": true,
            "description": "The longest lifetime this plan allows a page, in hours: 24 anonymous, 168 on Free, null on plans with no ceiling and on the paid one-off rails."
          },
          "expiry_source": {
            "type": "string",
            "enum": [
              "request",
              "plan_ceiling",
              "plan_default",
              "stored"
            ],
            "description": "What decided the date: `request` (the caller named it), `plan_ceiling` (the plan cap did), `plan_default` (the plan default did), or `stored` (this call only read the row)."
          },
          "byte_size": {
            "type": "integer",
            "description": "Total content size in bytes."
          },
          "email_gate": {
            "type": "object",
            "nullable": true,
            "properties": {
              "domain": {
                "type": "string"
              },
              "required": {
                "type": "boolean"
              }
            }
          },
          "metrics_locked": {
            "type": "boolean",
            "description": "True when the plan has no viewer numbers (anonymous and Free). The three viewer fields below are then null; there is no number to reveal, blur, or approximate."
          },
          "opened": {
            "type": "boolean",
            "description": "Whether anyone has opened the page. Returned on every plan, including when metrics_locked is true, so \"someone read this\" survives the lock."
          },
          "badged": {
            "type": "boolean",
            "description": "Whether THIS page carries the \"Made with Stacktree\" footer as served on its stacktr.ee URL. Returned by GET /sites/{idOrSlug} only. A fact about the page, not about the plan: pages published before 2026-08-03, generated client portals and end-to-end encrypted pages carry no badge even on Free, and a remove_badge entitlement lifts it off one page or a whole account. Never assume it from the plan; anything offering to remove the badge should read this."
          },
          "view_count": {
            "type": "integer",
            "nullable": true,
            "description": "Raw view count. Null when metrics_locked."
          },
          "unique_viewers": {
            "type": "integer",
            "nullable": true,
            "description": "Distinct human viewers over the last 30 days. Null when metrics_locked."
          },
          "last_viewed_at": {
            "type": "integer",
            "nullable": true,
            "description": "Unix epoch seconds of the most recent open. Null when metrics_locked, or when never opened."
          },
          "reading": {
            "type": "object",
            "nullable": true,
            "description": "Returned by GET /sites. How closely the newest named reader read this page: their visits and reading time, the sender's own previews excluded. Null when that open carried no name, or when metrics_locked. `seconds` is null on a plan without reading detail (Solo).",
            "properties": {
              "by": {
                "type": "string"
              },
              "others": {
                "type": "integer",
                "description": "How many other named readers opened it."
              },
              "visits": {
                "type": "integer"
              },
              "seconds": {
                "type": "integer",
                "nullable": true
              }
            }
          },
          "client": {
            "type": "object",
            "nullable": true,
            "description": "Client space this page is filed under, or null for a floating page.",
            "properties": {
              "slug": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "client_url": {
            "type": "string",
            "description": "Present on publish when the space has a connected hostname and the page got a space_path: the page’s address on the client’s own domain (e.g. https://acme.theiragency.com/june-report). Prefer handing this link to the user."
          },
          "recipient": {
            "type": "object",
            "nullable": true,
            "description": "Present on publish when the request named who the page is for (`recipient`). `link` is their own link (`?to=` on the page URL): hand that over rather than `url`, and every open through it comes back with their name. On GET /sites: the newest person the page was named for, on every plan (whether they opened it is a viewer number and is not here), or null.",
            "properties": {
              "name": {
                "type": "string"
              },
              "link": {
                "type": "string"
              },
              "added_at": {
                "type": "integer",
                "description": "GET /sites only. Unix seconds when they were named."
              },
              "others": {
                "type": "integer",
                "description": "GET /sites only. How many other people the page was named for."
              }
            }
          },
          "claim_token": {
            "type": "string",
            "nullable": true,
            "description": "Anonymous publishes only. Adopts the page into an account (POST /sites/{idOrSlug}/claim) and, until then, is its keyless update credential. As sensitive as the page."
          },
          "claim_url": {
            "type": "string",
            "format": "uri",
            "description": "Anonymous publishes only. The dashboard claim screen for this page: sign in, and the page moves into that account at the same address."
          },
          "keep_url": {
            "type": "string",
            "format": "uri",
            "description": "Anonymous publishes only. Give this to the person you published for: it opens a page that says when this page stops working and offers both ways to keep it (a free account, or the link by email), and it marks their browser so the page itself shows them a Keep button. It carries the claim token, so it is as sensitive as claim_url: share `url`, never this."
          },
          "deleted_at": {
            "type": "integer",
            "nullable": true,
            "description": "Unix epoch seconds the page stopped serving; null when it is live. Non-null means the URL is dead for everyone holding it."
          },
          "delete_reason": {
            "type": "string",
            "nullable": true,
            "description": "Why it stopped serving: `expired`, `owner` (someone deleted it), `abuse` (a takedown), or `merged` (folded into another page; see `merged_into`). null while the page is live. `abuse` and `merged` are never restorable: POST /sites/{idOrSlug}/restore refuses them, so do not offer a restore control for either."
          },
          "restorable_until": {
            "type": "integer",
            "nullable": true,
            "description": "Unix epoch seconds. A real deadline, not a hint: the hourly cleanup destroys the content at that moment and restore returns 404 from then on."
          },
          "likely_revision_of": {
            "type": "object",
            "description": "On POST /sites only, and only when the page looks like a new version of one this account already has: the same title (changed in the last 24 hours, same client or none) or the same client and client_path (last 30 days). The page was still published, as asked, at a new link; this names the earlier page and the call that would have kept its link (`hint`). Next time, PUT /sites/{id} on it instead of publishing again. When `can_merge` is true, POST /sites/{newId}/merge folds the two together now. Ignore it if this is a separate document.",
            "properties": {
              "id": {
                "type": "string"
              },
              "url": {
                "type": "string",
                "format": "uri"
              },
              "title": {
                "type": "string",
                "nullable": true
              },
              "matched_on": {
                "type": "string",
                "enum": [
                  "title",
                  "client_path"
                ]
              },
              "last_changed_at": {
                "type": "integer",
                "description": "Unix epoch seconds the earlier page last changed."
              },
              "last_changed_at_iso": {
                "type": "string",
                "nullable": true
              },
              "minutes_ago": {
                "type": "integer"
              },
              "client": {
                "type": "object",
                "properties": {
                  "slug": {
                    "type": "string"
                  },
                  "name": {
                    "type": "string"
                  }
                }
              },
              "can_merge": {
                "type": "boolean",
                "description": "Whether POST /sites/{newId}/merge would fold the earlier page into this one now."
              },
              "hint": {
                "type": "string",
                "description": "The same facts in a sentence, safe to repeat."
              }
            }
          },
          "merged_into": {
            "type": "object",
            "nullable": true,
            "description": "On GET /sites/{idOrSlug} for a page with delete_reason `merged`: the page that holds its content now, and that its link opens.",
            "properties": {
              "id": {
                "type": "string"
              },
              "url": {
                "type": "string",
                "format": "uri"
              },
              "title": {
                "type": "string",
                "nullable": true
              }
            }
          }
        }
      },
      "PlanError": {
        "type": "object",
        "description": "A plan ceiling was hit. Render an upgrade prompt from `error` and `limit`, not the raw string.",
        "required": [
          "error",
          "plan"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Stable code for what ran out.",
            "enum": [
              "plan_lifetime_limit_exceeded",
              "plan_site_limit_exceeded",
              "plan_password_not_available",
              "plan_password_limit_exceeded",
              "plan_viewer_gate_not_available",
              "plan_viewer_gate_limit_exceeded",
              "plan_domain_not_available",
              "plan_domain_limit_exceeded"
            ]
          },
          "plan": {
            "type": "string",
            "description": "The caller's current plan."
          },
          "limit": {
            "type": "integer",
            "description": "The cap that was hit, when the code is a *_limit_exceeded."
          },
          "used": {
            "type": "integer",
            "description": "How much of the cap is spent (lifetime pages)."
          },
          "message": {
            "type": "string",
            "description": "Human-readable detail."
          },
          "likely_revision_of": {
            "type": "object",
            "description": "On `plan_lifetime_limit_exceeded` only, when the upload looks like a new version of a page this account already has: that page, same shape as on the Site schema. Updating it (PUT /sites/{id}) uses no page slot and keeps its link, so it is the one call that still works."
          }
        },
        "example": {
          "error": "plan_lifetime_limit_exceeded",
          "plan": "free",
          "limit": 3,
          "used": 3
        }
      },
      "SiteUpload": {
        "type": "object",
        "description": "Multipart form body for `POST /sites` and `PUT /sites/{idOrSlug}`. Send `file` as a single .html/.htm/.md document or a .zip with an index.html at the root. All other fields are optional string-encoded knobs.",
        "required": [
          "file"
        ],
        "properties": {
          "file": {
            "type": "string",
            "format": "binary",
            "description": "A single .html, .htm, or .md file, or a .zip containing index.html at the root."
          },
          "public_slug": {
            "type": "string",
            "description": "Requested public subdomain, e.g. \"my-deck\" for my-deck.stacktr.ee (makes the site public). Optional; otherwise the site is unlisted with an unguessable token."
          },
          "reactions": {
            "type": "string",
            "description": "\"true\" to enable on-page emoji reactions and notes."
          },
          "pii_check": {
            "type": "string",
            "description": "PII scan mode: \"warn\" (default) or \"off\"."
          },
          "password": {
            "type": "string",
            "description": "Optional passcode gate. Available on every plan: anonymous publishes, free accounts (each of the 3 free pages can carry one), and paid plans without limit."
          },
          "expires_in_hours": {
            "type": "string",
            "description": "TTL in hours, or \"never\". A NUMBER longer than the plan allows is clamped to the ceiling and the response says so (`expiry_clamped: true`): anonymous caps at 24h, Free at 7 days (168), paid plans have no ceiling. \"never\" on a capped plan is REFUSED instead (409 `expiry_clamped`, nothing published) so that a shortened page can never be reported as permanent; send `accept_clamp` to take the ceiling. Omitted, or sent empty, means the plan default. A value that is neither a positive number of hours nor \"never\" (\"7d\", \"-5\") is 400 `invalid_expiry` and nothing is published. Check `expires_at` / `expires_at_iso` in the response for what you actually got."
          },
          "accept_clamp": {
            "type": "string",
            "description": "\"true\" to accept the plan ceiling when you asked for a page that never expires. Without it that request is refused rather than silently shortened. Has no effect on any other value of expires_in_hours."
          },
          "burn_after_read": {
            "type": "string",
            "description": "\"true\" to delete the site after its first view."
          },
          "e2e": {
            "type": "string",
            "description": "\"true\" if the payload is already AES-GCM ciphertext (end-to-end encrypted; the server stores ciphertext only)."
          },
          "csp_strict": {
            "type": "string",
            "description": "\"true\" to apply a strict Content-Security-Policy."
          },
          "agentation": {
            "type": "string",
            "description": "\"true\" to enable the Agentation annotation toolbar on the published page."
          },
          "client": {
            "type": "string",
            "description": "Client space to file this page under, by name or slug (e.g. \"Acme Co\"). Auto-created if it does not exist. Omit for a floating page. Ignored on anonymous publishes — there is no account to attach the space to."
          },
          "client_path": {
            "type": "string",
            "description": "Optional stable path for this page within the client space (e.g. \"june-report\"). Defaults from the page title. Ignored on anonymous publishes."
          },
          "recipient": {
            "type": "string",
            "description": "Who the page is for, by name (e.g. \"Megan Hanning\"), up to 80 characters. The response then carries `recipient.link`, their own link: opens through it come back with their name, and if they have not opened it an hour after publishing the owner's dashboard says so. Ignored on anonymous and end-to-end encrypted publishes."
          }
        }
      },
      "ShareToken": {
        "type": "object",
        "required": [
          "id",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Pass to DELETE /share-tokens/{tokenId} to revoke."
          },
          "label": {
            "type": "string",
            "nullable": true,
            "description": "Who the link is for; opens through it are attributed to this name."
          },
          "created_at": {
            "type": "integer"
          },
          "expires_at": {
            "type": "integer",
            "nullable": true
          },
          "max_uses": {
            "type": "integer",
            "nullable": true
          },
          "use_count": {
            "type": "integer",
            "description": "Uses counted against max_uses. Not opens: a bot or link scanner fetch counts here too."
          },
          "revoked_at": {
            "type": "integer",
            "nullable": true
          },
          "opens": {
            "type": "integer",
            "nullable": true,
            "description": "Human opens through this link. null when the plan has no viewer numbers (metrics_locked)."
          },
          "last_opened_at": {
            "type": "integer",
            "nullable": true
          }
        }
      },
      "ShareTokenCreated": {
        "type": "object",
        "required": [
          "id",
          "token",
          "url"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "token": {
            "type": "string",
            "description": "Shown only in this response."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "URL with ?t=<token> embedded; bypasses any password gate."
          },
          "expires_at": {
            "type": "integer",
            "nullable": true
          },
          "max_uses": {
            "type": "integer",
            "nullable": true
          },
          "label": {
            "type": "string",
            "nullable": true
          },
          "next": {
            "type": "string",
            "description": "On a labelled link only: what to tell the user about following up on it."
          },
          "feedback": {
            "type": "object",
            "additionalProperties": true,
            "description": "Present when minting this named link also switched client comments on for the page."
          }
        }
      },
      "Feedback": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "client_id": {
            "type": "string",
            "nullable": true
          },
          "comment": {
            "type": "string"
          },
          "element": {
            "type": "string",
            "nullable": true
          },
          "element_path": {
            "type": "string",
            "nullable": true
          },
          "selected_text": {
            "type": "string",
            "nullable": true
          },
          "nearby_text": {
            "type": "string",
            "nullable": true
          },
          "page_url": {
            "type": "string",
            "nullable": true
          },
          "intent": {
            "type": "string",
            "nullable": true,
            "description": "fix | change | question | approve"
          },
          "severity": {
            "type": "string",
            "nullable": true,
            "description": "blocking | important | suggestion"
          },
          "target_kind": {
            "type": "string",
            "nullable": true,
            "description": "What an on-page comment is attached to: text | image | video | audio | embed | graphic | element | page. null on older toolbar notes."
          },
          "anchor": {
            "type": "object",
            "nullable": true,
            "additionalProperties": true,
            "description": "Where on the page, by target_kind: e.g. exact for text, src/alt and point for an image, time for media."
          },
          "target": {
            "type": "string",
            "nullable": true,
            "description": "The anchor in words, e.g. the text \"Q3 revenue\". Quote this rather than reading anchor."
          },
          "on_page": {
            "type": "boolean",
            "nullable": true,
            "description": "Open comments with a target only: still in the page as it is now (true), gone from it, most likely answered by an edit (false), or not judgeable from the source (null)."
          },
          "created_at": {
            "type": "integer"
          },
          "updated_at": {
            "type": "integer",
            "nullable": true
          },
          "resolved_at": {
            "type": "integer",
            "nullable": true
          },
          "resolved_note": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "ClientSpace": {
        "type": "object",
        "required": [
          "id",
          "name",
          "slug"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string",
            "description": "Display name, e.g. \"Acme Co\". Renameable."
          },
          "slug": {
            "type": "string",
            "description": "URL-safe identifier. Stable forever — renames never change it."
          },
          "hostname": {
            "type": "string",
            "nullable": true,
            "description": "Custom hostname bound to the space, if any."
          },
          "page_count": {
            "type": "integer",
            "description": "Pages filed under the space."
          },
          "last_activity_at": {
            "type": "integer",
            "description": "Unix epoch seconds of the newest page update."
          },
          "archived_at": {
            "type": "integer",
            "nullable": true
          },
          "created_at": {
            "type": "integer"
          }
        }
      },
      "CustomDomain": {
        "type": "object",
        "required": [
          "hostname",
          "site_id",
          "status"
        ],
        "properties": {
          "hostname": {
            "type": "string",
            "example": "docs.example.com"
          },
          "site_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "verified",
              "failed"
            ]
          },
          "cname_target": {
            "type": "string",
            "description": "CNAME value the user must add to their DNS."
          },
          "verified_at": {
            "type": "integer",
            "nullable": true
          }
        }
      },
      "ApiKey": {
        "type": "object",
        "required": [
          "id",
          "name",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "prefix": {
            "type": "string",
            "description": "First few chars of the key (for display)."
          },
          "created_at": {
            "type": "integer"
          },
          "last_used": {
            "type": "integer",
            "nullable": true
          },
          "key": {
            "type": "string",
            "description": "Full API key. Only present on creation responses."
          }
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing or invalid credentials.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Resource not found.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests, or the daily publish cap for the plan (plan_upload_limit_exceeded). Carries Retry-After plus the RateLimit-Limit/-Remaining/-Reset headers.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "PlanLimited": {
        "description": "A plan ceiling was hit. The feature or the quota is not on this plan; the caller needs a different plan or an x402 unlock.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/PlanError"
            }
          }
        }
      },
      "SiteConflict": {
        "description": "The page cannot take this write in its current state. Read `error`: `site_deleted` means it has stopped serving and is being kept for 30 days: restore it with POST /sites/{idOrSlug}/restore and retry, and do NOT publish it again, which would create a second page at a different URL. `managed_portal` means it is a generated client portal that rebuilds from its space. `slug already taken` means another page holds that public slug, including a retired page still reserving it for its restore window.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": [
                "error"
              ],
              "properties": {
                "error": {
                  "type": "string",
                  "description": "site_deleted | managed_portal | slug already taken"
                },
                "delete_reason": {
                  "type": "string",
                  "nullable": true,
                  "description": "On site_deleted: `expired`, `owner`, or `abuse`. A takedown cannot be restored."
                },
                "restorable_until": {
                  "type": "integer",
                  "nullable": true,
                  "description": "On site_deleted: unix epoch seconds after which the content is destroyed and restore returns 404."
                },
                "message": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    }
  },
  "security": [
    {
      "bearerApiKey": []
    },
    {
      "oauth2": [
        "sites:write",
        "sites:read"
      ]
    }
  ],
  "tags": [
    {
      "name": "Sites",
      "description": "Publish, update, and delete static HTML sites."
    },
    {
      "name": "Client spaces",
      "description": "Group published pages per client (\"file this under Acme\"). Publishing with a `client` field auto-creates the space; these routes exist for listing, rename, and archive."
    },
    {
      "name": "Share tokens",
      "description": "Mint signed URLs that bypass per-site password gates."
    },
    {
      "name": "Custom domains",
      "description": "Bind a user-owned hostname to a Stacktree site."
    },
    {
      "name": "API keys",
      "description": "Long-lived credential management."
    },
    {
      "name": "OAuth",
      "description": "OAuth 2.1 + Dynamic Client Registration (RFC 7591). See /.well-known/oauth-authorization-server."
    },
    {
      "name": "MCP",
      "description": "Model Context Protocol surface — streamable-HTTP transport at /mcp, 41 tools, taking an API key or an OAuth token."
    },
    {
      "name": "Feedback",
      "description": "Viewer annotations collected from the on-page Agentation toolbar; readable and resolvable by the owner or their agent."
    }
  ],
  "paths": {
    "/provision": {
      "post": {
        "tags": [
          "Provision"
        ],
        "summary": "Pay to provision a persistent API key (x402): get an API key after one payment, no human approval.",
        "description": "Agent-native, no human in the loop. POST with no payment to receive a 402 with x402 payment requirements (PAYMENT-REQUIRED header for v2; the accepts list in the body for v1). Pay $1.00 in USDC on Base (accepts[0], an EIP-3009 authorization) or on Solana (accepts[1], an `exact` transfer sponsored by the fee payer in extra.feePayer) and retry; on settlement you receive a one-time stk_live_ API key. The key is a free-tier identity: 3 pages in total, each expiring after 7 days, passcodes included. POST /unlock lifts those limits (higher_limits $25/30 days) or cancels the expiry on one page (make_permanent $5). Settled through the Coinbase CDP facilitator. Read the accepts array from the 402 rather than hardcoding a rail: the requirements differ per network, and the v1 rail (X-PAYMENT, accepts in the body) is Base only. Other agentic rails (MPP, ACP, AP2) are advertised in the rail menu when enabled.",
        "x-payment-info": {
          "price": {
            "mode": "fixed",
            "currency": "USD",
            "amount": "1.00"
          },
          "protocols": [
            {
              "x402": {}
            },
            {
              "mpp": {
                "method": "evm",
                "intent": "charge",
                "currency": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
              }
            }
          ],
          "intent": "charge",
          "method": "evm",
          "amount": "1000000",
          "currency": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
          "offers": [
            {
              "amount": "1000000",
              "currency": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
              "description": "Provision a persistent Stacktree API key (returned once in the body).",
              "intent": "charge",
              "method": "evm"
            }
          ],
          "extensions": {
            "bazaar": {
              "serviceName": "Stacktree — provision",
              "description": "Stacktree x402 API key provisioning: get an API key after one x402 payment ($1.00 in USDC on Base, shown once), so an agent sets up its own credentials without human approval. It is for hosting HTML pages as private, shareable web pages: send it as Authorization: Bearer to publish reports, dashboards, landing pages and static sites to permanent unguessable links, and to update them at the same URL. For a single page, POST /publish ($0.50) needs no key at all.",
              "serviceRecord": "https://api.stacktr.ee/.well-known/x402-service",
              "tags": [
                "html",
                "hosting",
                "host",
                "web-page",
                "publishing",
                "private",
                "static-site",
                "api-key",
                "share-link",
                "agents"
              ],
              "info": {
                "input": {
                  "type": "http",
                  "method": "POST",
                  "bodyType": "json",
                  "body": {}
                },
                "output": {
                  "type": "json",
                  "example": {
                    "ok": true,
                    "api_key": "stk_live_…",
                    "usage": "Authorization: Bearer stk_live_…"
                  }
                }
              },
              "schema": {
                "$schema": "https://json-schema.org/draft/2020-12/schema",
                "type": "object",
                "properties": {
                  "input": {
                    "type": "object",
                    "properties": {
                      "body": {
                        "type": "object",
                        "description": "Optional JSON body. Payment is carried in the PAYMENT-SIGNATURE header (x402 v2), so no body is required. Fields apply only to the token-carried rails (ACP / AP2).",
                        "properties": {
                          "acp_shared_payment_token": {
                            "type": "string",
                            "description": "ACP rail: a Stripe Shared Payment Token (spt_...)."
                          },
                          "ap2_mandate": {
                            "type": "object",
                            "description": "AP2 rail: an AP2 mandate object."
                          }
                        }
                      }
                    }
                  },
                  "output": {
                    "type": "object",
                    "properties": {
                      "example": {
                        "type": "object",
                        "description": "Returned once payment settles.",
                        "properties": {
                          "ok": {
                            "type": "boolean"
                          },
                          "protocol": {
                            "type": "string"
                          },
                          "api_key": {
                            "type": "string",
                            "description": "The provisioned stk_live_ API key, shown once."
                          },
                          "prefix": {
                            "type": "string"
                          },
                          "usage": {
                            "type": "string",
                            "description": "How to use the key, e.g. \"Authorization: Bearer stk_live_...\"."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "requestBody": {
          "required": false,
          "description": "No body is required for the x402 rail — payment is carried in the PAYMENT-SIGNATURE header (x402 v2) or X-PAYMENT (v1). Body fields apply only to the token-carried rails (ACP / AP2).",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "acp_shared_payment_token": {
                    "type": "string",
                    "description": "ACP rail: a Stripe Shared Payment Token (spt_...)."
                  },
                  "ap2_mandate": {
                    "type": "object",
                    "description": "AP2 rail: an AP2 mandate object (supports human-not-present)."
                  },
                  "agent_context": {
                    "type": "object",
                    "description": "Optional discovery telemetry, never required and never affecting the response: which client is paying (e.g. agentcash, awal, pay.sh, bazaar-mcp), the kind of agent, and the search query that led here. Recorded only to improve how Stacktree describes itself to agents.",
                    "properties": {
                      "client": {
                        "type": "string",
                        "maxLength": 64
                      },
                      "agentType": {
                        "type": "string",
                        "maxLength": 64,
                        "description": "Codex, Claude Code, Hermes, OpenClaw, or Others (Specify)."
                      },
                      "agentTypeOther": {
                        "type": "string",
                        "maxLength": 64
                      },
                      "discovery_query": {
                        "type": "string",
                        "maxLength": 300,
                        "description": "The search that led here, verbatim."
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Paid and provisioned. Returns the API key once.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "protocol": {
                      "type": "string"
                    },
                    "api_key": {
                      "type": "string"
                    },
                    "prefix": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "402": {
            "description": "Payment Required. x402 requirements are in the PAYMENT-REQUIRED header (v2) and the response body (v1).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "502": {
            "description": "Facilitator verify/settle error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "postProvision"
      }
    },
    "/publish": {
      "post": {
        "tags": [
          "Provision"
        ],
        "summary": "Host generated HTML as a shareable web page (turn any HTML into a webpage): POST HTML + payment, get back a permanent private link.",
        "description": "Agent-native, no human and no account. POST your HTML with no payment to receive a 402 carrying x402 (PAYMENT-REQUIRED header) and MPP-evm (WWW-Authenticate: Payment) requirements. Pay $0.50 in USDC on Base (accepts[0]) or on Solana (accepts[1]) and retry with the same body; on settlement the page publishes to a permanent, unguessable link and you get back the URL plus a one-time claim token. The entry rung of the agentic ladder — vs $1 for a reusable key via /provision. Settled through the Coinbase CDP facilitator. Read the accepts array from the 402 rather than hardcoding a rail: the requirements differ per network (an EIP-712 domain on Base, a sponsoring feePayer on Solana), and free keyless revisions afterwards are EVM-only.",
        "security": [],
        "x-payment-info": {
          "price": {
            "mode": "fixed",
            "currency": "USD",
            "amount": "0.50"
          },
          "protocols": [
            {
              "x402": {}
            },
            {
              "mpp": {
                "method": "evm",
                "intent": "charge",
                "currency": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
              }
            }
          ],
          "intent": "charge",
          "method": "evm",
          "amount": "500000",
          "currency": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
          "offers": [
            {
              "amount": "500000",
              "currency": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
              "description": "Publish an HTML page to a permanent private link.",
              "intent": "charge",
              "method": "evm"
            }
          ],
          "extensions": {
            "bazaar": {
              "serviceName": "Stacktree — publish",
              "description": "Host an HTML page and get back a permanent, private, shareable link. Turns generated HTML (a report, plan, dashboard, landing page, static site, or any web page an agent produced) into a live, shareable webpage: POST your HTML with payment; no account, no key, no human in the loop. Free updates by the paying wallet at the same URL; restorable for 30 days if deleted.",
              "tags": [
                "html",
                "hosting",
                "host",
                "web-page",
                "publishing",
                "private",
                "static-site",
                "landing-page",
                "report",
                "dashboard",
                "generated-html",
                "share-link",
                "shareable-link",
                "agents"
              ],
              "info": {
                "input": {
                  "type": "http",
                  "method": "POST",
                  "bodyType": "json",
                  "body": {
                    "html": "<!doctype html>..."
                  }
                },
                "output": {
                  "type": "json",
                  "example": {
                    "url": "https://stacktr.ee/p/<token>/",
                    "claim_token": "<one-time-secret>"
                  }
                }
              },
              "schema": {
                "$schema": "https://json-schema.org/draft/2020-12/schema",
                "type": "object",
                "properties": {
                  "input": {
                    "type": "object",
                    "properties": {
                      "body": {
                        "type": "object",
                        "description": "The page to publish. Send { \"html\": \"<!doctype html>...\" } (or a raw text/html body). Payment is carried in the PAYMENT-SIGNATURE header (x402 v2).",
                        "properties": {
                          "html": {
                            "type": "string",
                            "description": "The full HTML document to host."
                          },
                          "filename": {
                            "type": "string",
                            "description": "Optional logical filename; defaults to index.html."
                          }
                        },
                        "required": [
                          "html"
                        ]
                      }
                    }
                  },
                  "output": {
                    "type": "object",
                    "properties": {
                      "example": {
                        "type": "object",
                        "description": "Returned once payment settles and the page is live.",
                        "properties": {
                          "url": {
                            "type": "string",
                            "description": "The permanent private link to share."
                          },
                          "unlisted_token": {
                            "type": "string"
                          },
                          "claim_token": {
                            "type": "string",
                            "description": "One-time secret to adopt this page into an account later."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "requestBody": {
          "required": true,
          "description": "The page to publish. Payment is carried in the PAYMENT-SIGNATURE header (x402 v2) or Authorization: Payment (MPP-evm).",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "html"
                ],
                "properties": {
                  "html": {
                    "type": "string",
                    "description": "The full HTML document to host."
                  },
                  "filename": {
                    "type": "string",
                    "description": "Optional logical filename; defaults to index.html."
                  },
                  "agent_context": {
                    "type": "object",
                    "description": "Optional discovery telemetry, never required and never affecting the response: which client is paying (e.g. agentcash, awal, pay.sh, bazaar-mcp), the kind of agent, and the search query that led here. Recorded only to improve how Stacktree describes itself to agents.",
                    "properties": {
                      "client": {
                        "type": "string",
                        "maxLength": 64
                      },
                      "agentType": {
                        "type": "string",
                        "maxLength": 64,
                        "description": "Codex, Claude Code, Hermes, OpenClaw, or Others (Specify)."
                      },
                      "agentTypeOther": {
                        "type": "string",
                        "maxLength": 64
                      },
                      "discovery_query": {
                        "type": "string",
                        "maxLength": 300,
                        "description": "The search that led here, verbatim."
                      }
                    }
                  }
                }
              }
            },
            "text/html": {
              "schema": {
                "type": "string",
                "description": "The raw HTML document."
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Paid and published. Returns the permanent URL + the claim token. The claim token adopts the page into an account, and until it is used for that it is also the page's keyless update credential: PUT /sites/{id} with `Authorization: Claim <claim_token>` replaces the content at the same URL, on Base and Solana alike, with no account and no signature. The `next.update_in_place` hint in the response body carries the exact request.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "url": {
                      "type": "string"
                    },
                    "unlisted_token": {
                      "type": "string"
                    },
                    "claim_token": {
                      "type": "string"
                    },
                    "expires_at": {
                      "type": [
                        "integer",
                        "null"
                      ],
                      "description": "null: the payment bought permanence outright, outside the plan ladder."
                    },
                    "expires_at_iso": {
                      "type": [
                        "string",
                        "null"
                      ]
                    },
                    "ttl_seconds": {
                      "type": [
                        "integer",
                        "null"
                      ]
                    },
                    "expiry_clamped": {
                      "type": "boolean",
                      "description": "Always false on this rail: a paid one-off page has no plan ceiling to be clamped by."
                    },
                    "expiry_ceiling_hours": {
                      "type": [
                        "integer",
                        "null"
                      ]
                    },
                    "expiry_source": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "402": {
            "description": "Payment Required. x402 requirements in the PAYMENT-REQUIRED header; MPP-evm in WWW-Authenticate.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "502": {
            "description": "Facilitator verify/settle error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "postPublish"
      }
    },
    "/unlock": {
      "get": {
        "tags": [
          "Provision"
        ],
        "summary": "The à-la-carte unlock catalog (SKUs and prices).",
        "description": "Live prices, always authoritative over any copy: make_permanent $5 one-time per page, custom_domain $5 / 30 days, higher_limits $25 / 30 days.",
        "security": [],
        "responses": {
          "200": {
            "description": "Catalog of purchasable feature unlocks.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "unlocks": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "operationId": "getUnlock"
      },
      "post": {
        "tags": [
          "Provision"
        ],
        "summary": "Buy a feature unlock over x402 (make-permanent, custom domain, higher limits).",
        "description": "POST with the feature and no payment to receive a 402 carrying x402 requirements; sign the EIP-3009 USDC authorization and retry to activate the entitlement. `make_permanent` ($5) cancels the expiry on one page; `higher_limits` ($25 / 30 days) lifts the identity to fleet limits (1 GB per site, unlimited daily publishes, no page cap, pages that do not expire); `custom_domain` ($5 / 30 days) allows a custom hostname. Live on Base mainnet.",
        "security": [
          {
            "bearerApiKey": []
          },
          {
            "oauth2": [
              "sites:write"
            ]
          }
        ],
        "parameters": [
          {
            "name": "feature",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The feature SKU to unlock (see GET /unlock for the catalog)."
          }
        ],
        "responses": {
          "200": {
            "description": "Unlock activated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "402": {
            "description": "Payment Required (x402 requirements in the PAYMENT-REQUIRED header and body).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        },
        "operationId": "postUnlock"
      }
    },
    "/pay/sessions": {
      "post": {
        "tags": [
          "Provision"
        ],
        "summary": "Create a scan-to-pay session (a human pays by card via QR when there is no wallet).",
        "description": "No wallet, no account required. Returns a short pay URL, a terminal-printable QR, and a poll URL. A human scans and pays by card; the agent polls and continues mid-task. Paying above the price leaves a prepaid balance future paid actions draw from silently.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "feature": {
                    "type": "string",
                    "description": "provision | topup | make_permanent | custom_domain | higher_limits"
                  },
                  "amount_minor": {
                    "type": "integer",
                    "description": "Optional amount in minor units; surplus becomes a prepaid balance."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Session created.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "url": {
                      "type": "string"
                    },
                    "qr": {
                      "type": "string"
                    },
                    "amount": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "operationId": "postPaySessions"
      }
    },
    "/pay/sessions/{code}/poll": {
      "parameters": [
        {
          "name": "code",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Provision"
        ],
        "summary": "Poll a pay session; returns the minted key (provision) or confirms the unlock once paid.",
        "responses": {
          "200": {
            "description": "Session status. Includes the stk_live_ API key once, on a completed provision.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        },
        "operationId": "getPaySessionsByCodePoll"
      }
    },
    "/wallet-auth/challenge": {
      "post": {
        "tags": [
          "Provision"
        ],
        "security": [],
        "summary": "Get a single-use challenge so the wallet that paid for a page can update it — no API key.",
        "description": "Public. Send { \"wallet\": \"0x…\" } (EVM, lowercase or checksummed). Returns { challenge, message, expires_at }: personal_sign (EIP-191) the message with that wallet, then send `Authorization: Wallet challenge=…,sig=0x…` on PUT /sites/{id}. One challenge per request, 5-minute TTL. 404 for a wallet with no pages here (pages are keyed to the wallet that PAID at publish time).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "wallet"
                ],
                "properties": {
                  "wallet": {
                    "type": "string",
                    "description": "The EVM address that paid at POST /publish (or is linked to the owning account)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Challenge issued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "challenge": {
                      "type": "string",
                      "example": "WAUTH-3F9K2M7Q1XZC"
                    },
                    "message": {
                      "type": "string",
                      "description": "Sign exactly this with personal_sign."
                    },
                    "expires_at": {
                      "type": "integer",
                      "description": "Unix ms."
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "This wallet has no pages here.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "postWalletAuthChallenge"
      }
    },
    "/sites": {
      "get": {
        "tags": [
          "Sites"
        ],
        "summary": "List sites belonging to the authenticated principal.",
        "description": "Viewer numbers are plan-gated: on a plan without them each site comes back with `metrics_locked: true` and null `view_count`, `unique_viewers` and `last_viewed_at`, plus a boolean `opened`. The redaction is server-side, so an agent sees exactly what the dashboard does. Each site carries its `client` space (or null). Pages that expired or were deleted stay in this list rather than disappearing: check `deleted_at` before handing anyone a `url`, because a row with `deleted_at` set is a dead link that POST /sites/{idOrSlug}/restore can revive until `restorable_until`.",
        "parameters": [
          {
            "name": "client",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Only sites filed under this client space (slug or name, case-insensitive)."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 500
            },
            "description": "Sites per page. Defaults to 200."
          },
          {
            "name": "before",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer"
            },
            "description": "Keyset cursor: pass `next_before` from the previous response. Send together with `before_id`."
          },
          {
            "name": "before_id",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Keyset cursor: pass `next_before_id` from the previous response. Send together with `before`."
          }
        ],
        "responses": {
          "200": {
            "description": "OK. Newest-updated first. When `has_more` is true, call again with `before` = `next_before` and `before_id` = `next_before_id`, and repeat until it is false.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "sites": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Site"
                      }
                    },
                    "has_more": {
                      "type": "boolean",
                      "description": "More pages remain."
                    },
                    "next_before": {
                      "type": "integer",
                      "nullable": true,
                      "description": "Cursor for the next page; null on the last page."
                    },
                    "next_before_id": {
                      "type": "string",
                      "nullable": true,
                      "description": "Cursor for the next page; null on the last page."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "operationId": "getSites"
      },
      "post": {
        "tags": [
          "Sites"
        ],
        "summary": "Publish a new site.",
        "description": "Send a multipart/form-data body with a `file` field (a single .html/.htm/.md document, or a .zip containing index.html at the root). Anonymous publishing works with no Authorization header from a direct client connection: the site gets a 24h TTL and is rate-limited per IP. An authenticated publish counts against the plan: Free allows 3 pages in total (a monotonic counter, so deleting a page does not free the slot) and expires every page after 7 days. Example: `curl -X POST https://api.stacktr.ee/sites -F file=@page.html`. An anonymous publish returns a `claim_token`: it adopts the page into an account (POST /sites/{idOrSlug}/claim) AND, until it is used for that, it is the page's keyless update credential on PUT /sites/{idOrSlug} (`Authorization: Claim <claim_token>`), so a curl-only agent can keep one URL current without an account. Store it with the page id; it is as sensitive as the page.",
        "security": [
          {
            "bearerApiKey": []
          },
          {
            "oauth2": [
              "sites:write"
            ]
          },
          {}
        ],
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Makes a retry safe. Any unique string (a UUID is ideal), 1 to 255 visible ASCII characters. The same key with the same body inside 24 hours returns the ORIGINAL page (same id, same url, same tokens) with `Idempotent-Replay: true`, publishes nothing new, and spends no second page against the Free lifetime cap. Time-relative fields (`ttl_seconds`, and `expires_at` if it has since been changed) are recomputed on the replay rather than repeated from the original response. The same key with a DIFFERENT body is refused with 422 rather than being served the old page. Two requests carrying one key cannot both publish: the loser gets 409 `idempotency_key_in_progress` and should retry. Keys are scoped to the caller, so yours cannot collide with anyone else's — EXCEPT on an anonymous publish, where there is no account and the scope is the network address: the key must be unguessable there (send a UUID) or it is refused with 400 `idempotency_key_too_weak`. If the page a key produced has since been deleted or burned, the retry is 409 `idempotent_page_gone`, never a 201 for a dead link. Every response echoes `Idempotency-Key` and an `Idempotency-Status` of created, replayed, in_progress, mismatch, gone or unsupported."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/SiteUpload"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Published. Also returned verbatim on an idempotent replay, with `Idempotent-Replay: true`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Site"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request (e.g. not multipart/form-data, missing file, an anonymous upload without a direct client IP, `invalid_expiry` for a lifetime that is neither a positive number of hours nor \"never\", `invalid_idempotency_key`, or `idempotency_key_too_weak` for a guessable key on an anonymous publish).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PlanLimited"
          },
          "409": {
            "description": "Nothing was published. `expiry_clamped`: you asked for a page that never expires on a plan that caps page lifetime; the body carries `would_expire_at` / `would_expire_at_iso`, and `accept_clamp` publishes it with that deadline. `idempotency_key_in_progress`: a publish with this key is already running; retry after `retry_after_seconds` with the identical request. `idempotent_page_gone`: the page this key already published has been deleted or burned, so there is nothing to replay; restore it, or use a NEW key for new content. None of the three is a reason to publish again without the key, which would create a second page.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "File exceeds the per-site size cap for the plan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`idempotency_key_reused`: this Idempotency-Key already belongs to different content. Nothing was published, and the earlier page is deliberately NOT returned: serving it would report success for content that was never published. Retry with a new key, or resend the original body unchanged.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "operationId": "postSites"
      }
    },
    "/sites/{idOrSlug}": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Sites"
        ],
        "summary": "Get a site by id or slug.",
        "description": "Same viewer-number gating as the list endpoint: `metrics_locked`, plus `opened` on every plan. Also carries `badged`, the per-page answer to whether this page shows the \"Made with Stacktree\" footer, which the plan alone does not decide.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Site"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "operationId": "getSitesByIdOrSlug"
      },
      "put": {
        "tags": [
          "Sites"
        ],
        "security": [
          {
            "bearerApiKey": []
          },
          {
            "oauth2": [
              "sites:write"
            ]
          },
          {
            "walletSignature": []
          },
          {
            "claimToken": []
          }
        ],
        "summary": "Replace the site contents (full re-upload, same URL).",
        "description": "Two body shapes. `application/json` with an `html` string is the simplest and mirrors POST /publish, which is what the paid rails already speak: `curl -X PUT https://api.stacktr.ee/sites/<id> -H \"Content-Type: application/json\" -d '{\"html\":\"<!doctype html>...\"}'`. `multipart/form-data` with a `file` field takes everything JSON cannot carry (a zip, a PDF, e2e ciphertext): `curl -X PUT https://api.stacktr.ee/sites/<id> -F file=@new.html -H \"Authorization: Bearer <key>\"`. Either shape keeps the existing URL while swapping the content, and keeps the site's settings — including its client-space filing — untouched (`client`/`client_path` are not accepted here; re-file with PATCH). Replacing a page does not count as a new publish against the Free lifetime cap. Two keyless alternatives, neither of which needs an account: for a page that is still UNCLAIMED, its own `claim_token` from the publish response is the update credential (`Authorization: Claim <claim_token>`, described in the claimToken security scheme; works on every rail, and stops working at the claim and at expiry); for a wallet-paid page, the paying wallet is (see walletSignature: challenge from POST /wallet-auth/challenge, then `Authorization: Wallet challenge=…,sig=0x…`). Either way the URL is unchanged: replacing a page never mints a second one. Send exactly one Authorization scheme per request.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "html"
                ],
                "properties": {
                  "html": {
                    "type": "string",
                    "description": "The full replacement document, as a string. One text file; anything binary or multi-file goes over multipart/form-data."
                  },
                  "filename": {
                    "type": "string",
                    "description": "Optional logical filename, default index.html. Set it to replace a page published as a machine asset (data.json, feed.xml) at its own path."
                  },
                  "expected_updated_at": {
                    "type": "integer",
                    "description": "Optimistic-concurrency precondition: the updated_at this replacement was derived from. A mismatch returns 409 and changes nothing."
                  },
                  "pii_check": {
                    "type": "string",
                    "enum": [
                      "warn",
                      "block",
                      "off"
                    ],
                    "description": "Pre-flight scan mode, default warn."
                  }
                }
              }
            },
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "The replacement HTML/zip, same accepted types as POST /sites."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Replaced.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Site"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PlanLimited"
          },
          "403": {
            "description": "The credential presented does not authorise this page. `invalid_claim_token`: the claim token is wrong, or the page has been claimed and now needs the owning account's credential. `wallet_blocked` / `blocked`: the actor is on the abuse blocklist.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/SiteConflict"
          },
          "410": {
            "description": "The page stopped serving and cannot be updated. `expired`: an unclaimed page reached its deadline; it is not restorable (restoring is an account action and the page never had an owner), so publish a new one rather than retrying. `burned`: burn-after-read consumed it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "operationId": "putSitesByIdOrSlug"
      },
      "patch": {
        "tags": [
          "Sites"
        ],
        "summary": "Update site settings (visibility, slug, expiry, passcode, email gate, client space).",
        "description": "JSON body. Only the fields you send are changed. Passcodes work on every plan (free covers its 3 pages); email gates start on Solo, so setting one on a free plan returns 402. Clearing either always works.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string",
                    "nullable": true,
                    "description": "Display name; null/empty clears back to the extracted <title>."
                  },
                  "public_slug": {
                    "type": "string",
                    "nullable": true,
                    "description": "Publish at {slug}.stacktr.ee (public); null returns the page to unlisted."
                  },
                  "password": {
                    "type": "string",
                    "nullable": true,
                    "description": "Set a passcode (paid plans), or null to clear."
                  },
                  "expires_in_hours": {
                    "type": "integer",
                    "nullable": true,
                    "description": "Hours from now, or null for no expiry. A number over the plan ceiling is clamped to it and the response says so (`expiry_clamped: true`). null (no expiry) on a capped plan is REFUSED with 409 `expiry_clamped` and nothing in the PATCH is applied, so an agent cannot report a 7-day page as permanent; send `accept_clamp: true` alongside it to take the ceiling instead. The response carries the whole expiry block: `expires_at`, `expires_at_iso`, `ttl_seconds`, `expiry_clamped`, `expiry_ceiling_hours`, `expiry_source`."
                  },
                  "accept_clamp": {
                    "type": "boolean",
                    "description": "Accept the plan ceiling when asking for a page that never expires, instead of being refused. Ignored for every other value of expires_in_hours."
                  },
                  "allowed_email_domain": {
                    "type": "string",
                    "nullable": true,
                    "description": "Require visitors to verify an email: domains and/or single addresses separated by commas (\"acme.com, jane@globex.com\"), up to 20 (paid plans), or null to clear. Mutually exclusive with password."
                  },
                  "burn_after_read": {
                    "type": "boolean"
                  },
                  "csp_strict": {
                    "type": "boolean"
                  },
                  "disabled": {
                    "type": "boolean",
                    "description": "true takes the page offline (link serves the expired page); false brings it back. Reversible — content, view history and share links are kept."
                  },
                  "agentation": {
                    "type": "boolean"
                  },
                  "reactions": {
                    "type": "boolean"
                  },
                  "client": {
                    "type": "string",
                    "nullable": true,
                    "description": "File the site under a client space by name or slug (auto-created), or null to detach it to a floating page. Filing also derives a stable space_path for the page."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PlanLimited"
          },
          "409": {
            "$ref": "#/components/responses/SiteConflict"
          }
        },
        "operationId": "patchSitesByIdOrSlug"
      },
      "delete": {
        "tags": [
          "Sites"
        ],
        "summary": "Take a page down. Restorable for 30 days.",
        "description": "The page stops serving immediately: the link is dead for everyone holding it, and the plan slot is freed at once. The content is then KEPT for 30 days, during which POST /sites/{idOrSlug}/restore puts it back at the same URL with the same id, token, slug and read history. After that window the R2 objects and the row are destroyed and nothing can bring them back. Deleting a page that is already down is not an error: the response carries `already_deleted: true` and the same `restorable_until`. On Free this does not return a page slot: the lifetime cap counts publishes, not live pages, and a restore does not spend a second one either.",
        "responses": {
          "200": {
            "description": "Stopped serving. `restorable_until` is present only when the delete is genuinely undoable; a client that does not see it must not promise an undo.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "restorable_until": {
                      "type": "integer",
                      "description": "Unix epoch seconds until which POST /sites/{idOrSlug}/restore works."
                    },
                    "already_deleted": {
                      "type": "boolean",
                      "description": "True when the page was already down; this call changed nothing."
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "operationId": "deleteSitesByIdOrSlug"
      }
    },
    "/sites/{idOrSlug}/keep": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Sites"
        ],
        "summary": "Email the claim link for an unclaimed page to its human, plus one reminder as it runs out.",
        "description": "For a page published without an account. The `claim_token` from the publish response is the credential; no key, no OAuth, no wallet. The address receives the claim link and the deadline now, and one reminder inside four hours of expiry or after it. It is stored on the page, used for those two emails only, and cleared when the page is claimed. Use this when the person you publish for is not reading your output: the claim link in a JSON body reaches nobody. Claiming works for the 30 days an expired page is held and brings it back at the same address. Same `claim_token` sent again for the same address inside ten minutes answers `sent: false` without a second email.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "claim_token",
                  "email"
                ],
                "properties": {
                  "claim_token": {
                    "type": "string",
                    "description": "From the publish response of this page."
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "description": "Where the claim link goes."
                  },
                  "consent": {
                    "type": "boolean",
                    "description": "True when the person whose address this is said yes to the two emails. Ask them first; send true only when they did."
                  },
                  "via": {
                    "type": "string",
                    "description": "Optional: which surface asked (api, drop, drop_webmcp, widget, page). Analytics only; unknown values are recorded as api."
                  },
                  "source": {
                    "type": "string",
                    "description": "Optional label for what asked, a plain slug. Analytics only."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Address recorded.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "sent"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "sent": {
                      "type": "boolean",
                      "description": "false when the same address was emailed for this page inside the last ten minutes."
                    },
                    "email": {
                      "type": "string"
                    },
                    "expires_at": {
                      "type": "integer",
                      "nullable": true
                    },
                    "expires_at_iso": {
                      "type": "string",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Missing claim_token or an address that is not an email.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The claim_token does not match this page.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "The page already belongs to an account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "410": {
            "description": "Past the hold, burned, or taken down: nothing left to claim.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`keep_email_limit`: this page has already sent 3 keep emails, the most one page can. The claim link (or keep_url) still works.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "The email could not be sent. The claim link itself still works.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "postSitesByIdOrSlugKeep"
      }
    },
    "/sites/{idOrSlug}/restore": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Sites"
        ],
        "summary": "Put a page that was deleted or expired back at the same URL.",
        "description": "The undo half of DELETE. Same id, same unlisted token, same slug, same read history, so every link already sent starts working again. This, not a fresh publish, is the answer to a 409 site_deleted. Republishing would mint a different URL, strand everyone holding the old one, and spend another lifetime page; a restore spends none. It is a RESCUE, NOT A RENEWAL: a page that ran out of time on a plan with an expiry ceiling comes back for 48 hours rather than a fresh full lifetime, so read `expires_at_iso` and `restored_for` off the response and tell the user that date. Owner-authed, and only for a page this account owns. Every failure is the same 404 body on purpose (the endpoint accepts a token, so a distinguishable answer would be an oracle over other people's pages): past the 30 days, never retired, or taken down for abuse, which is never restorable.",
        "security": [
          {
            "bearerApiKey": []
          },
          {
            "oauth2": [
              "sites:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Serving again at the same URL.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "id": {
                      "type": "string"
                    },
                    "url": {
                      "type": "string",
                      "format": "uri",
                      "description": "The same link the page had before; nothing new needs sending."
                    },
                    "expires_at": {
                      "type": "integer",
                      "nullable": true,
                      "description": "Unix epoch seconds the restored page now dies at, or null for never. Authoritative: do not assume a fresh full window."
                    },
                    "restored_for": {
                      "type": "string",
                      "enum": [
                        "grace",
                        "kept",
                        "plan"
                      ],
                      "description": "`grace` = the 48-hour rescue window, because the page had run out of time on a plan with an expiry ceiling; `kept` = it came back on the deadline it already had, including no deadline at all, which is what a paid permanent page keeps; `plan` = a fresh normal window for this plan."
                    },
                    "restore_grace_hours": {
                      "type": "integer",
                      "description": "How long a rescued page comes back for when `restored_for` is `grace`. 48 hours. Present on every restore so a client can state the policy without hardcoding it."
                    },
                    "expires_at_iso": {
                      "type": "string",
                      "nullable": true,
                      "description": "The restored deadline as RFC 3339 UTC. On a 48-hour rescue this exact moment is the message."
                    },
                    "ttl_seconds": {
                      "type": "integer",
                      "nullable": true,
                      "description": "Seconds from now until it stops serving again. Null for never."
                    },
                    "message": {
                      "type": "string",
                      "description": "The same thing in words, safe to repeat to the user."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "$ref": "#/components/responses/PlanLimited"
          },
          "403": {
            "description": "Account suspended. A restore puts content back on the public web, so it meets the same blocklist a publish does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Nothing restorable here: past the 30-day window, not retired, not yours, or an abuse takedown. One body for all of them, deliberately.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "postSitesByIdOrSlugRestore"
      }
    },
    "/sites/{idOrSlug}/content": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Sites"
        ],
        "summary": "Read the exact stored HTML source of a site you own.",
        "description": "Returns index.html verbatim (not the rendered page or the text-stripped /raw view), so an agent can read a page, edit it, and PUT it back in place without losing CSS or inline charts. The read→edit→update loop behind \"personalise this template with your agent\". Owner-authed.",
        "responses": {
          "200": {
            "description": "The stored HTML, byte for byte, sent as text/plain so no proxy rewrites it.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "operationId": "getSitesByIdOrSlugContent"
      }
    },
    "/sites/{idOrSlug}/versions": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Sites"
        ],
        "summary": "List earlier versions of a page you own, newest first.",
        "description": "Every update replaces a page in place, and the content it replaced is kept here: one snapshot per update, taken the moment before the new bytes landed. Use this, then POST /sites/{idOrSlug}/versions/{versionId}/restore, to undo a bad update instead of republishing. Owner-authed. `source` says how the update that produced each snapshot arrived: dashboard, api, agent, wallet, claim, beautify, or restore. SINGLE-FILE ONLY: on a page with more than one file only index.html is snapshotted and `partial` is true, so restoring one puts index.html back and leaves the page's other files at their current state; end-to-end encrypted pages are not snapshotted at all, because their index.html is a static bootstrap. Retention is per plan: the last 5 versions on free, 50 on paid, oldest pruned after each update. An empty list is not an error, and means one of three things: the page has never been updated, every version has aged past the cap, or the deployment predates the feature.",
        "security": [
          {
            "bearerApiKey": []
          },
          {
            "oauth2": [
              "sites:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The page's versions, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "versions"
                  ],
                  "properties": {
                    "versions": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "id",
                          "created_at",
                          "source"
                        ],
                        "properties": {
                          "id": {
                            "type": "string",
                            "description": "Pass this as versionId to the restore route."
                          },
                          "created_at": {
                            "type": "integer",
                            "description": "Unix epoch seconds. When the update that replaced this content happened."
                          },
                          "size_bytes": {
                            "type": "integer",
                            "description": "Size of the snapshotted index.html, not of the whole page."
                          },
                          "source": {
                            "type": "string",
                            "enum": [
                              "dashboard",
                              "api",
                              "agent",
                              "wallet",
                              "claim",
                              "beautify",
                              "restore",
                              "merge"
                            ],
                            "description": "How the update that produced this snapshot arrived. `merge`: another copy of this page, folded in by POST /sites/{idOrSlug}/merge (its `note` names the link it came from)."
                          },
                          "partial": {
                            "type": "integer",
                            "enum": [
                              0,
                              1
                            ],
                            "description": "1 when the page had other files and only index.html was kept."
                          },
                          "note": {
                            "type": "string",
                            "nullable": true
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "operationId": "getSitesByIdOrSlugVersions"
      }
    },
    "/sites/{idOrSlug}/versions/{versionId}/restore": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "versionId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          },
          "description": "An `id` from GET /sites/{idOrSlug}/versions."
        }
      ],
      "post": {
        "tags": [
          "Sites"
        ],
        "summary": "Put an earlier version of a page back at the same URL.",
        "description": "Swaps the chosen snapshot back into the live page. Same id, same link, same read history, so nothing needs re-sending. The CURRENT content is snapshotted FIRST, as a new version with source `restore`, so this is itself undoable and the new version id comes back as `new_version`. Script hashes and the page title and description are recomputed from the restored HTML, exactly as a normal update does. SINGLE-FILE ONLY: on a multi-file page this puts index.html back and leaves the other files unchanged, and the response carries `partial: true` saying so. Owner-authed, and only for a page that is currently serving: a retired page answers 409 site_deleted and has to be restored with POST /sites/{idOrSlug}/restore first.",
        "security": [
          {
            "bearerApiKey": []
          },
          {
            "oauth2": [
              "sites:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Restored; the link now serves that version.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "ok",
                    "restored"
                  ],
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "restored": {
                      "type": "string",
                      "description": "The versionId that is now live."
                    },
                    "new_version": {
                      "type": "string",
                      "nullable": true,
                      "description": "The version id holding what was live a moment ago, so this restore can be undone. Null when the snapshot could not be written; the restore still happened."
                    },
                    "size_bytes": {
                      "type": "integer"
                    },
                    "partial": {
                      "type": "boolean",
                      "description": "Present and true only on a multi-file page: index.html was put back, the other files are unchanged."
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The page is end-to-end encrypted, so there is nothing readable to put back.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Account suspended. A restore puts content back on the public web, so it meets the same blocklist a publish does.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such page on this account (not_found), no such version on it (version_not_found), or the stored copy is gone (version_gone). Nothing was changed in any of them.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/SiteConflict"
          }
        },
        "operationId": "postSitesByIdOrSlugVersionsByVersionIdRestore"
      }
    },
    "/sites/revision-match": {
      "post": {
        "tags": [
          "Sites"
        ],
        "summary": "Ask, before publishing, whether a page would be a new version of one you have.",
        "description": "Body `{\"title\": \"...\", \"client\": \"...\", \"client_path\": \"...\"}` (only `title` is needed). Answers `{\"match\": null}` or `{\"match\": {...}}` in the `likely_revision_of` shape, plus `slots_left` (free pages left, null on a plan that does not count them). Creates nothing: a client named here that has no space yet stays without one. When there is a match, PUT /sites/{match.id} updates that page in place (same link, no page slot) instead of publishing a second one. Owner-authed.",
        "security": [
          {
            "bearerApiKey": []
          },
          {
            "oauth2": [
              "sites:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The match, or null.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "match": {
                      "type": "object",
                      "nullable": true
                    },
                    "slots_left": {
                      "type": "integer",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "operationId": "postSitesRevisionMatch"
      }
    },
    "/sites/{idOrSlug}/passcode": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Sites"
        ],
        "summary": "Show the passcode set on a site you own.",
        "description": "Returns the passcode the owner set, so it can be sent to a client again. Passcodes set since 13 September 2026 are kept as an encrypted copy alongside the hash the viewer gate checks; one set before that cannot be shown (404 not_retrievable) and the fix is to set a new one with PATCH /sites/{idOrSlug}. Owner-authed; never included in list or detail payloads.",
        "security": [
          {
            "bearerApiKey": []
          },
          {
            "oauth2": [
              "sites:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The passcode.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "passcode"
                  ],
                  "properties": {
                    "passcode": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No such site (not_found), no passcode on it (no_passcode), or no readable copy (not_retrievable).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "getSitesByIdOrSlugPasscode"
      }
    },
    "/sites/{idOrSlug}/claim": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Sites"
        ],
        "summary": "Claim an anonymous site into your account.",
        "description": "Adopt an anonymously-published site (no account, 24h TTL) into the authenticated account using the single-use claim_token returned at publish time. Single-use, and only while the site is still unowned and unexpired. A claim is a publish: it counts against the plan's page cap, and the page is given the longest life that plan allows: permanent on a paid plan, 7 days from now on Free. A claimed page keeps its passcode on every plan; the response `expires_at` is authoritative for what the page actually got.",
        "security": [
          {
            "bearerApiKey": []
          },
          {
            "oauth2": [
              "sites:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "claim_token"
                ],
                "properties": {
                  "claim_token": {
                    "type": "string",
                    "description": "The secret claim token from the publish response."
                  },
                  "via": {
                    "type": "string",
                    "description": "Optional: the surface that sent the claimer here (the `via` on the claim link). Analytics only."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Claimed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "id": {
                      "type": "string"
                    },
                    "expires_at": {
                      "type": "integer",
                      "nullable": true,
                      "description": "Unix epoch seconds after the plan ceiling was applied; null = permanent."
                    }
                  }
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PlanLimited"
          },
          "403": {
            "description": "Invalid claim token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Already claimed by another account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "410": {
            "description": "Expired; can no longer be claimed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Active-page cap reached (plan_site_limit_exceeded). The claim link stays valid until the page expires.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlanError"
                }
              }
            }
          }
        },
        "operationId": "postSitesByIdOrSlugClaim"
      }
    },
    "/sites/{idOrSlug}/beautify": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Sites"
        ],
        "summary": "Generate a beautified rebuild of a single-file HTML page (preview only — nothing is written).",
        "description": "Runs the design guide (GET /design-guide) server-side and returns either an honest abstention (the page already carries design judgement) or {direction, html} to review. Applying is a separate, explicit `PUT /sites/{idOrSlug}` with the returned HTML. Owner-authed; single-file, non-E2E HTML pages up to 100 KB; rate-limited per user per day. Generation can take minutes, so the 200 response streams NDJSON: `{\"ping\":true}` keepalive lines, then one terminal line — the result object, or `{error, message, status}` on failure. Guard errors (the non-200 codes below) are plain JSON. Agents with their own inference should prefer running the design guide themselves — same pipeline, no cap.",
        "security": [
          {
            "bearerApiKey": []
          },
          {
            "oauth2": [
              "sites:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "A verdict: abstained with a reason, or a rebuilt document to review.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "abstained"
                  ],
                  "properties": {
                    "abstained": {
                      "type": "boolean"
                    },
                    "reason": {
                      "type": "string",
                      "description": "Present when abstained: why a rebuild would not improve the page."
                    },
                    "direction": {
                      "type": "string",
                      "nullable": true,
                      "description": "One-sentence design direction, written for the page owner."
                    },
                    "html": {
                      "type": "string",
                      "description": "The complete rebuilt document. Not yet applied."
                    },
                    "model": {
                      "type": "string"
                    },
                    "version": {
                      "type": "string",
                      "description": "Design-guide version used."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Not eligible: multi-file site (a rebuild would lose the other files) or E2E-encrypted page.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "413": {
            "description": "Page over the 100 KB one-click ceiling.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Daily beautify limit reached (rolling 24h).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Generation failed; nothing was changed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Beautify not enabled on this deployment, or temporarily busy.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "postSitesByIdOrSlugBeautify"
      }
    },
    "/design-guide": {
      "get": {
        "tags": [
          "Sites"
        ],
        "summary": "The house design guide for improving a published page.",
        "description": "Public, unauthenticated. The assess-first beautify workflow for agents: when to restyle vs elevate-in-voice, the quality floor, and the CSP constraints published pages run under. Also exposed as the get_design_guide MCP tool.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "guide"
                  ],
                  "properties": {
                    "guide": {
                      "type": "string"
                    },
                    "version": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        },
        "operationId": "getDesignGuide"
      }
    },
    "/spaces": {
      "get": {
        "tags": [
          "Client spaces"
        ],
        "summary": "List client spaces with page counts, most recently touched first.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "spaces": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ClientSpace"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "operationId": "getSpaces"
      },
      "post": {
        "tags": [
          "Client spaces"
        ],
        "summary": "Create a client space explicitly.",
        "description": "Rarely needed: publishing with a `client` field auto-creates the space with the same casing and slug rules, so this exists for setting a client up before anything ships.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "Display name, e.g. \"Acme Co\"."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created (or already existed — idempotent with the publish-path auto-create).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "space": {
                      "type": "object",
                      "required": [
                        "id",
                        "name",
                        "slug"
                      ],
                      "properties": {
                        "id": {
                          "type": "string"
                        },
                        "name": {
                          "type": "string"
                        },
                        "slug": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Missing or invalid name.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "postSpaces"
      }
    },
    "/spaces/{idOrSlug}": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Client spaces"
        ],
        "summary": "Get one space plus a light list of its pages (id, title, path).",
        "description": "Full site objects live on `GET /sites?client=`.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "id",
                    "name",
                    "slug",
                    "pages"
                  ],
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    },
                    "slug": {
                      "type": "string"
                    },
                    "hostname": {
                      "type": "string",
                      "nullable": true
                    },
                    "archived_at": {
                      "type": "integer",
                      "nullable": true
                    },
                    "created_at": {
                      "type": "integer"
                    },
                    "updated_at": {
                      "type": "integer"
                    },
                    "pages": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "title": {
                            "type": "string",
                            "nullable": true
                          },
                          "space_path": {
                            "type": "string",
                            "nullable": true
                          },
                          "unlisted_token": {
                            "type": "string"
                          },
                          "updated_at": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "portal": {
                      "type": "object",
                      "nullable": true,
                      "description": "The generated client portal, when enabled: a system-managed index page listing the space’s work, newest first.",
                      "properties": {
                        "enabled": {
                          "type": "boolean"
                        },
                        "managed": {
                          "type": "boolean",
                          "description": "true = regenerates automatically; false = customized, the owner edits it like any page."
                        },
                        "site_id": {
                          "type": "string"
                        },
                        "url": {
                          "type": "string"
                        }
                      }
                    },
                    "domain": {
                      "type": "object",
                      "nullable": true,
                      "description": "The hostname bound to this space, when one exists.",
                      "properties": {
                        "hostname": {
                          "type": "string"
                        },
                        "verified": {
                          "type": "boolean"
                        },
                        "ssl_status": {
                          "type": "string",
                          "nullable": true
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "operationId": "getSpacesByIdOrSlug"
      },
      "patch": {
        "tags": [
          "Client spaces"
        ],
        "summary": "Rename a space, archive/unarchive it, or gate the whole space.",
        "description": "Renames change the display name only; the slug is a forever addressing contract and never changes. Only the fields present in the body are touched; on the two gate fields `null` means remove the gate.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "description": "New display name."
                  },
                  "archived": {
                    "type": "boolean",
                    "description": "true to archive (the plan slot is freed; the portal and the address close to readers, each page keeps its own link), false to unarchive."
                  },
                  "password": {
                    "type": "string",
                    "nullable": true,
                    "description": "Passcode covering every page in the space, inherited by pages already filed under it and by ones published later. null clears it."
                  },
                  "allowed_email_domain": {
                    "type": "string",
                    "nullable": true,
                    "description": "Email gate covering every page in the space: domains and/or single addresses separated by commas (\"acme.com, jane@globex.com\"; strict-equal, no subdomains), up to 20. null clears it."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PlanLimited"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Another active space already answers to that name.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "patchSpacesByIdOrSlug"
      },
      "delete": {
        "tags": [
          "Client spaces"
        ],
        "summary": "Delete a space. Its pages detach to floating; they are never deleted.",
        "description": "The generated portal page and any bound hostname ARE removed — the space is the address, and a deleted space stops resolving. Archive instead to free the plan slot and close the portal while keeping the space and every page.",
        "responses": {
          "200": {
            "description": "Deleted; pages detached.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "operationId": "deleteSpacesByIdOrSlug"
      }
    },
    "/spaces/{idOrSlug}/portal": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Client spaces"
        ],
        "summary": "Enable the client portal: a generated index page of the space’s work.",
        "description": "Serves at the root of the space’s hostname once one is connected, and always at its own private stacktr.ee URL. Regenerates automatically on every publish into, removal from, or rename of the space. Enabling counts as ACTIVATING the space — the client-spaces plan unit (Solo 1, Studio 10, Firm unlimited); filing pages stays free on every plan. Idempotent.",
        "responses": {
          "201": {
            "description": "Enabled (or already on).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    },
                    "site_id": {
                      "type": "string"
                    },
                    "url": {
                      "type": "string"
                    },
                    "managed": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PlanLimited"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "operationId": "postSpacesByIdOrSlugPortal"
      },
      "delete": {
        "tags": [
          "Client spaces"
        ],
        "summary": "Disable the portal and remove the generated page.",
        "responses": {
          "200": {
            "description": "Disabled.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "operationId": "deleteSpacesByIdOrSlugPortal"
      }
    },
    "/spaces/{idOrSlug}/portal/customize": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Client spaces"
        ],
        "summary": "Take over the portal HTML. Regeneration stops until it is handed back.",
        "description": "After this, the portal is an ordinary page the owner (or an agent) edits with update_site — and membership changes no longer touch it. POST /spaces/{idOrSlug}/portal/reset undoes it.",
        "responses": {
          "200": {
            "description": "Customized; managed=false from now on.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Portal not enabled."
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "operationId": "postSpacesByIdOrSlugPortalCustomize"
      }
    },
    "/spaces/{idOrSlug}/portal/reset": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Client spaces"
        ],
        "summary": "Hand a taken-over portal back, so it rebuilds itself again.",
        "description": "Undoes portal/customize. The portal keeps its link and address, is rebuilt from the space straight away (replacing any hand edits), and from then on updates whenever a page is published into the space.",
        "responses": {
          "200": {
            "description": "Managed again; managed=true.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Portal not enabled."
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        },
        "operationId": "postSpacesByIdOrSlugPortalReset"
      }
    },
    "/sites/{idOrSlug}/share-tokens": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Share tokens"
        ],
        "summary": "List share tokens for a site.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "tokens": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ShareToken"
                      }
                    },
                    "metrics_locked": {
                      "type": "boolean",
                      "description": "No viewer numbers on this plan: opens and last_opened_at are null."
                    }
                  }
                }
              }
            }
          }
        },
        "operationId": "getSitesByIdOrSlugShareTokens"
      },
      "post": {
        "tags": [
          "Share tokens"
        ],
        "summary": "Mint a share link addressed to one recipient.",
        "description": "Set `label` to the person you are sending to: every open through this link is attributed back to that name and shows up in GET /sites/{idOrSlug}/recipients. To personalise ONE link across a whole mail-merge instead, skip this endpoint and append `?to={{contact.name}}` to the page URL.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "label": {
                    "type": "string",
                    "description": "Who this link is for, e.g. \"Megan Hanning\". Becomes the attributed name on every open."
                  },
                  "expires_in_hours": {
                    "type": "integer",
                    "description": "Token lifetime in hours. Omit for no token-level expiry."
                  },
                  "max_uses": {
                    "type": "integer",
                    "description": "Cap on uses. Omit for unlimited."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ShareTokenCreated"
                }
              }
            }
          }
        },
        "operationId": "postSitesByIdOrSlugShareTokens"
      }
    },
    "/share-tokens/{tokenId}": {
      "parameters": [
        {
          "name": "tokenId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "delete": {
        "tags": [
          "Share tokens"
        ],
        "summary": "Revoke a share token.",
        "responses": {
          "200": {
            "description": "Revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          }
        },
        "operationId": "deleteShareTokensByTokenId"
      }
    },
    "/sites/{idOrSlug}/recipients": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Share tokens"
        ],
        "summary": "Who opened the page, by the name it was addressed to.",
        "description": "Rolls up opens per recipient — from a labelled share link or from `?to=` / `?name=` on the URL. `viewers` is distinct devices behind one name, so a value above 1 means the link was forwarded. Depth fields (`sessions`, `active_seconds`, `max_scroll`) are null unless the plan includes engagement detail; the whole list is empty when the plan has no viewer numbers.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "metrics_locked": {
                      "type": "boolean"
                    },
                    "detail_locked": {
                      "type": "boolean"
                    },
                    "recipients": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "name": {
                            "type": "string"
                          },
                          "opens": {
                            "type": "integer"
                          },
                          "viewers": {
                            "type": "integer"
                          },
                          "first_seen": {
                            "type": "integer"
                          },
                          "last_seen": {
                            "type": "integer"
                          },
                          "via_link": {
                            "type": "boolean"
                          },
                          "sessions": {
                            "type": "integer",
                            "nullable": true
                          },
                          "active_seconds": {
                            "type": "integer",
                            "nullable": true
                          },
                          "max_scroll": {
                            "type": "integer",
                            "nullable": true
                          },
                          "video_percent": {
                            "type": "integer",
                            "nullable": true,
                            "description": "Furthest this person got through video on the page. null with no video, no play, or depth locked."
                          }
                        }
                      }
                    },
                    "sent_to": {
                      "type": "array",
                      "description": "Who the owner said the page is for, opened or not. Never locked: compare with recipients to see who has not opened it yet.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "name": {
                            "type": "string"
                          },
                          "key": {
                            "type": "string"
                          },
                          "source": {
                            "type": "string",
                            "enum": [
                              "publish",
                              "page",
                              "share_link",
                              "agent"
                            ]
                          },
                          "share_token_id": {
                            "type": "string",
                            "nullable": true,
                            "description": "The signed link this person was sent, when it was one."
                          },
                          "added_at": {
                            "type": "integer"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "operationId": "getSitesByIdOrSlugRecipients"
      }
    },
    "/sites/{idOrSlug}/feedback": {
      "parameters": [
        {
          "name": "idOrSlug",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Feedback"
        ],
        "summary": "List viewer feedback on a site (unresolved first).",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "feedback": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Feedback"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "operationId": "getSitesByIdOrSlugFeedback"
      }
    },
    "/feedback/{id}/resolve": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Feedback"
        ],
        "summary": "Mark a feedback item addressed (optional note).",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "note": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resolved.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          }
        },
        "operationId": "postFeedbackByIdResolve"
      }
    },
    "/feedback/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "delete": {
        "tags": [
          "Feedback"
        ],
        "summary": "Delete a feedback item you own.",
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          }
        },
        "operationId": "deleteFeedbackById"
      }
    },
    "/custom-domains": {
      "get": {
        "tags": [
          "Custom domains"
        ],
        "summary": "List custom domains.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "domains": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/CustomDomain"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "operationId": "getCustomDomains"
      },
      "post": {
        "tags": [
          "Custom domains"
        ],
        "summary": "Add a custom domain, bound to a site XOR a client space.",
        "description": "site_id keeps today’s behaviour (the hostname serves one page). space_id binds the hostname to a client space instead: the portal at /, member pages at /{space_path} — the client-spaces plan unit (Solo 1, Studio 10, Firm unlimited). Under a verified parent-domain claim the per-hostname TXT step is skipped and the response carries covered_by_claim.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "hostname"
                ],
                "properties": {
                  "hostname": {
                    "type": "string"
                  },
                  "site_id": {
                    "type": "string",
                    "nullable": true
                  },
                  "space_id": {
                    "type": "string",
                    "nullable": true,
                    "description": "Mutually exclusive with site_id."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomDomain"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/PlanLimited"
          },
          "403": {
            "description": "Hostname sits under a domain another account has verified.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "postCustomDomains"
      }
    },
    "/parent-domains": {
      "get": {
        "tags": [
          "Custom domains"
        ],
        "summary": "List your verified parent-domain claims.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "description": "JSON object; see the endpoint description for fields."
                }
              }
            }
          }
        },
        "operationId": "getParentDomains"
      },
      "post": {
        "tags": [
          "Custom domains"
        ],
        "summary": "Claim a parent domain (verify once, wildcard CNAME once).",
        "description": "Prove control of e.g. theiragency.com with one TXT record and point *.theiragency.com at the fallback origin with one wildcard CNAME. Every client-space hostname under it afterwards registers with zero DNS work. The claim also RESERVES the domain: other accounts cannot register hostnames beneath it.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Claim issued; add the TXT + wildcard CNAME records returned in `instructions`, then verify.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          },
          "409": {
            "description": "Claimed by another account.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "operationId": "postParentDomains"
      }
    },
    "/parent-domains/{domain}/verify": {
      "parameters": [
        {
          "name": "domain",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Custom domains"
        ],
        "summary": "Check the claim TXT record and mark the domain verified.",
        "responses": {
          "200": {
            "description": "Verified.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "TXT not found yet."
          }
        },
        "operationId": "postParentDomainsByDomainVerify"
      }
    },
    "/parent-domains/{domain}": {
      "parameters": [
        {
          "name": "domain",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "delete": {
        "tags": [
          "Custom domains"
        ],
        "summary": "Drop a claim. Hostnames already registered keep serving.",
        "responses": {
          "200": {
            "description": "Removed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          }
        },
        "operationId": "deleteParentDomainsByDomain"
      }
    },
    "/custom-domains/{hostname}": {
      "parameters": [
        {
          "name": "hostname",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "patch": {
        "tags": [
          "Custom domains"
        ],
        "summary": "Update settings on a custom domain.",
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          }
        },
        "operationId": "patchCustomDomainsByHostname"
      },
      "delete": {
        "tags": [
          "Custom domains"
        ],
        "summary": "Remove a custom domain.",
        "responses": {
          "200": {
            "description": "Removed.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          }
        },
        "operationId": "deleteCustomDomainsByHostname"
      }
    },
    "/custom-domains/{hostname}/verify": {
      "parameters": [
        {
          "name": "hostname",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Custom domains"
        ],
        "summary": "Verify domain ownership (checks CNAME).",
        "responses": {
          "200": {
            "description": "Verification result.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CustomDomain"
                }
              }
            }
          }
        },
        "operationId": "postCustomDomainsByHostnameVerify"
      }
    },
    "/api-keys": {
      "get": {
        "tags": [
          "API keys"
        ],
        "summary": "List API keys for the authenticated user.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "description": "JSON object; see the endpoint description for fields."
                }
              }
            }
          }
        },
        "operationId": "getApiKeys"
      },
      "post": {
        "tags": [
          "API keys"
        ],
        "summary": "Create a new API key. The full key value is only returned in this response.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiKey"
                }
              }
            }
          }
        },
        "operationId": "postApiKeys"
      }
    },
    "/api-keys/{id}": {
      "parameters": [
        {
          "name": "id",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "delete": {
        "tags": [
          "API keys"
        ],
        "summary": "Revoke an API key.",
        "responses": {
          "200": {
            "description": "Revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          }
        },
        "operationId": "deleteApiKeysById"
      }
    },
    "/.well-known/oauth-authorization-server": {
      "get": {
        "tags": [
          "OAuth"
        ],
        "summary": "RFC 8414 discovery metadata.",
        "security": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "description": "JSON object; see the endpoint description for fields."
                }
              }
            }
          }
        },
        "operationId": "getWellKnownOauthAuthorizationServer"
      }
    },
    "/.well-known/oauth-protected-resource": {
      "get": {
        "tags": [
          "OAuth"
        ],
        "summary": "RFC 9728 protected-resource metadata.",
        "security": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "description": "JSON object; see the endpoint description for fields."
                }
              }
            }
          }
        },
        "operationId": "getWellKnownOauthProtectedResource"
      }
    },
    "/oauth/register": {
      "post": {
        "tags": [
          "OAuth"
        ],
        "summary": "RFC 7591 Dynamic Client Registration. Used by claude.ai connectors and other agent runtimes that discover Stacktree via /.well-known/.",
        "security": [],
        "responses": {
          "201": {
            "description": "Client registered.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          }
        },
        "operationId": "postOauthRegister"
      }
    },
    "/mcp": {
      "post": {
        "tags": [
          "MCP"
        ],
        "summary": "Model Context Protocol over streamable HTTP. Exposes 41 site-publishing tools to MCP-aware agents.",
        "description": "Send EITHER credential, never both: `Authorization: Bearer stk_live_…` (an API key — no browser, no registration, the same key that drives every REST route), or `Authorization: Bearer <oauth-jwt>` (OAuth 2.1 + Dynamic Client Registration, for connectors acting for a signed-in human). Session cookies are refused so a browser cannot drive MCP cross-site. An agent with no key can mint one without a human at POST /provision (x402), or with a human but no browser via the RFC 8628 device-code flow at POST /api-keys/device-code. The stdio package `stacktree-mcp` bridges an API key to these same tools for clients that cannot speak streamable HTTP. The /.well-known/mcp/server-card.json card describes the transport + capabilities. Tools (41): publish_html, update_site, delete_site, restore_site, claim_site, set_password, set_expiry, create_share_link, list_share_links, revoke_share_link, set_client_feedback, set_email_gate, list_sites, get_me, list_client_spaces, set_client, create_client_space, update_client_space, delete_client_space, get_design_guide, get_site, get_content, get_passcode, list_versions, restore_version, list_feedback, resolve_feedback, link_wallet, whos_it_for, unopened_pages, get_revision_brief, publish_revision, resolve_comments, request_approval, get_approval_status, list_responses, resolve_addressed_notes, open_html_file, stacktree_pages, publish_card_status, publish_card_open.",
        "security": [
          {
            "bearerApiKey": []
          },
          {
            "oauth2": [
              "sites:write",
              "sites:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "MCP message stream.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "operationId": "postMcp"
      },
      "get": {
        "tags": [
          "MCP"
        ],
        "summary": "Open an SSE channel for server-initiated messages.",
        "security": [
          {
            "bearerApiKey": []
          },
          {
            "oauth2": [
              "sites:write",
              "sites:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "SSE stream.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "description": "JSON object; see the endpoint description for fields."
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        },
        "operationId": "getMcp"
      }
    },
    "/api-keys/device-code": {
      "post": {
        "tags": [
          "API keys"
        ],
        "summary": "Start the RFC 8628 device-code flow. No auth.",
        "description": "Returns { device_code, user_code, verification_url, verification_url_complete, interval, expires_in }. Print verification_url_complete for the human, then poll. Codes live 10 minutes. Rate limited per IP.",
        "security": [],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "client_hint": {
                    "type": "string",
                    "description": "Names the agent in the human’s approval screen and in the key label."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Codes issued.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "device_code",
                    "user_code",
                    "verification_url",
                    "interval",
                    "expires_in"
                  ],
                  "properties": {
                    "device_code": {
                      "type": "string",
                      "description": "Secret. Send this on every poll; never show it to the human."
                    },
                    "user_code": {
                      "type": "string",
                      "description": "Short code the human confirms on screen, e.g. \"K3PQ-7RTM\"."
                    },
                    "verification_url": {
                      "type": "string",
                      "format": "uri"
                    },
                    "verification_url_complete": {
                      "type": "string",
                      "format": "uri",
                      "description": "The URL to print: it carries the user_code already."
                    },
                    "interval": {
                      "type": "integer",
                      "description": "Seconds to wait between polls."
                    },
                    "expires_in": {
                      "type": "integer",
                      "description": "Seconds until the code dies."
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Per-IP rate limit."
          }
        },
        "operationId": "postApiKeysDeviceCode"
      }
    },
    "/api-keys/device-code/poll": {
      "post": {
        "tags": [
          "API keys"
        ],
        "summary": "Poll a device code for the key. No auth.",
        "description": "Returns { status } — \"pending\" (keep polling at the advertised interval), \"authorized\" with a one-time `api_key`, or \"denied\" / \"expired\" / \"consumed\" (stop). The key is handed over exactly once: store it on the first authorized poll.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "device_code"
                ],
                "properties": {
                  "device_code": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Current status, with the key on the first authorized poll.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "status"
                  ],
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "pending",
                        "authorized",
                        "denied",
                        "expired",
                        "consumed"
                      ]
                    },
                    "api_key": {
                      "type": "string",
                      "description": "Present only on the first poll after the human authorizes. Shown once; store it immediately."
                    }
                  }
                }
              }
            }
          }
        },
        "operationId": "postApiKeysDeviceCodePoll"
      }
    }
  }
}