host an app
measuring…

API Reference

Interactive reference live from /api/v1/openapi.json, the same spec your agents use for self-discovery. No account, no key.

OpenAPI 3.1 v1.0.0 Download JSON … endpoints

Try it

Pick an endpoint below, copy the curl, or fetch /api/v1/openapi.json from your agent. CORS * is enabled, so it works from any origin.

Servers

Limits & rate limits

All writable surfaces are rate-limited per client IP at the edge. Static content is never rate-limited. Exceeding a limit returns 429 Too Many Requests with a Retry-After: 60 header. Back off and retry rather than hammering.

SurfaceLimit
POST /api/v1/deploy30 requests/min per IP, burst 15
POST /mcp30 requests/min per IP, burst 15
POST /{slug}/api/op120 requests/min per IP (burst 30), plus a per-deployment bucket of 300 requests/min (burst 60), so one busy app can't crowd out the rest; public — invokes an author-defined action
POST /{slug}/api/fetchShares the same buckets; public — invokes an author-defined egress endpoint (5 s timeout, 256 KB response cap, no redirects)
POST /{slug}/api/query · GET /{slug}/api/schemaShares the same buckets; owner-only (Bearer workspace credential)
POST /api/v1/auth/{slug}10 attempts/min per IP, burst 10
POST /api/v1/reports10 reports/min per IP, burst 5
Static contentNo rate limit

Other hard limits

Request bodies are capped at 1 MB at the edge (768 KB for query and fetch calls); larger payloads get 413. Deployments expire after 7 days. Runtime databases (project backends) are capped at 10 MB per file, enforced by SQLite itself: a write that would exceed the cap fails atomically with 413. When the platform is at capacity, deploys are rejected with 503 Service Unavailable and a descriptive reason, distinct from 429 throttling.

Projects

A project is a durable envelope for an app: one identity, one expiry, and everything it owns inside it. Where a quick share (deploy_asset) is a single page that deletes itself after seven days, a project is for apps that must outlive that, or remember things.

The mental model, one line: workspace = account · project = envelope · backend = what the app talks to · frontend = what visitors see.

  • workspace — the account (tenant). Workspaces own projects; your authority is your live memberships in them, read on every request (enumerate with manage_workspace list / GET /api/v1/workspaces). One credential — a workspace key or an OAuth token — manages everything in every workspace it resolves to.
  • project — the envelope. One expiry clock, chosen at create (default: permanent). Expired projects are torn down by the same cascade as an explicit delete, and members added later simply inherit the clock — there is no per-member TTL, ever.
  • backend — the project's server-side tier: one SQLite database, optional and at most one.
  • frontend — a published page (html, markdown, mermaid, json, or csv), rendered by the same pipeline as quick shares.

Build an app in five steps

  1. manage_project create — hold the returned project id; it targets every later call.
  2. manage_backend create — provision the tier from DDL. Optional: static-only projects are first-class and can add a backend later.
  3. manage_frontend create — publish the pages. With a backend present, pages invoke it tokenlessly through the injected hostan.db runtime.
  4. manage_backend update — evolve the schema and define the actions pages may call. Pages pick up changes on their next call, no republish needed.
  5. Inspect and iterate: get/list everywhere, update to rename or republish, delete to tear down — all idempotent, retry freely.

One credential, one seam

