# Host An App — instructions for AI agents

> Host An App is zero-config ephemeral hosting for things AI assistants make:
> send one HTTP request with a file's content and get back a live public URL.
> No account, no API key, no setup for quick shares.
> This file has everything you need to publish a website via the REST API.

**What this file covers, and what the tool schemas cover.** The `manage_*`
MCP tool schemas (and the REST endpoints they mirror) describe the
**management** surface: how to declare a project, a frontend, a migration,
an action, a fetch. This file describes the **runtime** surface: what a
published page can actually do — the injected `hostan.db`/`fetch`/`auth`/
`payments`/`ws` runtimes, egress caps and failures, visitor sign-in, CSP and
browser rules, limits, and error codes. The schemas do not warn you that
they stop at the boundary, so read the relevant section here before your
first runtime call (`hostan.fetch`/`hostan.db`/`hostan.auth`) or your first
publish — not just before your first deploy.

- Machine-readable spec: `https://api.hostan.app/api/v1/openapi.json`
  (alias: `https://api.hostan.app/openapi.json`)
- Interactive API explorer: `https://api.hostan.app/api/docs`
- Human documentation: `https://hostan.app/docs`
- This file: `https://hostan.app/llms.txt`
  (alias: `https://hostan.app/agents.md`, same content)
- Installable skill: `https://hostan.app/SKILL.md` — a compressed Agent
  Skills file covering the same surface. `mkdir -p ~/.agents/skills/hostan
  && curl -fsSL https://hostan.app/SKILL.md -o
  ~/.agents/skills/hostan/SKILL.md` installs it into a harness's skill
  directory; it is a shortcut into this contract, not a replacement
- MCP tool `read_docs` returns this exact file in-band — call it on any
  hostan MCP session (no credential needed) instead of fetching the URL

## Contents

