API Reference
Interactive reference live from /api/v1/openapi.json, the same spec your agents use for self-discovery. No account, no key.
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.
| Surface | Limit |
|---|---|
| POST /api/v1/deploy | 30 requests/min per IP, burst 15 |
| POST /mcp | 30 requests/min per IP, burst 15 |
| POST /{slug}/api/op | 120 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/fetch | Shares 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/schema | Shares the same buckets; owner-only (Bearer workspace credential) |
| POST /api/v1/auth/{slug} | 10 attempts/min per IP, burst 10 |
| POST /api/v1/reports | 10 reports/min per IP, burst 5 |
| Static content | No 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, orcsv), rendered by the same pipeline as quick shares.
Build an app in five steps
manage_project create— hold the returned project id; it targets every later call.manage_backend create— provision the tier from DDL. Optional: static-only projects are first-class and can add a backend later.manage_frontend create— publish the pages. With a backend present, pages invoke it tokenlessly through the injectedhostan.dbruntime.manage_backend update— evolve the schema and define the actions pages may call. Pages pick up changes on their next call, no republish needed.- Inspect and iterate:
get/listeverywhere,updateto rename or republish,deleteto 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.workspacenames 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 bothttl_secondsandpermanentmeans permanent. The response carrieshost_labelandurl: the project's frontends are served athttps://<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 durablevisitstotal). 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_providersis 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. Passingnullexplicitly 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 andnullas leave-unchanged).host_labelrenames 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 optionalworkspace(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 inworkspace. - 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, yourrole(owner > admin > editor > viewer),plan, andproject_count. - get
{workspace}— detail plus the member list (workspacetakes 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).planis one offree,hobby,pro,unlimited, and onlyfreecan be set here: a paid plan is granted by a subscription, never by a request, so asserting a paid tier is a typed403naming the price. Start the subscription withPOST /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 withPOST /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. Settingfreewhile a live subscription stands is also refused (403), because the subscription is the truth and it ends at the provider — openPOST /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 tofreewhen 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 typed403naming 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 ladderowner,admin,editor,viewer. A removal takes effect on the member's next request — no token revocation needed. Two refusals (400): themachinerole (it marks a workspace key's service principal, never a person), and a service principal asuser_id(a key'susr_mch_…id, orusr_platform) — a key's singlemachinemembership 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.passwordsets 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_baseadvertise 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 atsites.hostan.app/. At root the platform owns/api,/auth,/payments,/ws,/__visitand/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/listreportmount("slug"or"root"). - content
{project_id, slug|id, revision_id?, known_content_sha256?}— the exact stored source: what to edit and feed back throughcreate_revision(the rendered page carries platform injections; the content read never returns those). Withrevision_id, the source of one specific revision. An html frontend published before sources were kept falls back to its liveindex.html. The answer always carriescontent_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_sha256is 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.queryis a literal substring, or an RE2 pattern withregex. 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_byteseach side) rather than verbatim — so a minified single-line page is readable and cannot blow the caller's token budget.max_bytescaps the total extracted text. The reported line numbers pair directly with aneditsline 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 —updateputs content on production with this call. Provide eithercontentoredits; 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 eithercontentoredits.base_revision_idmust 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 nohostan.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 currentactive_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_idis 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 therollbackkind and the displaced revision becomesrolled_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 VIEWplus optional seedINSERTs (additive only; at least one table;CREATE TABLE … AS SELECTis 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 thehostan.dbruntime; pages published earlier need onemanage_frontendupdate to receive it.realtime: trueadditionally 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, seedINSERTs), 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, otherALTERforms,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's503renders (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:namecontract the public op endpoint enforces — missing or extra params are refused naming the diff, and a declaredparam_schematypes the test exactly as it types a page's call (an omitted optional param binds SQLNULL) — and an action referencing a reservedhostan_*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/DELETEstatement per action (WITHallowed). Parameters are:namebinds only, and a call may pass only the declared caller-owned names — extras are a400, and each bind is required unless the action'sparam_schemamarks it optional. - An action may declare an optional
param_schema—[{name, type, required?}]withtypeone ofstring,integer,number,booleanandrequireddefaulting totrue. When present it must name exactly the statement's caller-owned binds (a mismatch is a400naming 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 a400naming the param and the expected type, and an omitted optional param binds SQLNULL. Omittingparam_schemaon 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 getrenders it asparam_schemabeside the always- derivedparamsbind 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 stableusr_…id) and:hostan_user_email(their verified email, SQLNULLwhen the provider asserted none). The platform injects both after resolving the visitor's session; a call naming anyhostan_*parameter is a400, and an anonymous call to an action that uses them is a401. Sessions come from the frontend's/{slug}/auth/loginflow 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 injectedhostan.authruntime (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 nosql/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 getstill lists the action flaggedretired(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-sidetest_actionrefuses 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
UNIQUEany 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:
nameis lowercase letters/digits/underscores;methodisGETorPOST;urlis the fixedhttpsdestination (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 declaredContent-Typemust be a JSON media type (application/json, parameters allowed) or absent: aPOSTfetch 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_backendand never appear inget(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 a503naming the secret — ask the human to set it, never invent a value. Both the503andgetcarry the dashboard URL of that exact field (…/dashboard/projects/{id}?tab=database#secrets), andgetrenders readiness (API_KEY — set <time>/X_KEY — NOT SET — required by fetch "y"), so handing over a link and re-readinggetcloses 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
querykeys and, forPOST, one JSONbody; redirects are never followed, the exchange times out after 5 s, and answers over 256 KB are refused. - Every egress failure is a structured
503envelope (deliberately not the502it once rode — the production edge replaces origin502responses with its own error page, so a502envelope never reaches a caller over the live path):{"error", "reason", "fetch", "upstream_status", "upstream_body_excerpt", "bytes_received", "elapsed_ms", "limit"}.reasonis one ofnon_2xx(the upstream answered non-2xx; its status and a bounded excerpt are included),timeout,too_large,dns,refused,tls, orunreachable— the cause is identifiable in one call, with no need to bisect. The same fields ride thehostan.fetchrejection (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). Answersrun_id,run_url,expires_at. Any prior live run of the same revision is stopped first. - drive — open
run_urlin any browser you control. The page is the draft's rendered artifact with the realhostan.db/hostan.fetchruntime 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 exerciseshostan.dbandhostan.fetchonly —hostan.auth,hostan.payments, andhostan.wsare 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, asummarytally, and the boundedrecords. - 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?};typeis 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, andtsbefore fanout; clients cannot forge them, nor thehostan:*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_idfor identity and the database as the source of truth: write viahostan.db.op, broadcast the small event viahostan.ws, reload onopen.
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) orPOST /api/v1/projects/{id}/payments/catalog(REST).amountis an integer in minor units (cents forusd);currencyis ISO 4217. The response carries theprice_…id pages use.manage_payments catalog/GET …/payments/cataloglists products and prices;deactivate_price/DELETE …/payments/catalog/{price_id}stops a price being purchasable (history keeps its snapshots).manage_payments sessions/GET …/payments/sessionslists 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 behindhostan.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 requirematched_by === 'user_id'; per-user authorization keyed on:hostan_user_idis unaffected. - A price flagged
recurringbills 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 incanceled/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_paymentstable into the project's database (uniquesession_id,price_id,price_label,amount,currency,user_id,email,status,created_at) — a derived view for JOINs and composability in your own actions. Treatstatus()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 createhostan_*tables. - Where the platform has payments unconfigured,
checkoutanswers503(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).
getdetects it, reports aschema_ahead_of_ledgerwarning, 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.
getends 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.