Workspaces own projects. A workspace is the tenant; a membership (workspace, user, role) is the authorization grant. Two credential shapes resolve at the same seam and produce the same principal: a workspace key (hstn_…, a machine membership issued by the operator) and an OAuth access token (hoat_…, which identifies the user — authority is that user's live workspace memberships, so one connection manages every workspace they belong to). Neither is a per-project token: there is nothing to store or rotate, and no anonymous project creation. Either always travels as the HTTP bearer credential — Authorization: Bearer … — never as a tool argument, URL parameter, or page content. Configure it once on the hostan entry of your MCP client, and every manage_* call authenticates from that connection:

"hostan": {
  "url": "https://api.hostan.app/mcp",
  "headers": { "Authorization": "Bearer hstn_…" }
}

An invalid credential is rejected at the transport with 401; a missing one leaves the connection anonymous — quick shares only. deploy_asset stays the free funnel: content fields only (content/type/slug/title/one_time/password), bound to the seeded free project.

The tools, at a glance

tool what it does
manage_workspace list and manage your workspaces; member roles
manage_project create, inspect, rename, list, delete the envelope
manage_backend attach and evolve the SQLite tier; define actions
manage_frontend publish, update, and remove the project's pages
preview_run exercise a draft against a scratch runtime; read the evidence

The same surface exists over REST with full parity, under /api/v1/projects/{id}… (project lifecycle, …/frontends, …/backend, …/backend/actions), with the workspace credential as the bearer credential. The reference is published at /api-docs (OpenAPI).

manage_project

  • create {name, description?, workspace?, ttl_seconds?|permanent?} — returns the project (target its id on every later call) and these docs. workspace names the target workspace (its id or slug); it is defaulted when your membership set has exactly one workspace and required when it has several. A workspace you hold no membership on is refused — never a silent create. Omitting both ttl_seconds and permanent means permanent. The response carries host_label and url: the project's frontends are served at https://<host_label>.hostan.app/<slug> — a project-scoped origin shared by all its frontends (#113). The label is derived from the project name and its workspace and is unique platform-wide; URL paths are identical to the shared-sites form.
  • get {project_id} — identity, visit rollup, and the member inventory (each member carries its own durable visits total). Target-authoritative: no workspace argument.
  • update {project_id, name?, description?, allowed_providers?, host_label?} — identity, the per-project sign-in restriction (#275), and the subdomain label (#612). allowed_providers is a list of provider registry names (e.g. ["google", "github"]) the project's frontends may offer at sign-in; a present-but-empty list clears the restriction (every registered provider is allowed). Names must be registered providers — unknown ones are a rejected update. Passing null explicitly also clears the restriction (MCP detects key presence, so JSON null and an empty list are equivalent here; REST treats a present array as the update and null as leave-unchanged). host_label renames the project's subdomain label. A change is a move, not a history: the previous label is freed immediately and links to it stop resolving (no redirect, no retention window), so warn before renaming a link you have shared. The new value must be unclaimed (the unique index is the arbiter — a taken label is a clean conflict) and must not be a reserved deployment slug or a platform subdomain (api, auth, sites, admin, docs, mcp, assets, www); the grammar is the slug family (lowercase letters, digits, hyphens; 3–63 chars). The default label derives from the project name ("Botiga" → botiga). Expiry, workspace, and structure never change here.
  • list {workspace?} — the projects across every workspace your principal belongs to (bounded by your memberships, never beyond them); an optional workspace (id or slug) narrows the listing to one, and a workspace you hold no membership on is refused. Each project carries its owning workspace's slug in workspace.
  • delete {project_id} — cascades to every member (frontends, then the backend) and frees their slugs. Retrying after success is a clean no-op.
manage_project { "action": "create", "name": "Tasks",
                 "description": "Personal task tracker",
                 "ttl_seconds": 604800 }

manage_workspace

Workspaces own projects and carry members. Your authority is your live membership in each, so enumeration shows ONLY your workspaces — never a global list.

  • list — your workspaces: id, slug, display_name, your role (owner > admin > editor > viewer), plan, and project_count.
  • get {workspace} — detail plus the member list (workspace takes the id or the slug).
  • create {display_name, slug?} — a new workspace you own (users only: a machine key cannot found workspaces). The slug derives from the display name when omitted. A fresh workspace starts on the free plan, whose single project is counted per ACCOUNT (see update below) — founding more workspaces does not multiply it.
  • update {workspace, display_name?, plan?} — display name (admin floor) and platform billing plan (owner floor). plan is one of free, hobby, pro, unlimited, and only free can be set here: a paid plan is granted by a subscription, never by a request, so asserting a paid tier is a typed 403 naming the price. Start the subscription with POST /api/v1/workspaces/{id}/billing/checkout (or the upgrade button on the tenant dashboard) and read the plan back afterwards — the plan moves when the payment provider confirms it. A workspace that already pays changes tiers with POST /api/v1/workspaces/{id}/billing/change ({"plan": …}, owner floor, 204, no URL): the existing subscription is re-priced and prorated — an upgrade is charged now, a downgrade is credited against the next renewal — never a second subscription, and the new tier similarly lands when the provider reports it. The dashboard renders one button per other tier on a paid card. Setting free while a live subscription stands is also refused (403), because the subscription is the truth and it ends at the provider — open POST /api/v1/workspaces/{id}/billing/portal (the Manage billing button on the dashboard card) to update the payment method, read invoices, or cancel; the workspace returns to free when the provider reports it, with its projects intact. The plan caps projects. Every PAID tier caps them per workspace (hobby: 5, pro: 25, unlimited: no ceiling), and a create past that ceiling is a typed 403 naming the plan, the ceiling, and the console page where a human picks a bigger tier. The FREE plan's single project is counted per account — across every FREE workspace the owner owns; projects in paid workspaces are covered by their own tier and never consume it — so a second free workspace grants no second free project (that is the anti-farming rule, and founding workspaces never multiplies the free project), while paying for a workspace never spends the allowance. Slugs and owners are not updatable here.
  • delete {workspace} — owner floor, 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 when the paid period closes before anything is destroyed (the row carries the payer linkage — deleting it while the subscription lives would strand an agreement the platform could neither see nor stop), its tenant keys are purged, and the row goes last. A scheduling failure deletes nothing; retry once the provider is reachable or cancel from the billing portal first.
  • members {workspace} — the member list.
  • add_member {workspace, user_id, role} / remove_member {workspace, user_id} / set_role {workspace, user_id, role} — membership management at the admin floor; the roles are the human ladder owner, admin, editor, viewer. A removal takes effect on the member's next request — no token revocation needed. Two refusals (400): the machine role (it marks a workspace key's service principal, never a person), and a service principal as user_id (a key's usr_mch_… id, or usr_platform) — a key's single machine membership is written by credential provisioning, not here.

The same surface exists over REST at /api/v1/workspaces (and …/{id}, …/{id}/members), mirroring these actions.

manage_frontend

Frontends are static deployments owned by the project and sharing its clock. There is no per-frontend TTL and no one_time self-destruction — one_time remains a deploy_asset capability. A frontend can carry an access password (#576): the same visitor gate quick shares use, covering the page surface on every origin that serves it (HTML, raw sources, sub-paths, revision previews) while the runtime API (hostan.db actions) stays tokenless. The gate is page metadata, not content: it survives republishes, changes create no revision, and get/list answer password_protected. Slugs stay global and flat: the project owns the page, it never prefixes the URL.

  • create {project_id, type, content, requested_slug?, title?, password?} → url, api_base, and the deployment id. password sets the access gate at create (hashed server-side, never stored or returned as plaintext). On a slug collision a suffixed variant is chosen automatically. url/api_base advertise the project's subdomain form (https://<host_label>.<sites domain>/<slug>, #283) when the sites domain is configured; sites.hostan.app/<slug> stays valid and remains the storage/edge identity. The page's URL is purged from the edge cache, so a slug redeployed after a delete never serves the previous site's cached bytes.
  • get {project_id, slug|id} — state, revision, the project's expiry, the member's visit total, and — once the frontend is in the revision workflow — active_revision_id (revision_count).
  • set_password {project_id, slug|id, password | clear_password} — install/replace, or lift, the access gate without republishing content. Exactly one intent per call. The edge cache is purged so a publicly cached copy cannot outlive a newly set gate.
  • set_mount {project_id, slug|id, mount: "root"|"slug"} — designate this frontend as the project's root (#612), served at the project subdomain's / (https://<host_label>.<sites domain>/), or return it to path serving at /<slug>. Exactly one frontend per project is root; setting a new root demotes the previous one, and the frontend's own /<slug> URL keeps working either way. The mount is pure routing metadata — no revision, no content change. Root serving 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 at sites.hostan.app/. 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. Deleting the root frontend frees the mount (the project host answers 404 until another frontend is set root). get/list report mount ("slug" or "root").
  • content {project_id, slug|id, revision_id?, known_content_sha256?} — the exact stored source: what to edit and feed back through create_revision (the rendered page carries platform injections; the content read never returns those). With revision_id, the source of one specific revision. An html frontend published before sources were kept falls back to its live index.html. The answer always carries content_sha256 — the same fingerprint a write returns — so a publish can be verified without a read-back and an edit can assert "only the version I read": known_content_sha256 is a conditional read, and when it still matches, the answer is {unchanged: true} with no body — the cheapest "nothing changed". It works for every frontend, versioned or not (an unversioned frontend's fingerprint is derived from the bytes the read returns; a stored revision's source stays authoritative when one exists).
  • search {project_id, slug|id, query, regex?, ignore_case?, context_lines?, context_bytes?, max_bytes?, limit?, revision_id?} — match the stored source server-side and return only the matching lines (with context) and their 1-based line numbers, instead of the whole file. query is a literal substring, or an RE2 pattern with regex. Every match also reports its exact byte range (byte_start/byte_end), and a line longer than the readable limit comes back as a bounded excerpt around the match (context_bytes each side) rather than verbatim — so a minified single-line page is readable and cannot blow the caller's token budget. max_bytes caps the total extracted text. The reported line numbers pair directly with an edits line range, and the byte range pairs with a byte-range edit.
  • diff {project_id, slug|id, revision_id?, against_revision_id?, context_lines?} — the bounded change between two revisions (the active revision against its predecessor by default). A revision with no predecessor answers with a note, not a whole-file addition.
  • list {project_id} — the project's frontends.
  • update {project_id, slug|id, content|edits, title?} — the LEGACY immediate publish: creates a revision and promotes it unconditionally (last-write-wins) via the same atomic swap. The URL's edge-cache entry is purged, so visitors see the change immediately. Prefer the revision loop below for agent-managed apps — update puts content on production with this call. Provide either content or edits; the response carries the change as a bounded diff.
  • create_revision {project_id, slug|id, base_revision_id, content|edits, title?, message?} — render + validate + publish an IMMUTABLE draft and return {revision, preview_url, diff}; the canonical page is untouched. Provide either content or edits. base_revision_id must equal the current active revision (a stale one is a structured conflict). Same-content writes deduplicate to the original revision (idempotent retries). A preview is static-only: it renders layout/styling/copy with no hostan.db/auth/payments runtime, so inspecting a draft can never mutate production data — promoting re-injects the runtime per the project's current capabilities.
  • list_revisions {project_id, slug|id, limit?} — revision metadata only (never bodies), newest first, with the current active_revision_id.
  • promote {project_id, slug|id, revision_id, expected_active_revision_id, message?} — the ONLY way the revision workflow changes production: re-renders the stored source through the production pipeline and publishes it. expected_active_revision_id is the optimistic-concurrency check — a stale one is refused (409-style conflict) so concurrent editors can never silently overwrite each other. See the preview first.
  • rollback {project_id, slug|id, revision_id (a formerly promoted one), expected_active_revision_id, message?} — restore a historic revision to the canonical slug; the release event records the rollback kind and the displaced revision becomes rolled_back.
  • delete {project_id, slug|id} — idempotent removal; the slug is freed, and a later redeploy of it under another project starts from zero. The URL's edge-cache entry is purged, so the page stops being served at the edge immediately instead of until the cache TTL.

A frontend enters the revision workflow lazily: the first revision-surface call records a baseline revision mirroring its already-live source and claims the release pointer, so get/content report revision_id and content_sha256 from then on. Revision preview URLs look like https://sites.hostan.app/_preview/<deployment-id>/<revision-id>/ and are immutable (aggressively cacheable) but unlisted.

A preview is static. It renders layout, styling and copy and 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 prove works — actions reading and writing rows, fetches surviving real egress, no console errors — start a preview run (preview_run, below): the draft executes against a throwaway SQLite tier replayed fresh from the backend's migration ledger, so production provably keeps its data while the run still measures real behavior. The no-runtime preview stays a deliberate safety property — a preview that could run actions against production state would not be a preview.

The edits alternative exists so a small change costs a small request. Each edit uses exactly one of three addressing modes: exact-match ({old, new}; old must occur exactly once unless replace_all), a 1-based inclusive line range ({line_start, line_end, new}; line_end = line_start-1 inserts before line_start), or an exact byte range ({start_byte, end_byte, new}; end_byte is exclusive, so end_byte = start_byte inserts). Byte ranges are the addressing that works on a minified page, where the whole document is one line and every match shares line 1 — pair them with a search result's byte_start/byte_end. Edits apply in order against the base revision's stored source and the whole call fails if any edit does not match — so the loop is search to find the region, create_revision with edits, inspect the preview_url, promote. edits suits the text-like types (html, markdown, mermaid); for json/csv a full content write is usually clearer.

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 past-window revision's row still exists, but the list_revisions text line omits the markers — content_expired: true, no preview_url, and a pruned_at stamp — which the revision metadata (REST JSON, GET /api/v1/projects/{id}/frontends/{target}/revisions) carries. content, search, diff, promote, or rollback on it are refused (410-style). Re-submitting a pruned revision's exact content revives it (the unique content index forbids a second row), so a retry is safe (the revived revision is still outside the window, so a later write may prune it again). Do not plan on a long rollback window.

manage_frontend { "action": "create", "project_id": "0193…",
                  "type": "html", "content": "<h1>Hi</h1>",
                  "requested_slug": "tasks-ui" }

manage_backend

The backend is the project's single server-side tier: one SQLite database, name default, provisioned into a private runtime directory keyed by an immutable id (never web-served) and hard-capped in size — a write past the cap fails atomically with 413. It inherits the project's clock: when the project expires or is deleted, the tier goes with it and frontends keep serving while their action calls answer 404, until a new backend exists and pages are republished.

  • create {project_id, ddl, realtime?} — provisions the database from a DDL script: CREATE TABLE / CREATE INDEX / CREATE VIEW plus optional seed INSERTs (additive only; at least one table; CREATE TABLE … AS SELECT is refused — declare the table's shape instead of inferring it from a query). Returns the backend id and the database resource id. Frontends published from then on embed the hostan.db runtime; pages published earlier need one manage_frontend update to receive it. realtime: true additionally opts the tier into the project-scoped WebSocket room relay (see "Real-time rooms").
  • get {project_id} — the spec, the schema derived live from the file (never from stored state), the migration ledger with every entry's SQL (the schema's only stored truth, verbatim — read it to compose the next additive migration), the actions with their SQL, the fetches with their URLs, the secret names (values never appear — the owner sets them in the dashboard), the realtime capability state, the inventory, drift warnings, and advisory hints about dead or stale surface (see under the hood).
  • update {project_id, ddl|actions|fetches|realtime, note?} — one additive migration (CREATE TABLE/INDEX/VIEW, ALTER TABLE … ADD COLUMN, seed INSERTs), one batch of actions, one batch of fetches (see "Server-side fetches"), OR a realtime capability flip (realtime: true|false, alone) per call. Send exactly one of the four. Everything destructive — DROP, DELETE, UPDATE, other ALTER forms, CREATE TABLE … AS SELECT, PRAGMA, ATTACH, VACUUM, EXPLAIN, explicit transactions — is rejected.
  • delete {project_id} — idempotent tier teardown; frontends keep serving, as above.
  • test_fetch {project_id, name, query?, body?} — invoke one declared fetch WITHOUT publishing a page, the define → test → fix loop (#487). The invocation is real: it hits the upstream with the fetch's real secrets (substituted server-side and never echoed), under the same 5 s / 256 KB caps a page's call has. On success the result carries status, response headers, byte count, elapsed, and a bounded body excerpt (≤2 KB, secrets redacted); on failure it carries the same structured envelope a page's 503 renders (reason, upstream_status, body, elapsed_ms, bytes_received, limit), so a draft-time failure predicts the production failure in one call. A fetch whose secret is unset is refused naming the secret and the dashboard field — the same fail-closed state the page surface answers 503. REST mirror: POST /api/v1/projects/{id}/backend/fetches/{name}/test.
  • test_action {project_id, name, params} — execute one declared action against the LIVE backend and get rows (or the RETURNING ids) for a read and the change count for a write. The write is real: there is no scratch tier (the migration ledger is the schema's only truth), so a test INSERT commits exactly like a page's — verify with a read-back, and clean up with owner-only raw SQL (DELETE) if the rows were only for the test. Params are held to the same strict :name contract the public op endpoint enforces — missing or extra params are refused naming the diff, and a declared param_schema types the test exactly as it types a page's call (an omitted optional param binds SQL NULL) — and an action referencing a reserved hostan_* bind is refused with guidance: those values come from a signed-in visitor's session, which an owner-side test does not have. A failed statement returns the verbatim SQL error (the same text the runtime relays). REST mirror: POST /api/v1/projects/{id}/backend/actions/{name}/test.
manage_backend { "action": "create", "project_id": "0193…",
                 "ddl": "CREATE TABLE todos (
                   id INTEGER PRIMARY KEY,
                   title TEXT NOT NULL
                 );" }

Actions — the public surface

Visitors never send SQL. Everything a page can do to the database goes through named actions the author defines, one manage_backend update per batch:

manage_backend { "action": "update",
  "project_id": "…",
  "actions": [
    { "name": "book_slot",
      "sql": "INSERT INTO bookings(slot_id, name, code)
              SELECT :slot_id, :name, substr(hex(randomblob(6)),1,8)
              WHERE NOT EXISTS (SELECT 1 FROM bookings WHERE slot_id = :slot_id)
              RETURNING id, code",
      "note": "one booking per slot; returns the booking id + cancel code" },
    { "name": "cancel_booking",
      "sql": "DELETE FROM bookings WHERE id = :id AND code = :code" }
] }

The action contract:

  • One SELECT/INSERT/UPDATE/DELETE statement per action (WITH allowed). Parameters are :name binds only, and a call may pass only the declared caller-owned names — extras are a 400, and each bind is required unless the action's param_schema marks it optional.
  • An action may declare an optional param_schema — [{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 binds (a mismatch is a 400 naming the diff at define time), 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 the expected type, and an omitted optional param binds SQL NULL. Omitting param_schema on a redefine keeps the action's stored schema — an omission never silently clears a declared contract; a stored schema the new statement no longer satisfies is refused, and an explicit empty array is the one way to drop a schema. manage_backend get renders it as param_schema beside the always- derived params bind list. An action without one behaves exactly as before — every bind required, any JSON scalar accepted.
  • Caller parameters and platform identity are separate scopes. An action may reference two reserved binds the caller can never supply: :hostan_user_id (the signed-in visitor's stable usr_… id) and :hostan_user_email (their verified email, SQL NULL when the provider asserted none). The platform injects both after resolving the visitor's session; a call naming any hostan_* parameter is a 400, and an anonymous call to an action that uses them is a 401. Sessions come from the frontend's /{slug}/auth/login flow and are project-scoped: a token minted for one project never authenticates another's actions. Sign-in is per frontend — each frontend's login mints its own session under its own cookie name, and a frontend honors only its own — so signing in on one frontend does not sign the visitor in on a sibling frontend, even within the same project. The page reads the visitor's identity through the injected hostan.auth runtime (below).
  • An action is a ledger row like a migration: checksummed, versioned by name. Re-sending an unchanged body (same SQL, note and param schema) is a no-op; a changed body appends the next version. Notes are immutable per version — redefinition, never delete, is how an action changes.
  • Retiring an action ({ "name": "…", "retire": true }, retire entries alone per update — never mixed with definitions, and a retire entry carries no sql/param_schema) is the author-facing removal. The ledger is append-only, so retirement is a stamp on the action's CURRENT version: manage_backend get still lists the action flagged retired (every version stays readable), but pages calling it get a 410 Gone — "this existed and was deliberately removed", not a 404 that reads like a typo. Re-retiring is an idempotent no-op, the owner-side test_action refuses a retired action with the same 410 class, and any later definition of the name (even byte-identical) revives it under a new version. Retirement is the only way an action leaves the public surface; a dead action can no longer be invoked and can no longer be mistaken for a bug.
  • Business rules live in the statement and the schema constraints: a page (or a curl script) cannot bypass UNIQUE any more than it can bypass the action list.

Pages invoke actions tokenlessly through the injected runtime:

const res = await hostan.db.op("book_slot", { slot_id: 3, name });
// res.rows[0].code — SELECT/RETURNING results come back as rows

Server-side fetches — the egress surface

Pages cannot call external APIs that need a secret or that lack CORS headers, so the backend declares named egress endpoints and the page invokes them by name — the only server-side HTTP a page may trigger. The deployment CSP declares connect-src 'self' data: blob: https:, so a page may fetch() a public CORS-friendly https endpoint directly from the browser; such a call goes browser→third party, never through the platform, and is not bound by the 5 s timeout or 256 KB cap below. Use hostan.fetch when you need a secret, a server-side call, or the SSRF guard, and a direct browser fetch for large or slow public responses. One manage_backend update per batch, alone per call:

manage_backend { "action": "update",
  "project_id": "…",
  "fetches": [
    { "name": "summarize",
      "method": "POST",
      "url": "https://api.example.com/v1/chat",
      "headers": { "Authorization": "Bearer ${OPENAI_KEY}" } }
] }

The fetch contract:

  • name is lowercase letters/digits/underscores; method is GET or POST; url is the fixed https destination (no credentials; the host must resolve to public IPs only — private, loopback, link-local, and metadata addresses are refused at define time and re-checked at call time). Header values may reference secrets as ${NAME}. A declared Content-Type must be a JSON media type (application/json, parameters allowed) or absent: a POST fetch always carries a JSON body, so declaring anything else misdescribes the request and the upstream answers a 4xx — omit the header and the platform sets it. Two header names differing only in case are the same header and are refused.
  • Secret values never travel through manage_backend and never appear in get (names and set dates only). The project owner sets them in the dashboard (project page, Secrets section). A page calling a fetch whose secret is unset gets a 503 naming the secret — ask the human to set it, never invent a value. Both the 503 and get carry the dashboard URL of that exact field (…/dashboard/projects/{id}?tab=database#secrets), and get renders readiness (API_KEY — set <time> / X_KEY — NOT SET — required by fetch "y"), so handing over a link and re-reading get closes the loop without guessing. The link lands on the field for a human already signed in; a signed-out one hits the sign-in gate first (no return path), so name the project and the Database tab too.
  • Resubmit the whole set without a name to remove it (PUT semantics — no versions, no delete); an empty set switches egress off.
  • Callers add small query keys and, for POST, one JSON body; redirects are never followed, the exchange times out after 5 s, and answers over 256 KB are refused.
  • Every egress failure is a structured 503 envelope (deliberately not the 502 it once rode — the production edge replaces origin 502 responses with its own error page, so a 502 envelope never reaches a caller over the live path): {"error", "reason", "fetch", "upstream_status", "upstream_body_excerpt", "bytes_received", "elapsed_ms", "limit"}. reason is one of non_2xx (the upstream answered non-2xx; its status and a bounded excerpt are included), timeout, too_large, dns, refused, tls, or unreachable — the cause is identifiable in one call, with no need to bisect. The same fields ride the hostan.fetch rejection (e.reason, e.upstreamStatus, e.body, e.elapsedMs, e.bytesReceived, e.limit), and no field ever carries a secret.

Pages invoke fetches tokenlessly through the same injected runtime:

const res = await hostan.fetch("summarize", { body: { text } });
// res.status — upstream 2xx status; res.data — parsed JSON, or text

The same injected runtime exposes the signed-in visitor as hostan.auth (managed identity, above):

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

user() is a synchronous read that starts null: the session cookie is HttpOnly, so the runtime resolves it once per page load from GET /{slug}/auth/status and fires onChange listeners with the result — registering before or after that resolution both work. id is the same stable usr_… value actions receive as :hostan_user_id; the profile fields (email, name, picture, locale) are provider-asserted metadata and may be absent — email is kept only when the provider verified it, picture is the provider's avatar URL and locale the visitor's language tag when it supplies them (render initials when picture is missing). provider names who authenticated the session (e.g. "google"), and expiresAt (RFC3339 UTC) is the session's hard end — pages can warn before it passes; it is the hard absolute bound, so it does not slide even as the session refreshes on each call. Sign-in uses Google or GitHub (operator-configured) and is operator-enabled; where identity is not configured, login() answers 503 and every visitor stays anonymous, so pages should code for both states. A failed resolution (auth disabled, rate limit, offline) settles as anonymous — the runtime never errors on a page that does not use it. Provider tokens and session identifiers never reach the page; login() asks the flow to land back on the current page, and logout() revokes server-side before reloading.

Raw SQL and schema introspection over HTTP (POST /{slug}/api/query, GET /{slug}/api/schema) are owner-only: send the owning workspace credential as Authorization: Bearer hstn_…. The credential must never appear in a page — anything a page carries is public.

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 every hostan.db op and hostan.fetch call with its outcome. Nothing is published and production data is unreachable by construction — the missing half of draft → preview → promote.

  • 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 newest). Answers run_id, run_url, expires_at. Any prior live run of the same revision is stopped first.
  • 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 bounded capture shim. Actions execute against a throwaway SQLite tier replayed fresh from the migration ledger — reads and writes hit scratch rows while production provably keeps its data — and fetches do real egress with the production caps (5 s / 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; verify drafts using those surfaces on a published frontend.
  • result — preview_run {mode: "result", project_id, run_id} (REST: GET …/runs/{rid}): verdict (clean | issues | quiet — nobody opened the URL), state, a summary tally, and the bounded records.
  • stop — preview_run {mode: "stop", project_id, run_id} (REST: DELETE …/runs/{rid}): teardown of both artifacts plus the final report.

Runs live 30 minutes (TTL-swept), 3 active per project, 25 platform-wide (over the cap → a capacity error naming the remedy); terminal rows persist briefly so a late result still reads, and startup reconciliation removes any residue. 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, and 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; the run page rides the same edge gate as its frontend (safety-disabled → dark, password → unlock).

Real-time rooms

A backend created with realtime: true (or flipped on later with manage_backend update {"realtime": true}, alone) exposes one project-scoped WebSocket relay and an injected hostan.ws runtime. Frontends published after the capability was enabled carry the runtime; older pages need one republish. The endpoint rides every frontend of the project, resolved server-side from the slug:

const room = hostan.ws.connect('game-abc');

room.on('open', () => console.log('connected'));
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('join',    (e) => markJoined(e.client_id));
room.on('leave',   (e) => markLeft(e.client_id));
room.on('close',   () => showReconnecting());
room.on('error',   (e) => console.error(e));

room.send('move', { row: 1, column: 2 });
room.close();

The model is an ephemeral, single-process relay: live peers synchronize; nothing is stored. No history, no replay, no delivery receipts, no offline inbox, no cross-backend rooms. A server restart ends all sockets — the runtime reconnects with capped exponential backoff and jitter, but anything broadcast while a peer was away is gone, so an app reloads authoritative state from hostan.db on open.

  • Client frames are JSON text {v: 1, type, data, client_message_id?}; type is 1–64 characters of [A-Za-z0-9._~-]; binary frames are refused; the frame cap is 16 KiB; sustained ~10 messages/second per socket is enforced (violations close with 1008/1013).
  • The server stamps sender (opaque connection id), room, and ts before fanout; clients cannot forge them, nor the hostan:* platform events. The sender does not receive its own message back.
  • Same backend + same room fan out together; rooms and backends are strictly isolated. Room ids are 1–64 characters of [A-Za-z0-9._~-]. Limits per backend: 100 concurrent sockets, 100 active rooms, 20 sockets per client IP, a 32-message outbound queue per client (a slow receiver is dropped with close code 1013 rather than stalling the room), ping every 25 s with a 60 s pong deadline.
  • Realtime is public to the same degree as the backend URL — it is not authentication. Use actions with :hostan_user_id for identity and the database as the source of truth: write via hostan.db.op, broadcast the small event via hostan.ws, reload on open.

Managed payments

A project with a backend can collect one-time payments through the platform's managed Stripe integration. The platform hosts the checkout, verifies the webhook, and owns the verdict; the application decides what a paid visitor may do. No Stripe SDK, no keys in pages, no webhook code — and amounts are never client-supplied: the visitor picks a price id from your catalog, nothing else.

Define prices through the owner surface (workspace credential):

  • manage_payments add_price {product_name, description?, label, amount, currency, recurring?} (MCP) or POST /api/v1/projects/{id}/payments/catalog (REST). amount is an integer in minor units (cents for usd); currency is ISO 4217. The response carries the price_… id pages use.
  • manage_payments catalog / GET …/payments/catalog lists products and prices; deactivate_price / DELETE …/payments/catalog/{price_id} stops a price being purchasable (history keeps its snapshots).
  • manage_payments sessions / GET …/payments/sessions lists recent checkouts with their verdicts — open, paid, canceled, failed, refunded — for support.

In a page (the injected hostan.payments runtime, present in frontends published after the project got its backend — one republish adds it to older pages). The runtime addresses the visitor endpoints under the slug ROOT, like hostan.auth: checkout() POSTs /<slug>/payments/checkout, status() GETS /<slug>/payments/status — never under /<slug>/api/* (only hostan.db.op() lives there):

hostan.payments.checkout('price_…'); // redirect into hosted checkout
const pays = await hostan.payments.status();
// [{price, status: 'paid', amount, currency, user_id?, email?, matched_by, created_at}]
  • status() is authoritative. 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. The success redirect is UX, never proof.
  • Every entry carries matched_by — 'user_id', 'email' or 'cookie' — saying which identifier claimed it (strongest wins when a session matches several: user_id > email > cookie). The email match is the deliberate convenience that lets a later sign-in claim an anonymous checkout, 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'; per-user authorization keyed on :hostan_user_id is unaffected.
  • A price flagged recurring bills as a monthly subscription; the entitlement is granted on the first verified success webhook either way. The platform enforces the provider lifecycle both ways: a FULL refund revokes the entitlement and a terminated subscription (visitor cancellation, or dunning ending in canceled/unpaid) revokes it; status() returns only currently-entitled entries, so a revoked payment disappears from it. A failed renewal is dunning, not revocation — access continues while Stripe retries, and only Stripe ending the subscription revokes. Partial refunds are recorded in the ledger but do not revoke.
  • The platform also materializes a reserved hostan_payments table into the project's database (unique session_id, price_id, price_label, amount, currency, user_id, email, status, created_at) — a derived view for JOINs and composability in your own actions. Treat status() as the hardened gate: the table is re-derivable from the platform's event ledger, which is the source of truth.
  • The hostan_ table prefix is reserved platform-wide; author DDL (provisioning, migrations, actions' target schema) may never create hostan_* tables.
  • Where the platform has payments unconfigured, checkout answers 503 (feature off, fail closed); the catalog surface still works so prices can be defined ahead of the credential.

Under the hood

Optional reading — none of this is needed to build an app.

  • The ledger is the schema's only stored truth. Schemas are introspected live from the database file; there is no second stored schema to drift.
  • Drift self-heals. The DDL and its ledger row cannot commit atomically together, so the only possible lag is schema ahead of ledger (a crash between the two commits). get detects it, reports a schema_ahead_of_ledger warning, and re-sending the same migration heals it idempotently.
  • Migrations serialize. Concurrent migrations to one project are applied one at a time; a loser answers "applied but not recorded" and heals when re-sent.
  • Hints flag dead or stale surface. get ends with advisory hints — a table no action references (so no page can reach it), a declared fetch no published page references, a realtime capability enabled after the newest publish, and frontends whose baked page runtime predates the platform's current generation (the hostan.* bootstrap is injected at publish time, so those two name the required republish). They are deliberately not warnings: a warning means something is genuinely wrong (the ledger drifted), while a hint means something looks unused. Matching is heuristic (word-bounded identifiers for tables, quoted string literals for fetches), so a name a page builds at runtime reads as unreferenced — a false negative costs nothing.
  • Visit rollups tolerate a small window. Totals sum durable per-deployment counters; the last few seconds may be missing (visits buffer for up to one flush window) — bounded loss, never a double count.
  • Publishes purge the edge cache. A project frontend create, update, or delete purges its URL from the Cloudflare edge cache (purge by prefix), best-effort and asynchronous: a failed purge never fails the operation, it only leaves the previous state cached until its TTL. Prefix matching is literal, so sibling slugs sharing the prefix are over-evicted and simply re-fetch once. Unconfigured (no purge token/endpoint), purging is disabled. Quick shares never purge — their slugs are always freshly minted.

Endpoints

Click a row to expand request/response schemas. All examples live from the spec.

Components