Publishing: [What this is](#what-this-is) ·
[Base URLs & surface map](#base-urls--surface-map) ·
[Publish a website (quick share)](#publish-a-website-quick-share-no-key) ·
[Request fields](#request-fields) · [Slugs](#slugs-url-names) ·
[Writing HTML that works here](#writing-html-that-works-here) ·
[Limits and errors](#limits-and-errors)

Durable apps: [Durable apps (projects)](#durable-apps-projects--needs-a-workspace-credential) ·
[Editing a published page](#editing-a-published-page-incremental-changes) ·
[Preview runs](#preview-runs-test-a-draft-before-promoting) ·
[Page access passwords](#page-access-passwords)

Runtime (what a published page can do):
[Visitor sign-in](#visitor-sign-in-managed-identity) ·
[Server-side fetches (hostan.fetch)](#server-side-fetches-hostanfetch) ·
[Real-time rooms (hostan.ws)](#real-time-rooms-hostanws) ·
[Managed payments (hostan.payments)](#managed-payments-hostanpayments)

**Debugging a published page → [Debugging](#debugging-a-published-page)**
(there is no console; this section is where to look when a page "doesn't
work"), and the symptom→cause table in
[Troubleshooting egress failures](#troubleshooting-egress-failures).

Reference: [Error codes](#error-codes) ·
[Platform plans](#platform-plans-what-you-pay-host-an-app) ·
[MCP OAuth](#mcp-oauth-connecting-consumer-clients) ·
[MCP alternative](#mcp-alternative) ·
[Self-hosting operations](#self-hosting-operations) ·
[Minimal agent loop](#minimal-agent-loop)

## What this is

You generate content (an HTML page, a Markdown note, a Mermaid diagram, a
JSON or CSV dataset). Host An App publishes it at
`https://sites.hostan.app/<slug>` — live immediately, world-readable,
auto-deleted after 7 days. Quick shares are anonymous and disposable: demos,
prototypes, snapshots, data explorers, notes worth sharing once.

Apps that must last longer than 7 days, or remember things between visits
(a to-do list, bookings, a poll), are built as **projects**
(workspace → project → backend + frontends). Projects need a workspace
credential — an `hstn_…` key or an MCP OAuth token — and are covered in
"Durable apps (projects)" below.

## Base URLs & surface map

One rule decides where an endpoint lives: **credential or ops → api;
visitor browser or tenant runtime → sites; sign-in machinery → auth;
humans → hostan.app.**
Each origin owns exactly one audience — never call an owner endpoint on
the sites host, and never look for human pages on the API host.

- `https://api.hostan.app` — the platform API, canonical for everything
  it serves: `/api/v1/…` (deploy, workspaces, projects, frontends,
  backend, payments catalog/sessions, reports, visit, auth, stats),
  `/api/v1/access/root` (an internal nginx `auth_request` target that
  resolves a project host's `/` to that project's root frontend and
  answers its slug in `X-Root-Slug` — not a browser-facing endpoint),
  `/mcp` — the MCP resource server — with its RFC 9728 resource metadata
  (`/.well-known/oauth-protected-resource[|/mcp]`), `/healthz`,
  `/metrics`, and the spec at `/api/v1/openapi.json` (alias
  `/openapi.json`, which redirects there). Also `/llms.txt` (alias
  `/agents.md`): both OAuth discovery documents advertise
  `resource_documentation`/`service_documentation` at
  `https://api.hostan.app/llms.txt`, so the same
  drift-guarded file the humans origin serves resolves here too — one
  file, never a divergent copy. JSON answers, including
  errors. CORS `*`, so browser `fetch` works from any origin.
- `https://auth.hostan.app` — the **identity origin**, and nothing else:
  the centralized visitor sign-in callback (`/auth/app/callback`) and the
  MCP authorization server
  (`/auth/mcp/{authorize,callback,consent,token,register,revoke}` plus its
  RFC 8414 metadata at `/.well-known/oauth-authorization-server` — the
  issuer's own origin, where RFC 8414 §3.1 requires it). It is also where
  the tenant dashboard lives (`/dashboard` — a human surface: first-party
  OAuth sign-in via this same AS, BFF session; enabled only when the AS
  is). It is the tenant console: a sticky header carrying the workspace
  switcher and four tabs — Projects (the
  landing page, scoped to the workspace chosen in the switcher, with the
  new-project control), API keys (per workspace, with the secret shown
  once at issue), Workspaces (plan usage, billing, members), and Account
  (the user's own id, linked sign-ins, sign-out). The header avatar is the
  signed-in identity's provider profile picture, falling back to initials.
  Project pages carry a **live preview** of the published page (an iframe
  at desktop/tablet/phone widths, no JS), the frontends with their public
  URLs, visits, and the backend tier's summary, plus a per-frontend
  **release timeline** over the same revision workflow `manage_frontend`
  exposes (promote a draft, roll back to a historic revision), rename, and
  delete; a workspace page — the console's only page per workspace, at
  `/dashboard?ws={id}` — carries the workspace's project inventory, plan and
  billing, the payment-account connection, the member list, rename, and
  settings. Every mutation rides the
  same
  membership seam and role ceilings the REST/MCP surface enforces, so
  browser identity and session state never leave one trust domain. Its
  sign-in gate lists the registered providers directly, so a human picks a
  method once (the dashboard forwards `?provider=<name>` into the AS
  authorize call) instead of stepping through a bare "continue" page.
  It serves no deployment
  content, no tenant runtime and no API, so a path here is never a
  negotiation with user content. Its callbacks are the platform's only
  provider-facing redirect URIs, and all derive from the operator's
  `HOSTAN_AUTH_URL` — none is configured per provider; the dashboard's
  redirect URI is `<auth origin>/dashboard/callback`, and the Stripe
  Connect onboarding callback (when Connect is configured) is
  `<auth origin>/auth/connect/stripe/callback`.
- `https://sites.hostan.app/<slug>` — the visitor origin: deployed
  content plus only what a deployed page needs same-origin — the tenant
   runtime (`/<slug>/api/op|fetch|query|schema`, `/<slug>/payments/…`,
  `/<slug>/auth/…`, `/<slug>/ws`) and three platform paths the browser
  reaches same-origin: `GET /api/v1/visit/…` (beacons), `POST
  /api/v1/auth/…` (login + the edge gate), `/api/v1/reports`
  (report-a-page form). `POST /api/v1/deploy` is NOT proxied here — it
  is canonical on the API origin (CORS `*` covers the playground's
  cross-origin POST), and the origin-isolation guard keeps it that way.
  A sign-in started here (or on a project subdomain) finishes on the
  identity origin and returns to the origin it started from.
- `https://<host_label>.hostan.app/<slug>` — **project subdomains**
  (#113): every managed project gets a unique host label (returned as
  `host_label` + `url` in manage_project responses). The project's
  frontends are served at `https://<host_label>.hostan.app/<slug>` —
  the same path grammar and tenant runtime as the sites origin, on a
  project-scoped hostname (the project is the browser origin boundary).
  The Go gate verifies the requested slug belongs to that project and
  answers 404 otherwise; free quick-share slugs stay on the sites
  origin and are not reachable from any project subdomain. Responses
  for a project's frontends (manage_frontend / the REST equivalents)
  advertise this subdomain form in `url`/`api_base` when the sites
  domain is configured (#283); `sites.hostan.app/<slug>` stays valid
  and remains the storage/edge identity — both origins serve the page.
  The label defaults to the slugified project name ("Botiga" → the label
  `botiga`); the workspace stem is only a conflict fallback.
  **The label is editable** (#612): `manage_project update {host_label}`
  renames it. A rename is a MOVE — the previous label is freed at once
  and links to it stop resolving (no redirect, no retention) — and the
  new label must be unclaimed and not a reserved/platform label.
- **Root mount** (#612): a project may designate ONE of its frontends
  as its root, served at the project subdomain's `/` —
  `https://<host_label>.hostan.app/` — while its siblings stay at
  `/<slug>`. Set it with `manage_frontend set_mount {mount: "root"}`
  (or `"slug"` to return it to path serving); setting a new root
  demotes the previous one. It applies ONLY on the project subdomain —
  the shared sites origin has no project host, so quick shares are
  unaffected and a root-mounted frontend is not reachable there. The
  frontend's own `/<slug>` URL keeps working, and at root the platform
  owns `/api`, `/auth`, `/payments`, `/ws`, `/__visit` and
  `/favicon.ico`, so a root frontend's own path space excludes them (a
  root frontend is still ONE document; use sibling frontends for
  sub-pages).
- `https://hostan.app` — humans only: landing, docs, pricing, showcase, legal, changelog,
  this file (canonical copy; the API origin serves it too — see above).
  No API.
- Reserved platform subdomains — `www.hostan.app`, `docs.hostan.app`,
  `mcp.hostan.app`, `assets.hostan.app`, `admin.hostan.app`, plus
  `sites`, `api` and `auth` above — are permanently barred from being
  project host labels (fail-closed blocklist in the origin gate, #111),
  so a project can never claim a platform hostname. Adding another
  platform subdomain means updating this map AND that blocklist in the
  same change.

Two path grammars mean two different products: `/api/v1/…` is the
platform API (your credential applies); `/<slug>/api/…` is the per-app
runtime (tokenless, slug resolved server-side, versioned by the injected
`hostan.db` runtime rather than the URL).

## Publish a website (quick share, no key)

One request. `content` is the only required field.

```bash
curl -X POST https://api.hostan.app/api/v1/deploy \
  -H "Content-Type: application/json" \
  -d '{"type": "html", "name": "my-app",
       "content": "<!DOCTYPE html><html><head><title>Hi</title></head><body><h1>Hello, internet.</h1></body></html>"}'
```

Success is `201 Created`:

```json
{"id": "dep_…", "slug": "my-app", "url": "https://sites.hostan.app/my-app",
 "created_at": "2026-…Z", "expires_at": "…seven days later…"}
```

Share the `url`. It works immediately and deletes itself after 7 days.

Markdown example (rendered to a styled page; same for `mermaid`, `json`, `csv`):

```bash
curl -X POST https://api.hostan.app/api/v1/deploy \
  -H "Content-Type: application/json" \
  -d '{"type": "markdown", "name": "notes", "title": "My notes",
       "content": "# Notes\n\nEverything I remember."}'
```

## Request fields

| Field      | Meaning |
|------------|---------|
| `content`  | **Required.** The file's full text: HTML, Markdown, Mermaid source, JSON, or CSV. Non-empty, valid UTF-8, under 1 MB (rendered HTML over 1 MB is also rejected). |
| `type`     | `html` (default, served as written), `markdown` (rendered to a styled page), `mermaid` (interactive zoomable diagram), `json` (searchable data explorer), `csv` (filterable table with charts). |
| `name`     | Preferred slug (URL name). Omit it and the server picks one. On collision the server appends a suffix automatically. |
| `title`    | Page `<title>`/heading for `markdown`/`mermaid`/`json`/`csv`. Auto-derived from the first H1 or the slug if omitted (max 80 chars). |
| `password` | Optional access password. Visitors see a login page first. |
| `one_time` | Optional boolean. If `true`, the page self-destructs after the first visit (burn-after-reading). |

## Slugs (URL names)

- 3–63 chars, lowercase `a-z`, digits, hyphens; never start or end with `-`.
- Reserved (rejected): `api`, `auth`, `oauth`, `www`, `mcp`, `admin`, `static`, `hostan`,
  `app`, `healthz`, `_healthz`, `docs`, `assets`, `terms`, `privacy`,
  `acceptable-use`, `report`, `openapi`, `changelog`, `ws`.
- View a deployment at `https://sites.hostan.app/<slug>`.
- Raw source variants (same slug): `/<slug>.md` (Markdown source),
  `/<slug>.txt` (plain text), `/<slug>.json` (JSON source),
  `/<slug>.csv` (CSV source).

## Writing HTML that works here

- Ship **one self-contained HTML file**. Inline your CSS and JS; external
  `https://` resources (fonts, libraries, images, APIs) are allowed.
- Served with a content-security policy roughly: scripts/styles/images may
  come from `'self'`, `data:`, `blob:` and any `https:` origin;
  `frame-ancestors 'none'` (your page cannot be iframed — the one
  exception is the tenant console's live preview, which embeds the
  trailing-slash URL `/<slug>/` and is the only ancestor the platform ever
  admits); `form-action 'self'` (forms may only post back to the page
  itself — send data out with `fetch`, not forms).
- **Browser `fetch` to a public, CORS-friendly `https` origin is
  allowed.** The policy carries no `connect-src` of its own, so
  connections fall back to `default-src … https:`. Such a call goes
  browser→third party directly (never through Host An App) and is
  therefore **not** subject to the `hostan.fetch` caps: no 5 s timeout,
  no 256 KB cap, no secret support. Use it for large or slow public
  responses, and `hostan.fetch` when you need a secret or a server-side
  call. See "Server-side fetches" above.
- When a page needs to be edited incrementally later, **keep the source
  multi-line**. `manage_frontend search`/`edits` are addressed by line and
  by exact text, and a page minified onto one long line defeats the line
  half of that: every match reports line 1, so line ranges address
  nothing and an exact-match `old` must be unique in the whole file.
  Shipping readable HTML costs nothing and keeps the editing surface
  intact (if a source is already minified, `content` remains the reliable
  path).
- The visit beacon counts a **top-level** view only (`window.top ===
  window.self`), so an embedded preview never inflates your visit total
  and never consumes a one-time deployment.
- There is no server side of your own: persistence across visits needs a
  project backend (below), otherwise keep state in the page
  (`localStorage`, URL hash) or in third-party `https://` APIs.
- Total request body must stay under 1 MB.
- **There is no page console and no page-side error log.** Nothing
  captures your page's `console.log`, uncaught exceptions, or failed
  requests, and Host An App cannot see them. If a page must be
  debuggable, surface errors into the DOM yourself (see "Debugging a
  published page").

## Limits and errors

- Quick shares live **7 days**, then are removed automatically.
- Rate limit: **30 deploys/min per IP** (burst 15). Over it → `429` with
  `Retry-After: 60`; wait and retry.
- Body cap: **1 MB** → `413` over it (keep content smaller).
- Platform capacity (5000 active deployments, 100 MB free-disk floor) →
  `503` with a reason; retry later.
- `400` means validation failed (bad slug, empty content, bad type —
  `type: "database"`/`"app"` are rejected; databases are project
  backends, see below). The response body names the problem.

## Durable apps (projects) — needs a workspace credential

Quick shares expire and forget. A **project** is a durable envelope — one
identity, one expiry clock (default permanent) — holding published
**frontends** (pages, same five types) and at most one **backend** (a
private 5 MB SQLite database your pages call through author-defined
**actions**; pages use `await hostan.db.op("<action>", params)` and never
see SQL or credentials).

Auth: every project request carries your workspace credential — a
workspace key (`hstn_…`, issued by the operator — no self-serve signup
yet) or an MCP OAuth access token (`hoat_…`) — as
`Authorization: Bearer …`. Without it you get `401`; quick shares stay
anonymous.

### Workspaces

Workspaces own projects; your authority is the caller's live membership
set, read per request. Enumerate yours before creating when you belong to
several:

- `GET /api/v1/workspaces` — ONLY the workspaces you belong to:
  `{"workspaces": [{id, slug, display_name, role, plan, project_count, state,
  created_at}]}`
  (role is one of `owner > admin > editor > viewer`; a machine key is a
  `machine` member of exactly its one workspace). Never a global list.
- `POST /api/v1/workspaces` `{display_name, slug?}` → a new workspace you
  own (OAuth-token callers only — a machine key cannot found workspaces).
  A fresh workspace starts on the free plan, whose single project is
  counted per ACCOUNT across the owner's FREE workspaces (see the
  allowance below), so its first project is refused only when that
  allowance is already spent — with the console URL where a human picks
  a tier.
- `GET /api/v1/workspaces/{id}` — detail with the member list (id or slug
  in the path); `PATCH …/{id}` `{display_name?, plan?}` (display name:
  admin floor; plan: owner floor, **free only**) — `plan` is the platform
  billing plan (`free`, `hobby`, `pro`, `unlimited`) and the workspace is
  the payer of record.
  **A paid plan can never be asserted**: `PATCH …/{id}`
  `{"plan":"pro"}` answers `403` naming the price, because a paid plan is
  granted by a subscription and never by a request. `DELETE …/{id}`
  (owner floor) **deletes the workspace as a cascade**: every member
  project is torn down through the same cascade an explicit project
  delete runs, the workspace's subscription (if any) is scheduled to end
  at the payment provider at the close of the paid period BEFORE
  anything is destroyed, its tenant keys are purged, and the row goes
  last. `409` (`ErrSubscriptionCancel`) when the cancellation
  could not be scheduled (payments disconnected or provider refusing) —
  nothing is destroyed, retry once the provider is reachable or cancel
  from the billing portal first.
- The plan caps projects. Every PAID tier caps them per WORKSPACE
  (`hobby`: **5**, `pro`: **25**, `unlimited`: **no ceiling**), while the
  free plan's allowance — **1 project** — is counted per **ACCOUNT**:
  across every FREE workspace the owner owns, not once per workspace. That
  is the anti-farming rule — a second free workspace grants no second free
  project — and it cuts fairly the other way too: projects in PAID
  workspaces are covered by the tier their workspace subscribes to and
  never consume the allowance, so an account with an Unlimited workspace
  holding five projects founds a free one and starts at 0/1, not 5/1. A
  free plan creates `1` project and refuses the second with a
  `403`; a paid tier refuses the first project past its ceiling the same
  way. Either refusal names the plan, the ceiling, and the console page
  where a human picks a larger tier
  (`auth.hostan.app/dashboard?ws={id}`) — relay that URL rather
  than retrying, and never delete the human's projects to make room. This
  is **platform billing** (the workspace pays Host An App) and is separate
  from app payments (visitors pay a project's Stripe catalog, below).
- `POST /api/v1/workspaces/{id}/billing/checkout` (owner floor) — optional
  body `{"plan": "hobby"|"pro"|"unlimited"}`, defaulting to the entry paid
  tier — → `{"url": "https://checkout.stripe.com/…"}`: one hosted
  subscription checkout for that tier, priced from the platform catalogue
  (**hobby €5.00/month, pro €15.00/month, unlimited €25.00/month**, all tax
  included, `eur`; the amount is never taken from the request). Send the
  human to that URL in a browser; the endpoint grants nothing and the plan
  does **not** move when they come back. Paid plans are granted by the
  provider webhook, so read the plan back with `GET /api/v1/workspaces/{id}`
  after a few seconds instead of trusting the redirect. `409` when the
  workspace already holds a live subscription (`ErrAlreadySubscribed`) —
  one workspace holds ONE subscription, so a tier CHANGE goes through
  `…/billing/change` below, never through a second checkout — `503` when
  the platform has no payment credential, no identity origin to return the
  buyer to, or no webhook signing secret (it would sell a plan it cannot
  apply), `502` when the provider refuses. The human path is the
  dashboard's workspace page
  (`auth.hostan.app/dashboard?ws={id}`), which renders one button
  per tier.
- `POST /api/v1/workspaces/{id}/billing/change` (owner floor; body
  `{"plan": "hobby|pro|unlimited"}`, **required**) → `204`: moves the
  workspace's EXISTING subscription onto another catalogue tier, prorated
  at the provider — an upgrade is charged now, a downgrade is credited
  against the next renewal. Use it when a workspace already pays and must
  move tiers; it never opens a second subscription (which would charge
  twice) and it grants nothing — like checkout, the plan moves when the
  provider reports the change, so read the plan back with
  `GET /api/v1/workspaces/{id}`. The provider's own billing portal
  (`…/billing/portal`) is **not** a reliable tier-change path: whether it
  offers a plan switch is a per-account portal configuration, and a portal
  switch would move the price without stamping the platform's tier
  metadata — leaving the tier and the charge out of step. `409` when
  the workspace is not paying (never billed, no subscription pointer, or a
  non-paid plan — buy one instead: `ErrNoSubscription`), when the
  subscription is already scheduled to end (`ErrSubscriptionEnding` — a
  change would charge for a period it will not complete; keep it or let it
  end first), or when it already holds
  the tier asked for, `400` for a missing tier or an unknown/unsold one
  (`ErrInvalid`/`ErrUnknownPlan` — the message names what is sold), `503`
  when the platform has no payment credential or no webhook signing secret
  (a change the platform cannot verify must not charge), `502` when the
  provider refuses. The dashboard renders one button per OTHER tier on a
  paid workspace page.
- `POST /api/v1/workspaces/{id}/billing/portal` (owner floor) →
  `{"url": "https://billing.stripe.com/…"}`: the provider's own billing
  portal, opened for the workspace's recorded provider customer — where the
  owner **updates the payment method or cancels the subscription**. This is
  the supported exit from a paid plan: the platform has no route that
  downgrades a workspace out from under a live subscription (asserting
  `free` while one stands is `403`), because the subscription ends at the
  provider and the cancellation comes back as an ordinary webhook verdict.
  Same display-only contract as checkout — the plan moves when the provider
  reports it, and a cancelled workspace returns to `free` with its projects
  untouched. `409` when the workspace has never completed a checkout (there
  is no customer to manage), `503` when the platform has no payment
  credential or no identity origin, `502` when the provider refuses. The
  dashboard renders the same action as a **Manage billing** button on a
  paid workspace page.
- Members — `GET …/{id}/members` reads the list at the **viewer** floor;
  `POST …/{id}/members` (`{user_id, role}`), `PATCH …/{id}/members/{user_id}`
  (`{role}`) and `DELETE …/{id}/members/{user_id}` change it at the
  **admin** floor, under a role ceiling: a caller can only grant or touch a
  role at or below its own, so an **admin can never grant (or remove)
  `owner`** — only an owner can, as a co-owner grant. Removing a member
  takes effect on their next request — no token revocation needed. `role`
  is the human ladder `owner`, `admin`, `editor`, `viewer`; `machine` is
  refused (it marks a workspace key's service principal), as is a **service
  principal** `user_id` (`usr_mch_…` for a workspace key, `usr_platform`)
  — a key holds exactly one membership, written by provisioning and removed
  by revoking the key, so keys are never managed as members (`400`).

REST endpoints (full schemas in `openapi.json` under `/api/v1/projects…`):

- `POST /api/v1/projects` `{name, description?, workspace?, ttl_seconds?|permanent?}` → project (keep its `id`); `workspace` (id or slug) is **required when the caller belongs to several workspaces**, defaulted when exactly one, and a workspace without membership is `403`
- `GET /api/v1/projects` — list your projects (optional `?workspace=<id|slug>` filter); each carries the owning workspace's slug in `workspace`
- `GET|PATCH|DELETE /api/v1/projects/{id}` — inspect, rename, tear down (cascade deletes members, frees slugs)
- `POST /api/v1/projects/{id}/frontends` `{type, content, requested_slug?, title?, password?}` → `{url, …}` (inherits the project's clock; no per-page TTL or `one_time`; `password` sets the access gate — see "Page access passwords")
- `GET /api/v1/projects/{id}/frontends` — list the project's frontends (each with `password_protected`)
- `PUT /api/v1/projects/{id}/frontends/{slug}/password` `{password}` or `{clear: true}` — install/replace, or lift, the page's access gate without republishing content (the create/update bodies never carry a password)
- `GET|DELETE /api/v1/projects/{id}/frontends/{slug}` — inspect, remove (slug freed, visit history retired)
- `PUT /api/v1/projects/{id}/frontends/{slug}` `{content | edits}` — republish immediately (type fixed at create); the response carries a bounded `diff` (see "Editing a published page")
- `GET /api/v1/projects/{id}/frontends/{slug}/content` — the exact stored source (never the rendered page, which has the platform injections baked in)
- `GET /api/v1/projects/{id}/frontends/{slug}/search?query=&regex=&ignore_case=&context_lines=&context_bytes=&max_bytes=&limit=&revision_id=` — line-addressed matches with context over the stored source
- `GET /api/v1/projects/{id}/frontends/{slug}/diff?revision_id=&against_revision_id=&context_lines=` — the bounded change between two revisions
- `GET|POST /api/v1/projects/{id}/frontends/{slug}/revisions` — list revisions (metadata only) / create a draft (`{base_revision_id, content | edits, title?, message?}`); `GET …/revisions/{rid}` (+ `/content`), `POST …/revisions/{rid}/promote|rollback` `{expected_active_revision_id, message?}` — the draft → preview → promote workflow
- `POST /api/v1/projects/{id}/frontends/{slug}/runs` `{revision_id?}` — start a preview run (#500, scratch tier + evidence; default revision: the newest); `GET …/runs/{rid}` — the report (state, verdict, summary, records); `DELETE …/runs/{rid}` — stop + final report (see "Preview runs")
- `POST /api/v1/projects/{id}/backend` `{ddl, realtime?}` — provision SQLite from `CREATE TABLE/INDEX/VIEW` + seed `INSERT`s (additive only; schema-defining statements only, so `CREATE TABLE … AS SELECT` is refused — declare the table's shape instead); `realtime: true` opts into the WebSocket room relay (see "Real-time rooms")
- `GET|PATCH|DELETE /api/v1/projects/{id}/backend` — inspect live schema + ledger + actions (each with its SQL verbatim), apply one additive migration (`ddl`), or tear down
- `PUT /api/v1/projects/{id}/backend/actions` `{actions: [{name, sql, param_schema?, note?}]}` — define the named statements pages may invoke (one `SELECT`/`INSERT`/`UPDATE`/`DELETE` each, `:name` binds; a call may pass only the statement's caller-owned binds — extras are a `400` — and each is required unless `param_schema` marks it optional). `param_schema` is an optional declared call contract: `[{name, type, required?}]` with `type` one of `string`|`integer`|`number`|`boolean` and `required` defaulting to `true`. When present it must name **exactly** the statement's caller-owned `:name` binds (a mismatch is refused at define time naming the diff), it rides the action's version like a SQL or note edit, and it makes every call type-checked — a wrong-typed or missing-required param is a `400` naming the param and expected type, while an omitted optional param binds SQL `NULL`. **Omitting `param_schema` on a redefine keeps the action's stored schema** (an omission never clears a declared contract; a stored schema the new SQL no longer satisfies is refused — declare the schema for the new statement or send `[]`); an explicit empty array drops it, returning the action to the historical contract. `GET …/backend` renders the declared schema on each action as `param_schema` alongside the always-derived `params` bind list; an action without one behaves exactly as before (all binds required, any JSON scalar accepted). **Retirement** (`{name, retire: true}`, a batch of retire entries ALONE per update — never mixed with definitions, and a retire entry carries no `sql`/`param_schema`) is the author-facing removal: it stamps the action's CURRENT version retired — the ledger keeps every version — `manage_backend get` still lists it flagged `retired`, pages calling it get a **410 Gone** ("existed and was deliberately removed", not a 404 typo), re-retiring is an idempotent no-op, and any later definition of the name (even byte-identical) revives it under a new version. Retirement is the way an action leaves the public surface — redefinition alone never removes one.
- `PUT /api/v1/projects/{id}/backend/fetches` `{fetches: [{name, method, url, headers?}]}` — define the named server-side endpoints pages may call (fixed `GET`/`POST` + `https` URL each; header values may reference secrets as `${NAME}`; a declared `Content-Type` must be a JSON media type or absent, since a `POST` fetch always carries a JSON body, and two header names differing only in case are refused; see "Server-side fetches")
- `POST /api/v1/projects/{id}/backend/secrets` `{name, value}` — store one secret value (names look like `API_KEY`); `DELETE …/backend/secrets/{name}` removes it (idempotent). Responses are names-only — values are never returned. Secret values travel here (dashboard or headless CI with a workspace credential), never through MCP.
- **Test the declared surface without publishing** (owner-only, define → test → fix): `POST /api/v1/projects/{id}/backend/fetches/{name}/test` `{query?, body?}` invokes the real fetch — real egress, real secrets (substituted server-side, never echoed), same 5 s / 256 KB caps — and returns `ok`, `status`, response `headers`, `bytes_received`, `elapsed_ms`, and a bounded secret-redacted `body`; a failure returns the same envelope a page's 503 carries (`reason`, `upstream_status`, `elapsed_ms`, `bytes_received`, `limit`, `error`), so one call names the cause. `POST /api/v1/projects/{id}/backend/actions/{name}/test` `{params}` executes the action against the **live** database: rows for a SELECT, the verbatim SQL error for a bad statement, a `400` naming the diff for missing/extra params, and a `410` for a retired action (it no longer runs for pages, so it is not owner-testable either) (and, when the action declares a `param_schema`, wrong-typed or missing-required params — the same typed contract a page's call is held to). **test_action writes are real** (no scratch tier exists — the ledger is the schema's only truth): a test INSERT commits like a page's INSERT, so treat a write action as a write. Actions binding `:hostan_user_id`/`:hostan_user_email` need a signed-in visitor and are refused owner-side with guidance. Both exist over MCP as `manage_backend test_fetch` / `manage_backend test_action`.
- Visitors call actions tokenlessly: `POST https://sites.hostan.app/<slug>/api/op` `{"action": "…", "params": {…}}` — the request body must be exactly one JSON value (trailing bytes are a `400`). A retired action answers **410 Gone** naming the retirement (check `manage_backend get` for the live surface); an unknown one answers 404. Raw SQL (`POST …/api/query`) and schema (`GET …/api/schema`) are owner-only (Bearer credential, never in a page), and the same one-value body rule applies there.
- Visitors call fetches tokenlessly: `POST https://sites.hostan.app/<slug>/api/fetch` `{"fetch": "…", "query": {…}, "body": …}` — same one-value body rule. Unknown fetch → `404`; fetch whose secret is unset → `503` naming the secret (ask the human to set it in the dashboard); every other egress failure → `503` with the structured envelope below (`reason` distinguishes `non_2xx` / `timeout` / `too_large` / `dns` / `refused` / `tls` / `unreachable`). The status is deliberately not the `502` the envelope once rode: the production edge (Cloudflare) replaces origin `502` responses with its own error page, so `502` envelopes never reach a caller over the live path (#504) — branch on `reason`, not on the status.
- Every platform JSON request body must be exactly one JSON value: trailing bytes after the value are a `400` (trailing whitespace is fine). Applies to all of `/api/v1/*`, the tenant runtime (`api/op|fetch|query|schema`, payments, auth, reports) and the admin/MCP-OAuth registration endpoints.
- Actions can also reference the reserved binds `:hostan_user_id` and
  `:hostan_user_email` — the platform injects the signed-in visitor's
  identity (see "Visitor sign-in" below), which is how an app stores and
  serves per-user data without any session code.

The same surface exists over MCP as `manage_workspace`, `manage_project`,
`manage_backend`, `manage_frontend`, `preview_run` (credential configured once on the MCP
server entry, never passed as a tool argument). `manage_workspace` lists
and manages your workspaces (`list`, `get`, `create`, `update`, `delete`,
`members`, `add_member`, `remove_member`, `set_role`); `manage_project`
create accepts the `workspace` argument and `list` the optional filter.

## Editing a published page (incremental changes)

A durable page is edited without round-tripping the whole file: **locate
with `search`, change with `edits`, verify from the returned `diff`**.

- `search` / `GET …/frontends/{slug}/search` reads the exact stored source
  server-side and returns only the matching lines (with a few context
  lines) and their 1-based line numbers, each with its exact byte range
  (`byte_start`/`byte_end`). Options: `query` (literal, or an
  RE2 pattern with `regex`), `ignore_case`, `context_lines`, `limit`,
  `revision_id` (search a specific revision rather than the active one).
  A line longer than the readable limit (a minified single-line page) is
  returned as a window around the match instead of verbatim: `context_bytes`
  (default 120, max 2000) bounds the window on each side, and `max_bytes`
  (default 48 KB, max 256 KB) caps the total extracted text, so a broad
  query stays readable and bounded.
- `edits` is the alternative to `content` on `create_revision` and
  `update`, and the two are mutually exclusive. Each edit is either:
  - `{old, new}` — exact-match text, replaced once; `old` must match
    exactly once (set `replace_all` to replace every occurrence), or
  - `{line_start, line_end, new}` — a 1-based inclusive line range
    (`line_end = line_start-1` inserts before `line_start`), or
  - `{start_byte, end_byte, new}` — a byte range (`end_byte` exclusive,
    so `end_byte = start_byte` inserts); the form that still works when
    the page is minified onto one line, pairing with a match's
    `byte_start`/`byte_end`.
  Edits apply in order, each seeing the previous result, and the call
  fails as a whole if any edit does not match — nothing is half-applied. A
  miss answers with the closest line so the next attempt can self-correct.
- Every write on the revision surface returns a bounded unified `diff`
  (`diff`, `diff_summary`, `added_lines`, `removed_lines`) between the
  source it replaced and the source it published, so the change is
  verified without reading the page back. The standalone `diff` (`…/diff`
  / the `diff` action) compares any two revisions — the active revision
  against its predecessor by default.
- The safe path is still draft → preview → promote: `create_revision`
  with `edits`, inspect the `preview_url`, then `promote` with the current
  `expected_active_revision_id`. `update` publishes immediately and is the
  compatibility path. **Know what each step proves**: the preview renders
  layout, styling and copy and carries no runtime; to prove a draft
  *works* — actions, fetches, console, exceptions — run it with
  `preview_run` (next section), then promote.

`edits` fits the text-like types (`html`, `markdown`, `mermaid`); for
`json` and `csv` a full `content` write is usually clearer.

## Preview runs (test a draft before promoting)

`preview_run` exercises a draft revision against a **scratch runtime** and
returns the evidence a browser produced — console output, uncaught
exceptions and rejections, and every `hostan.db` op and `hostan.fetch`
call with its outcome — without publishing, and without touching
production data. This is the missing half of the draft → preview →
promote loop: the preview proves *renders*, the run proves *works*.

- **start** — `preview_run {mode: "start", project_id, frontend,
  revision_id?}` (REST: `POST
  /api/v1/projects/{id}/frontends/{slug}/runs` `{revision_id?}`). The
  revision defaults to the frontend's newest draft; any prior live run of
  that revision is stopped first. The answer carries `run_id`, `run_url`
  (a `/_run/<run-id>/` URL), and `expires_at`.
- **drive** — open `run_url` in any browser you control. The page is the
  draft's rendered artifact with the real `hostan.db`/`hostan.fetch`
  runtime bound to the run's own API base plus a capture shim (console,
  errors, op/fetch outcomes — strictly bounded, batched same-origin).
  Actions execute against a **throwaway SQLite tier replayed fresh from
  the project backend's migration ledger** — reads and writes hit scratch
  rows, production provably keeps its data — and fetches do real egress
  with the production caps (5 s timeout, 256 KB, structured failure
  envelopes), so a draft failure predicts the production failure. A run
  exercises `hostan.db` and `hostan.fetch` only: `hostan.auth`,
  `hostan.payments`, and `hostan.ws` are not part of a run (no run routes
  exist for them), so verify a draft that uses those surfaces on a
  published frontend — the run's verdict reflects db+fetch behavior.
- **result** — `preview_run {mode: "result", project_id, run_id}` (REST:
  `GET …/runs/{rid}`). Returns `verdict` (`clean` | `issues` | `quiet` —
  nobody opened the URL — or the terminal state), `state`, a `summary`
  tally, and the bounded `records`.
- **stop** — `preview_run {mode: "stop", project_id, run_id}` (REST:
  `DELETE …/runs/{rid}`). Ends the run, tears down both artifacts (the
  scratch tier and the run page), and returns the final report.

Caps and lifecycle: a run lives 30 minutes (TTL sweeps it — transition,
artifact teardown, evidence drop), 3 active runs per project, 25
platform-wide (over the cap → a capacity error naming the remedy). Rows
persist briefly after a terminal transition so a late `result` still
reads. Interrupted runs are reconciled at startup; no scratch directory
outlives its run.

Hard guarantees: identity-gated actions (`:hostan_user_id` binds) fail in
a run exactly as they fail for an anonymous visitor (a run measures the
anonymous path — the failure is the evidence); secrets are substituted
server-side and never appear in a report; the run's raw SQL and schema
surfaces stay owner-only (workspace credential), like production; the run
page rides the same edge gate as its frontend (a safety-disabled or
password-protected frontend is dark/locked on its runs too).

**Retention (which revisions still have content).** A frontend keeps every
revision's metadata forever but only the newest seven revisions'
*artifacts* (operator-tunable via `HOSTAN_REVISION_RETENTION_COUNT`; the
active revision is always kept). The pruning moment is the next
`create_revision` — there is no scheduled job. A revision past the window
still appears in `list_revisions`/`get` with `content_expired: true`, no
`preview_url`, and a `pruned_at` stamp; reading its content, searching it,
or diffing against it, or promoting/rolling it back, answers `410`. If you
need to roll back further than seven revisions, that content is already gone
— do not plan on a long rollback window.

Two different "auth"s — don't confuse them:

- **Owner auth** (this section): your workspace credential, `hstn_…` or
  `hoat_…`, as a Bearer header on management requests. Never appears in a
  page.
- **Visitor sign-in** (next section): platform-managed Google sign-in
  for the people using your published app. No credential, no code.

## Page access passwords

A project frontend can carry an **access password** — the same gate quick
shares use, on the durable surface:

- Set it at create (`password` on `POST …/frontends` or the `password`
  argument of `manage_frontend create`) and manage it later with the
  `set_password` action of `manage_frontend` (`password` installs or
  replaces; `clear_password: true` lifts the gate) or
  `PUT /api/v1/projects/{id}/frontends/{slug}/password`.
- Visitors see a login page first; unlocking sets a short-lived,
  slug-bound cookie. The gate covers the **page surface**: the canonical
  URL, the project's own subdomain, raw sources (`/<slug>.md` and
  friends), sub-paths, and revision previews — on every origin that
  serves the slug, with no extra configuration.
- The gate is page metadata, not content: it survives every
  republish/promote/rollback untouched, changes create no revision, and
  setting it purges the edge cache so an already-public cached copy
  cannot outlive it. `get` answers `password_protected: true`; the hash
  (PBKDF2+pepper) never leaves the control plane.
- The **runtime API is deliberately not gated**: `/<slug>/api/op` and
  `/<slug>/api/fetch` stay tokenless by design (the author-defined
  surface). When an action needs to be private to a signed-in visitor,
  bind `:hostan_user_id` (see "Per-user actions").

## Visitor sign-in (managed identity)

Project frontends can sign their visitors in with Google or GitHub
(operator-configured — no per-app OAuth setup, no session code to
write). A signed-in visitor is a platform user with a stable `usr_…` id
that survives across visits and browsers-after-login, which is the basis
for per-user data.

Endpoints, per frontend slug (all on the published site):

- `GET https://sites.hostan.app/<slug>/auth/login?return=/path` — start
  sign-in; the visitor is redirected to the provider and lands back on
  `return` (default `/`). An optional `?provider=<name>` pins the sign-in
  method (#275): a project that does not allow that provider answers
  `404`. With no `provider`, exactly one allowed method redirects
  directly and several render the sign-in picker (the visitor chooses,
  the platform never guesses). Answers `503` where identity is not
  enabled.
- `GET https://sites.hostan.app/<slug>/auth/providers` → the sign-in
  methods the frontend's project allows, in stable order: `{"providers":
  ["google","github"], "default": "redirect"|"picker"}`
  (`default` says what a provider-less login does). Used to render
  "Sign in with …" buttons. Answers `404` for unknown/free slugs, `503`
  where identity is not enabled.
- `GET https://sites.hostan.app/<slug>/auth/status` →
  `{"user": {"id": "usr_…", "email": "…", "name": "…", "picture": "…",
  "provider": "google", "locale": "en", "expiresAt": "…"}}` (fields
  other than `id` appear only when asserted), or `{"user": null}` for an
  anonymous visitor (always HTTP 200 — anonymous is a normal state, not
  an error).
- `POST https://sites.hostan.app/<slug>/auth/logout` — revoke the
  session server-side and clear the cookie.

In a page, the injected `hostan.auth` runtime wraps these (present
whenever the project has a backend, alongside `hostan.db`):

```js
const user = hostan.auth.user();        // null, or {id, email?, name?,
                                        //  picture?, provider?, locale?,
                                        //  expiresAt?}
hostan.auth.login();                    // redirect into sign-in, back to this page
hostan.auth.login('github');            // or force a specific method (#275)
const providers = hostan.auth.providers(); // the project's allowed sign-in methods
await hostan.auth.logout();             // revoke, then reload
hostan.auth.onChange(u => render(u));   // fires once the session resolves
```

- `user()` is synchronous and starts `null`: the session cookie is
  HttpOnly, so the runtime resolves it once per page load from
  `auth/status`. Register `onChange` instead of polling.
- Identity cookies (`hauth_tx`, `hauth_<slug>`) are scoped to the
  registrable domain (`Domain=hostan.app`, derived from the identity
  origin) so a sign-in started on a project subdomain round-trips
  through the centralized callback on the identity origin (#283). The
  browser presents them to every platform host; the server-side session
  stays project-scoped — a session minted for one project is rejected
  with 401 at another, so cookie reachability is never authorization.
- After the callback the visitor lands back on the ORIGIN they signed in
  from (the project subdomain they started on, or the sites origin), not
  on the callback's origin: the origin rides the transaction from the
  login request itself. Only a host on the platform's own registrable
  domain qualifies; every other value falls back to the path alone, so
  the return can never become an open redirect.
- The profile is `{id, email?, name?, picture?, provider?, locale?,
  expiresAt?}` — only `id` is guaranteed; every other field appears
  when its source asserts it:
  - `email`, `name` — when the provider verified/asserted them
  - `picture` — the provider avatar URL (drop it straight into `<img>`)
  - `provider` — the authenticating provider (e.g. `"google"`)
  - `locale` — the visitor's language tag (BCP 47)
  - `expiresAt` — the session's hard end, RFC3339 UTC; warn before it,
    an expired session resolves back to `user() === null`
- The allowed methods are the project owner's choice: the per-project
  `allowed_providers` list restricts sign-in to exactly those providers
  (#275), validated against the platform's registered set (an unknown
  name is a rejected update). With no per-project list, every registered
  provider is offered; `HOSTAN_AUTH_PROVIDER` names the platform default
  when no provider is pinned and several are allowed.
- Sessions are per-frontend and project-scoped: signing in on one
  frontend does not sign the visitor in on a sibling frontend, and a
  session minted for one project can never authenticate another's.
- Sessions rotate: each authenticated `auth/status`/`api/op` call
  refreshes the sliding expiry (never past the absolute lifetime) and
  re-mints the token past its rotation age (the old value stays valid for
  a short grace window), so `auth/status` answers may carry a fresh
  `Set-Cookie`. `expiresAt` is the hard absolute end.

### Per-user actions

Actions may reference two reserved binds the caller can never supply;
the platform injects them after resolving the visitor's session:

- `:hostan_user_id` — the stable `usr_…` id (your ownership column)
- `:hostan_user_email` — the verified email (SQL `NULL` when none)

```sql
-- action "my_todos": every visitor sees only their own rows
SELECT id, title, done FROM todos WHERE user_id = :hostan_user_id
```

Calling an action that uses `hostan_*` binds without a session → `401`;
supplying any `hostan_*` param yourself → `400`. Actions without them
stay public.

## Server-side fetches (hostan.fetch)

Pages cannot call external APIs that **require a secret**, or that **lack
CORS headers** — for those the backend declares named egress endpoints and
the page invokes them by name, with secrets substituted server-side. That is
the ONLY **server-side** HTTP a page may trigger.

Be precise about the other half, because it decides architectures: the
deployment CSP has no `connect-src` of its own, so connections fall back to
`default-src … https:` — a published page **may** `fetch()` a public,
CORS-friendly `https` endpoint **directly from the browser**. Direct calls
travel from the visitor's browser to that origin, never through Host An App,
so they are **not subject to the egress caps below** (no 5 s timeout, no
256 KB cap, no public-IP check, no secret support). Use them when a response
is large or slow and needs no secret; use `hostan.fetch` when you need a
secret, a guaranteed server-side call, or the platform's SSRF guard. Both
paths are legitimate — choose deliberately rather than assuming the browser
is closed.

Define the shape with `manage_backend update` (`fetches: [{name, method,
url, headers?}]`, alone per update — never combined with `ddl`, `actions`,
or `realtime`):
`name` is lowercase letters/digits/underscores, `method` is `GET` or
`POST`, `url` is the fixed `https` destination (no credentials in the
URL; the host must resolve to public IPs only — private, loopback,
link-local, and metadata addresses are refused), and `headers` lines may
reference secrets as `${NAME}` (e.g. `"Authorization": "Bearer
${API_KEY}"`). A declared `Content-Type` must be a JSON media type or
absent — the body the platform sends is always JSON, so declaring a form
type only makes the upstream misparse it. Resubmit the whole set without a
name to remove it.

```js
// pages call fetches by name — never a URL, never a secret
const res = await hostan.fetch('summarize', {body: {text}});
res.status; // upstream 2xx status
res.data;   // parsed JSON for JSON answers, text otherwise
```

Contract:

- Secret VALUES never travel through MCP or appear in `get` (names and
  dates only). The human sets them in the dashboard (project →
  Database tab → Secrets); headless CI may use the secrets REST endpoints. A
  page calling a fetch whose secret is unset gets a `503` naming the
  secret — tell the human which name to set, never invent a value. The
  `503` and `manage_backend get` both carry the dashboard URL of that
  exact field, and `get` renders readiness (`API_KEY — set <time>`,
  `X_KEY — NOT SET — required by fetch "y"`), so the handshake is a
  link plus a re-check rather than a blind instruction. The link is
  exact for a human who is already signed in to the dashboard; a
  signed-out one lands on the sign-in gate first (the dashboard's
  sign-in carries no return path), so name the project and the Database
  tab alongside the link.
- The fixed URL is the whole destination: callers add small `query`
  keys (≤16, ≤512 chars each) and, for `POST`, one JSON `body` (≤64
  KB). A `POST` fetch always carries a JSON body — the platform sets
  `Content-Type: application/json` — and declared headers are applied
  after that, so a fetch that declares its own `Content-Type` must
  declare a JSON media type (parameters allowed); declaring anything
  else is refused at define time. The refusal names the rule so the fix
  is to remove the header or declare JSON. The simplest correct thing
  is to declare no `Content-Type` at all. Two header names that differ
  only in case are the same header (net/http canonicalizes them) and are
  refused, so the header actually sent is never a coin flip. No
  redirects are followed; the exchange times out after 5 s and
  answers over 256 KB are refused.
- **Every egress failure is a structured `503` envelope** — read the
  cause from one call instead of bisecting with `curl`. (Not `502`: the
  production edge replaces origin `502` bodies with its own error page,
  so a `502` envelope never reaches you — #504. `503` survives it.)
  Redirects are not followed (a `3xx` is `non_2xx`).

  ```json
  {
    "error": "fetch \"scrape\" upstream answered 400",
    "reason": "non_2xx",
    "fetch": "scrape",
    "upstream_status": 400,
    "upstream_body_excerpt": "{\"message\":\"No URL provided\"}",
    "bytes_received": 96,
    "elapsed_ms": 182,
    "limit": { "bytes": 262144, "timeout_ms": 5000 }
  }
  ```

  `reason` is one of `non_2xx` (upstream answered non-2xx;
  `upstream_status` + a ≤1 KB `upstream_body_excerpt` accompany it),
  `timeout` (the whole exchange exceeded `limit.timeout_ms`),
  `too_large` (the answer exceeded `limit.bytes`), `dns` (the
  destination did not resolve), `refused` (connection refused, or the
  destination is not a public address), `tls` (handshake/certificate
  failure), or `unreachable` (none of the above — including a
  destination that could not be turned into a request). `upstream_status`
  and `upstream_body_excerpt` appear only for `non_2xx`; the excerpt
  also rides its legacy `preview` key.
- The same envelope rides `hostan.fetch` rejections: the thrown
  `Error` carries `message`, `status`, `reason`, `upstreamStatus`,
  `body`, `elapsedMs`, `bytesReceived`, and `limit`, so page code can
  branch on the cause — `catch (e) { if (e.reason === "timeout") … }`.
  No field ever contains a secret.
- Fetches are PUBLIC like actions — they are the author's declared
  surface. Never put a secret in a URL, a query key, or a page; secrets
  travel in server-side headers only.

## Troubleshooting egress failures

Start from `reason` in the envelope above — it makes the cause
machine-readable, so one call replaces a bisect loop. The shapes you will
actually meet:

| What you observe | `reason` | What it means / what to do |
| --- | --- | --- |
| `503`, fast | `non_2xx` | The upstream answered — read `upstream_status` and `upstream_body_excerpt`; they usually name the problem outright. A `400`/`415` is often a payload mismatch (e.g. a `Content-Type` that does not describe the JSON body). |
| `503` at ~5 s | `timeout` | The exchange exceeded `limit.timeout_ms`. The upstream is slow, or the response is big and slow. Narrow it with `query`, or call the endpoint from the browser instead (browser-direct calls have no timeout — see "Server-side fetches"). |
| `503`, fast, big page | `too_large` | Over `limit.bytes` (256 KB). Same remedy: browser-direct, or ask the upstream for less. |
| `503`, no upstream contact | `dns` / `refused` | The host did not resolve, or it resolved to a non-public address (the SSRF rule), or the connection was refused. `dns`/`refused` never reach the upstream, so nothing was sent. |
| `503` mentioning certificates | `tls` | Handshake or certificate verification failed. |
| `503` naming a secret | — | The `${NAME}` placeholder has no value. Tell the human which name to set; the error carries a dashboard link to that field. Never invent a value. |
| `404` naming a fetch | — | The fetch is not declared on this project's backend (or the project has none). `manage_backend get` lists them. |
| `400` | — | The caller shaped the invocation wrongly (too many query keys, oversized body, body on a `GET`). The definition is fine. |

## Debugging a published page

**There is no page console.** A published page has no `console.log` capture,
no exception stream, and no failed-request log — Host An App never sees what
happens in the visitor's browser. So do not wait for an error report that
will never arrive; build the evidence into the page and reproduce it from
the outside.

- **Probe the backend directly.** As the OWNER, prefer `manage_backend test_fetch` / `test_action` (or the REST mirrors above): they invoke exactly what the page invokes — same secrets, same caps, same envelopes — without a published page, and `test_action`'s write warning is the same: **writes are real**. On any already-published project you can also probe the public runtime endpoints:

  ```bash
  curl -s -X POST "https://sites.hostan.app/<slug>/api/fetch" \
    -H 'Content-Type: application/json' \
    -d '{"fetch":"summarize","body":{"text":"…"}}'
  ```

  Actions have the same shape at `/api/op` (`{"action": …, "params": …}`).
  Both are public surfaces — treat a write action as a write there too.
- **Render errors into the page.** Since nothing else captures them, have
  the app's entry point `catch`, write the message into a visible element,
  and support a `?debug=1` flag that shows the envelope fields (`e.reason`,
  `e.upstreamStatus`, `e.body`). Then "it doesn't work" arrives with a
  cause attached instead of a blank screen.
- **Preview is not a test — a run is.** A revision preview (`create_revision` →
  `preview_url`) renders layout, styling and copy and deliberately carries
  **no runtime**: `hostan.db`, `hostan.fetch`, `hostan.auth`, `hostan.ws`
  and `hostan.payments` are all absent, and neither CSP nor CORS is
  exercised. A preview that looks perfect proves the page *renders*, never
  that it *works*. To measure behavior before promoting, start a
  `preview_run` (previous-but-one section): a real browser, a scratch
  tier, real egress, collected evidence. Verify rendering with the
  preview, behavior with the run.
- **Re-read the source, not the artifact.** `manage_frontend content` gives
  the exact stored source; the live page has the platform injections
  (runtime bootstrap, visit beacon) baked in and is not what you wrote.

## Error codes

| Status | Where | Means |
| --- | --- | --- |
| `400` | anywhere | Validation failed: bad slug, empty content, wrong type, malformed JSON body (the body must be exactly one JSON value — trailing bytes are a `400`), unknown action params, or a `hostan.fetch` invocation shaped wrongly. The body names the problem. |
| `401` | project/frontend/backend/payments | Missing or invalid workspace credential; or an action that binds `:hostan_user_id`/`:hostan_user_email` called anonymously (that is the action's opt-in to sign-in). |
| `403` | workspaces, safety | Not a member of the workspace (uniform, never an existence leak); a plan/project cap; or a page disabled by the safety system. |
| `404` | slugs, runtime | Unknown slug, unknown fetch/action, or a page with no backend resource where one was expected. `404` is also what a *deleted* or expired page returns. |
| `409` | revisions, workspace delete | Optimistic-concurrency conflict (a stale `base_revision_id`/`expected_active_revision_id`; the body carries the current pointer and hash — re-read, re-apply), or a workspace delete whose subscription cancellation could not be scheduled. |
| `410` | revisions | The revision exists (it is still listed) but its content was pruned by the retention policy — the artifact is gone, so `content`, `search`, `diff`, `promote`, and `rollback` refuse it. Base new work on the active revision, or pick a revision still inside the retained window (the metadata view marks the expired ones). Re-submitting the pruned revision's exact content is *not* an error: the write revives that revision's artifact (the unique content index forbids a second row for it), so a retry is safe. |
| `413` | deploy, runtime | Body over the cap: 1 MB for a deploy, 768 KB for runtime query/fetch bodies, or a project database that has hit its 10 MB invariant (`SQLITE_FULL`). |
| `429` | anywhere | Rate limited (30 deploys/min per IP, plus the per-slug runtime bucket). `Retry-After` says how long. |
| `502` | billing; the edge itself | The payment provider refused (billing checkout/portal — the body names its reason), or the edge's own bad-gateway body when the origin is down. Never an egress status: the envelope once rode `502`, but the production edge replaces origin `502` bodies with its own error page, so egress rides `503` (#504). |
| `503` | deploy, fetch, secrets, payments | Platform at capacity (retry later); an egress failure (the structured envelope — read `reason`, see the troubleshooting table; distinct from the unset-secret answer below, which is the plain `{"error"}` shape); a fetch's `${NAME}` secret is not configured (the body names it and links to the dashboard field); or payments are not configured for the platform. |

## Real-time rooms (hostan.ws)

A backend with `realtime: true` (opt-in at create, or flip later with
`PATCH /api/v1/projects/{id}/backend` `{"realtime": true}` / `manage_backend
update` with `realtime`) exposes one project-scoped WebSocket endpoint and
an injected `hostan.ws` runtime. Set it only if the app actually uses
`hostan.ws`.

- Endpoint: `wss://<host>/<frontend-slug>/ws?room=<room-id>` — never
  construct this URL yourself; use the runtime.
- The runtime is injected into frontends PUBLISHED AFTER the capability
  was enabled (same publish-time contract as `hostan.db`); republish a
  frontend to give an older page the runtime.
- Enable alone per update: `realtime` is never combined with `ddl` or
  `actions`.

```js
const room = hostan.ws.connect('game-abc');
room.on('open', () => reloadStateFromDb());
room.on('message', (env) => {       // env = {v, type, data, sender, room, ts}
  if (env.type === 'move') renderMove(env.data);
});
room.on('presence', (p) => updateCount(p.members)); // {members: N}
room.on('close',   () => showReconnecting());
room.send('move', {row: 1, column: 2});
room.close();
```

Contract:

- Ephemeral, single-process relay. NO history, NO replay, NO delivery
  receipts, NO offline inbox, NO cross-backend rooms. A restart ends all
  sockets; the runtime reconnects with capped backoff + jitter, but
  messages sent while offline are gone — reload authoritative state from
  `hostan.db` on `open`.
- Client frames are JSON text: `{v: 1, type, data, client_message_id?}`.
  `type` is 1–64 chars `[A-Za-z0-9._~-]`; binary frames are rejected;
  frames over 16 KiB are rejected; ~10 msg/s per socket is enforced.
- The server stamps trusted metadata before fanout: `sender` (opaque
  connection id), `room`, `ts`. Platform events (`hostan:presence`,
  `hostan:join`, `hostan:leave`) cannot be forged by clients.
- Same backend + same room fan out to everyone except the sender;
  different rooms/backends are strictly isolated.
- Realtime is PUBLIC — it is not authentication. Rooms are unauthenticated
  shared state; enforce identity in your actions (`:hostan_user_id`) and
  treat broadcast data as tamperable. Casual collaboration and live UI,
  not authoritative multiplayer or anti-cheat.
- Limits (per backend unless noted): 100 sockets, 100 active rooms,
  room ids 1–64 chars `[A-Za-z0-9._~-]`, 20 sockets per client IP,
  32-message outbound queue (slow receivers are dropped, 1013).

Pattern for anything that must survive a refresh: write via
`hostan.db.op`, broadcast the small event via `hostan.ws`, and reload
from the database on `open`/reconnect.

## Managed payments (hostan.payments)

Project frontends can collect payments through the platform's managed
Stripe integration (no per-app keys, no webhook code, no amounts in the
browser). The platform hosts checkout, verifies the webhook, and writes
the entitlement; your app decides what a paid visitor may do.

**Each workspace connects its own Stripe account over Stripe Connect.**
A workspace's owner or admin opens the workspace page in the dashboard
(`auth.hostan.app/dashboard?ws={id}`), Payments section, and clicks
**Connect Stripe** — an OAuth redirect to Stripe, back to
`<auth origin>/auth/connect/stripe/callback`. Every project in the
workspace then charges directly on the connected account: the platform
names that account per request and never stores a tenant secret, money
never passes through Host An App, and refunds, disputes, and negative
balances are the connected account's. This needs
`HOSTAN_STRIPE_CONNECT_CLIENT_ID` configured (off when unset: no Connect
button; a workspace that never connected then fails closed, while an
already-connected workspace keeps charging until it disconnects). The
client id's mode must match `HOSTAN_STRIPE_SECRET_KEY`'s mode — the OAuth
exchange authenticates with the secret key, so a live-key deployment cannot
complete test-mode onboarding at all (it fails late, after the workspace's
Connect click already succeeded at Stripe). One deployment exercises one
mode: rehearse connect → sell → refund → disconnect on a staging deployment
with a test key and a test `ca_…`; `hostan config doctor` prints the key's
mode when Connect is configured.

**A project whose workspace has no connected account fails closed.**
Checkout answers `503` with `payments are not configured`; the platform's
own Stripe account is deliberately never a project's merchant. Disconnect
(made from the same section) revokes the connection: existing price lists
survive, but checkout closes until an account is connected again.
Reconnecting — the same account or a different one — needs no catalog work:
prices are platform-side catalog rows and the checkout line item is built
from them on whichever account is connected, so the catalog is portable.
Deleting the workspace also deauthorizes the account at the provider
before the connection row goes, so the platform's authorization does not
outlive the workspace.

**Two webhook rails, two secrets.** App-payment deliveries land on
`POST /api/v1/payments/webhook`, verified with the CONNECT endpoint's
signing secret; platform-billing deliveries land on
`POST /api/v1/billing/webhook`, verified with the platform account
endpoint's secret. Neither rail inspects a delivery to find out which it
is. The Connect secret
(`HOSTAN_STRIPE_CONNECT_WEBHOOK_SECRET`) gates BOTH directions: while it
is unset, app-payment deliveries answer `503` and visitor checkout
refuses with `503 payments are not configured` — a workspace showing a
connected account is not enough on its own, because a charge nobody
could verify into an entitlement must not be taken.

Owner setup (needs the workspace credential; a backend is only required
for the `hostan_payments` mirror table — the catalog and checkout work
without one):

- `POST /api/v1/projects/{id}/payments/catalog` — define a price
  `{product_name, description?, label, amount, currency, recurring?}`:
  `amount` is an integer in minor units (cents for `usd`), `currency` is
  ISO 4217. The same surface exists over MCP as `manage_payments`
  (actions: `add_price`, `catalog`, `deactivate_price`, `sessions`).
- `GET /api/v1/projects/{id}/payments/catalog` — list products/prices;
  `DELETE …/catalog/{price_id}` stops a price being purchasable.
- `GET /api/v1/projects/{id}/payments/sessions` — recent checkouts with
  their verdicts (`open`/`paid`/`canceled`/`failed`/`refunded`).

Visitor surface (per frontend slug, tokenless, wrapped by the injected
`hostan.payments` runtime). The mounts are literal and live at the slug
ROOT, not under `/api`: `checkout()` POSTs `/<slug>/payments/checkout`
and `status()` GETs `/<slug>/payments/status` — the same root-prefixed
pathing as `hostan.auth`. The database and egress verbs live under
`/<slug>/api` (`/<slug>/api/op`, `/<slug>/api/fetch`).

```js
hostan.payments.checkout("price_…"); // redirects into hosted checkout
const pays = await hostan.payments.status();
// [{price, status: "paid", amount, currency, user_id?, email?, matched_by, created_at}]
```

- The visitor picks a price id, NEVER an amount — amounts live in the
  platform catalog; a page that computes money is wrong.
- A price created with `recurring: true` bills as a monthly subscription
  (Stripe `mode=subscription`, interval `month`); `recurring` omitted or
  false is a one-time payment. Either way the entitlement is granted on
  the first verified success webhook, and the platform enforces the
  provider lifecycle both ways:
  - a FULL refund (`charge.refunded` with `refunded: true`) REVOKES the
    entitlement — `status()` stops reporting the payment (partial
    refunds are recorded in the ledger but do not revoke);
  - subscription termination (visitor cancellation, or dunning ending in
    `canceled`/`unpaid`) REVOKES it too;
  - a failed renewal is dunning, not revocation: access continues while
    Stripe retries (`invoice.payment_failed`, `past_due`); the revocation
    happens only when Stripe ends the subscription.
  The verdicts live on the sessions (`open`/`paid`/`refunded`/
  `canceled`/`failed`); `status()` returns ONLY currently-entitled
  (`paid`) entries — a revoked payment disappears from it, so a
  `len(payments) > 0` gate can never over-grant. Every delivery is
  deduplicated by event id, and out-of-order deliveries land on the same
  final state as in-order ones.
- `status()` matching: a signed-in visitor matches their platform user
  id, plus any paid session whose Stripe-collected email equals their
  account email; an ANONYMOUS visitor is recognized only through this
  browser's checkout cookie (30 days) — a payment made without signing
  in is invisible from another browser or device, so gate valuable
  access behind `hostan.auth.login()` before checkout. Entitlement reads
  are authoritative platform state — never trust the success redirect
  (it is UX, not proof). The redirect lands on a platform-owned page at
  `/<slug>/payments/success?session=…` (canceled for abandonment) that
  frames the outcome without asserting it — display only, never a grant
  — so your page should re-check `status()` on load for the real
  verdict.
- Every entry carries `matched_by`: `"user_id"` (the platform identity
  — the only match that asserts identity), `"email"` (the
  Stripe-collected address), or `"cookie"` (this browser's checkout
  cookie). When a session matches more than one, the strongest wins
  (user_id > email > cookie). The email match is a deliberate
  convenience — a paid anonymous checkout is claimed by a later sign-in
  with the same address — and email providers recycle addresses, so
  **two different platform accounts that share a provider-verified
  email both see the entitlement**. An app that trusts only platform
  identity should require `matched_by === "user_id"`; authorization
  keyed on `:hostan_user_id` is unaffected either way.
- A reserved `hostan_payments` table is materialized into your database
  for JOINs/composability (`session_id` unique, `status`, `user_id`,
  `email`, `amount`, `currency`, …). It is a derived view: treat
  `status()` as the hardened gate; the platform can re-derive the table.
- The `hostan_` table prefix is reserved platform-wide: your DDL may not
  create, alter into, or index objects named `hostan_*` (tables, views and
  indexes; quoted and schema-qualified spellings included).
- The runtime is injected into frontends PUBLISHED AFTER the project got
  a backend (same per-revision contract as `hostan.db`); where the
  platform has payments unconfigured, or the project's workspace has no
  connected account, checkout answers `503`.
- A checkout can also fail with a `400` naming a missing/ineligible
  product tax code (`line_items[0]`): that is the platform's account
  policy, not something your page controls — your app should surface the
  error and not treat it as a payment failure or a bug in the page.
  Whether a tax code is present is operator-side configuration, resolved
  per checkout server-side; there is nothing to set in your deployment.

## Platform plans (what you pay Host An App)

Separate from app payments: this is the workspace's own subscription.

- `free` — **1 project per ACCOUNT** (not per workspace), permanent, no
  card.
- `hobby` — **€5.00/month, tax included**, **5 projects** in one workspace.
- `pro` — **€15.00/month, tax included**, **25 projects** in one workspace.
- `unlimited` — **€25.00/month, tax included**, no project ceiling.

  Every price is fixed by the platform catalogue (never computed in a page)
  and billed as a recurring monthly subscription through the platform's own
  Stripe account (`HOSTAN_STRIPE_SECRET_KEY`) — NOT a connected account:
  this is the platform charging for itself, and it is the one flow the
  platform's key is still the merchant of record for. A plan is per
  WORKSPACE: one subscription pays for one workspace's projects, which is
  why the free allowance — the only account-scoped one — is the number a
  second FREE workspace does not refresh, and why it counts only free
  workspaces' projects: a paid workspace's projects are covered by its own
  tier, never by the allowance. The platform free bucket (`ws_free`) is
  excluded from that count by identity: its quick shares are
  platform-generated content, never a tenant's farming, so they consume
  nobody's allowance.

The upgrade path is `POST /api/v1/workspaces/{id}/billing/checkout`
(owner floor) with an optional `{"plan": …}` body → a hosted checkout URL
for a human to open; the dashboard's workspace page
(`auth.hostan.app/dashboard?ws={id}`) renders the offer as a card per
catalogue tier — the held tier carded as "Current plan" with no control,
the catalogue's featured tier flagged "Most popular", one buy button per
card the workspace is not on.
A workspace that already pays changes tiers with
`POST /api/v1/workspaces/{id}/billing/change` (owner floor, body
`{"plan": …}`) → `204`, which re-prices the existing subscription
(prorated) instead of opening a second one; the same grid carries those
buttons on a paid workspace.
The way out is
`POST /api/v1/workspaces/{id}/billing/portal` (owner floor) → the
provider's own billing portal, where the owner changes the payment method
or cancels; the dashboard shows it as **Manage billing** on a paid card.
Cancellation is never a platform route: the subscription ends at the
provider and returns as a webhook verdict. Three rules
matter when you automate this:

- **You cannot assert a paid plan.** `PATCH /api/v1/workspaces/{id}`
  `{"plan":"pro"}` (and `manage_workspace update {plan}`) is a `403`
  naming the price. Only a completed subscription moves a workspace onto a
  paid tier; a subscription that lapses (canceled, unpaid, incomplete)
  returns it to `free` — its projects are never deleted, but creating more
  than the free allowance then fails until it subscribes again.
- **The redirect is not proof.** Checkout returns the human to the
dashboard; the plan moves when the provider confirms the subscription
  server-side. Read it back with `GET /api/v1/workspaces/{id}` a few
  seconds later. `409` means the workspace already holds a live
  subscription — a tier CHANGE goes through `…/billing/change`, not a
  second checkout; `503` means the platform has no payment credential or
  webhook signing secret configured.
- **To leave a paid plan, open the portal** (`…/billing/portal`) and
  cancel there. Asserting `{"plan":"free"}` on `PATCH` is refused (`403`)
  while a live subscription stands, because downgrading the row underneath
  a subscription the provider still bills would let the next renewal
  silently re-grant a plan the workspace was told it had lost. Once the
  provider reports the cancellation the workspace is `free` and its
  projects are never deleted.

## MCP OAuth (connecting consumer clients)

Consumer MCP clients — ChatGPT custom connectors, Claude, Perplexity —
support OAuth per the MCP authorization spec, not pasted credentials.
The platform runs a small OAuth 2.1 authorization server for exactly
that (enabled when the operator turns it on):

- **Discovery**: the MCP resource's Protected Resource Metadata
  (RFC 9728) lives on the resource origin —
  `https://api.hostan.app/.well-known/oauth-protected-resource` (plus the
  path-inserted `…/mcp` form and the `/mcp/.well-known/…` probe some
  clients try) — and names `https://auth.hostan.app` as its authorization
  server. That server's RFC 8414 metadata is on the ISSUER origin,
  `https://auth.hostan.app/.well-known/oauth-authorization-server`
  (RFC 8414 §3.1). A 401 from `/mcp` carries
  `WWW-Authenticate: Bearer resource_metadata=…` pointing at the PRM.
- **Registration**: dynamic (RFC 7591) —
  `POST https://auth.hostan.app/auth/mcp/register` with `redirect_uris`
  (exact-match https; http only for loopback). Public
  clients only: no secrets, PKCE S256 mandatory (plain rejected).
- **Flow**: standard authorization-code + PKCE — browser hits
  `https://auth.hostan.app/auth/mcp/authorize` and signs in with the
  platform's identity provider
  (pinned by the client's `provider` query, the operator default, the
  only registered provider — or a picker page when several are
  registered and none is named; an unresolvable name answers an
  "unavailable" page), then — for third-party clients — **approves the
  consent page** (#337: it names
  the client, the requested scope, and the workspaces the client could
  act in, with the user's role in each; deny redirects back with
  `error=access_denied`; the platform's first-party dashboard client is
  the one exception and skips consent, since its redirect URI is the
  platform's own callback), then is bounced back with a single-use code;
  `POST /auth/mcp/token` exchanges it (and later rotates `refresh_token`
  grants; `POST /auth/mcp/revoke` kills a token). Scopes: a single
  `hostan:full` — the token carries the full authority of every workspace
  its user belongs to (consent authorizes the client to act as the user
  across all their live memberships; the per-request target decides
  authority — no workspace selection is recorded).
- **The gate is workspace membership**: a sign-in that resolves to a user
  holding **no** active membership provisions their personal workspace
  (they become its `owner`) before the gate is read, so the gate admits
  them immediately and an account whose memberships were all removed is
  re-provisioned rather than refused — removing every membership is not a
  lockout. The not-authorized page is a backstop only an unwired
  provisioner reaches. The token identifies the **user**, and authority
  is that user's **live workspace memberships**, read per request — one
  connection works across every workspace the user belongs to, and
  revoking a membership takes effect on the next request with no re-auth
  (tokens minted before a workspace existed gain access to it
  automatically). After connecting, use the same MCP tools exactly as
  with a bearer credential.
- The anonymous quick-share funnel still works for clients that ignore
  OAuth (deploy_asset needs no credential); unless the operator sets
  strict mode (`HOSTAN_MCP_OAUTH_REQUIRE`), an absent credential on
  `/mcp` still passes through as anonymous (no 401 challenge) and the
  manage_* tools keep their self-correctable hints.

## MCP alternative

If the harness speaks MCP (Claude Code, Cursor, VS Code, OpenCode), point
it at `https://api.hostan.app/mcp`. `deploy_asset` publishes quick shares
(same fields as `POST /api/v1/deploy`), the `manage_*` tools build
projects, and `read_docs` returns this file in-band. Agent conveniences
worth knowing: `manage_frontend` action `content` returns the exact
stored source to start your next revision from (the live page has the
platform injections baked in — never round-trip it through update);
action `search` locates a line without pulling the file (each match
reports its 1-based line/column **and** its exact `byte_start`/`byte_end`;
a line longer than the readable limit comes back as a bounded excerpt
around the match, and the whole result is bounded by `max_bytes`), and
`create_revision`/`update` take `edits` (`{old,new}`, `{line_start,
line_end,new}`, or `{start_byte,end_byte,new}`) instead of a full `content`
— the response carries the change as a bounded diff. Prefer byte ranges on
minified pages: a single-line 100 KB page makes every match share line 1,
so line ranges address nothing. Never strip the newlines from a page you
may later edit — keeping the source multi-line keeps line addressing
usable. And `manage_backend` `get` reports
each ledger migration's and each action's SQL verbatim, so the next
additive migration or action batch is composed from what is actually
stored, and `get` ends with advisory **hints** about dead or stale surface
— a table no action references, a fetch no published page calls, realtime
enabled after the newest publish (the hostan.ws runtime is injected at
publish time, so that hint names the required republish), and frontends
whose baked page runtime predates the platform's current generation (the
hostan.db/fetch/auth/payments runtime is likewise baked at publish time;
after a platform upgrade that hint names the stale pages and the one
`manage_frontend update` that refreshes them) — plus any ledger drift
warning. Hints are advisory (dead surface, not a fault); warnings say
something is genuinely wrong. Every `content` read returns the source's
`content_sha256` (the same fingerprint a write returns), and
`known_content_sha256` turns the read into a conditional one: pass the hash
you hold and a match answers `unchanged` with no body — so an idempotent
edit can assert "only the version I read" and a publish can be verified
without a read-back. For
durable frontends, rest on the revision
loop: `get` (record `active_revision.id`) → `content`/`content?revision_id=`
or `search`/`diff` → `create_revision` (with `content` or `edits`) →
inspect the `preview_url` → `promote` (with the
current `expected_active_revision_id`) → `rollback` on a post-release
regression. REST and MCP are the same pipeline — use whichever fits.

## Self-hosting operations

When you run Host An App yourself (rather than calling the hosted service), the
operator CLI on the `hostan` binary answers the two questions the environment
can raise:

- `hostan config doctor` — print the resolved effective configuration (secret
  values redacted, defaults marked) and run every startup check without
  starting the service: the four canonical origins, the shared/dedicated
  identity-origin rules, incomplete provider or Stripe credential pairs,
  whether the control database sits inside the web-served sites root, retired
  variables still set, and malformed duration/integer overrides. Exit `0` with
  warnings, non-zero when a check fails. Read-only: no port, no database, no
  network. Same result as `make config-doctor` from the repository root.
- `hostan set-safety <slug> <active|under_review|disabled> [reason]` — change
  a deployment's safety status straight in the control plane (no endpoint, no
  engine startup), for when a published page must be taken down by hand.

## Minimal agent loop

1. Build the content (one file, under 1 MB).
2. `POST https://api.hostan.app/api/v1/deploy` with
   `{"type": "<html|markdown|mermaid|json|csv>", "content": "…"}`.
3. Read `url` from the `201` response and hand it to the user.
4. On `429`/`503`, wait 60 s and retry. On `400`, fix what the error names.
