# Hostus runtime 1

> The contract for HTML that runs on Hostus (https://<id>.hostus.page). Read it before generating a page that keeps state, has a form, ships with files or calls a model. Machine-readable copy: https://www.hostus.io/runtime.json. Over MCP: `hostus_runtime_spec`.

Runtime version: **1**. The version only changes when something here changes in a way that breaks a page written against it.

## 1. The file

- One self-contained HTML file (.html, .htm), UTF-8, up to 5 MB. CSS and JS inline or loaded from https CDNs. Served at https://<id>.hostus.page/ (the doc is the root of its own origin).
- Through the API and MCP the request body stops at about 4 MB of HTML. Keep media out of the HTML: base64 grows files by a third.
- Always include the viewport meta: readers open docs on phones.
- The doc is born private (only the owner opens it). Public and email-gated are owner decisions in the dashboard; the page does not choose its visibility.
- Hostus edits HTML in place on the same link and keeps the last 20 versions. Do not generate code that assumes a build step, a server or a framework runtime.

Viewport meta to include:

```html
<meta name="viewport" content="width=device-width, initial-scale=1">
```

### Assets

- Optional files uploaded next to the doc (images, video, audio, fonts, css, js, json, pdf...) and referenced by relative path from the root: media/hero.mp4 or /media/hero.mp4. Up to 40 files, 25 MB each, 150 MB per doc, path depth 4, path length 180.
- Extension whitelist; the content type is fixed from the extension. No .html/.htm assets: the doc is the only HTML. No path segment may start with _ or . (dotfiles and the reserved _ prefix).
- Assets inherit the doc's access control (private and gated docs gate their media too).
- How they get there: in the dashboard, a folder or a .zip next to the HTML (the .zip is opened in the browser, never uploaded). Over MCP (hostus_upload / hostus_update) and the API, small text and icon files go inline in `assets` ([{ path, content | content_base64 }], up to 20 per call, 2 MB total); heavy media goes through signed uploads at /api/v1/documents/{id}/assets or the dashboard.

Allowed extensions: `.mp4`, `.m4v`, `.webm`, `.mov`, `.ogv`, `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.avif`, `.svg`, `.ico`, `.mp3`, `.wav`, `.m4a`, `.ogg`, `.flac`, `.woff`, `.woff2`, `.ttf`, `.otf`, `.css`, `.js`, `.mjs`, `.json`, `.txt`, `.csv`, `.vtt`, `.md`, `.pdf`, `.glb`, `.gltf`, `.bin`, `.wasm`.

Path segment pattern: `^[a-zA-Z0-9][a-zA-Z0-9._-]*$` (max depth 4, max length 180).

### Reserved paths

On the doc's origin every path starting with `_` belongs to Hostus: `/_hostus/*`, `/_data`, `/_form`, `/_attn`, `/_ask`, `/_reply`, `/_derive`, `/_user`, `/_files`, `/_ai`, `/_err`, `/_card`, `/_room`. Do not use them as asset paths and do not call them from your code (use the SDK and plain forms instead).

### What Hostus injects into the served HTML

The file you upload is kept as is; these are added when the doc is served. Do not remove, hide or restyle them.

| Injection | Where | When | What |
|---|---|---|---|
| data-sdk | start of <head> | The HTML references hostus.data, window.hostus or /_hostus/data.js. | <script src="/_hostus/data.js" data-mode="..."> as the first child of <head>, so window.hostus.data exists before any script of the page runs. |
| user-sdk | start of <head> | The HTML references hostus.user, or the owner turned on data_require_login. | <script src="/_hostus/user.js"> right after data.js: window.hostus.user (signIn by magic link, signOut, current). See Identity. |
| files-sdk | start of <head> | The HTML references hostus.files or /_hostus/files.js, or destructures { files } from window.hostus. | <script src="/_hostus/files.js" data-mode="..."> after data.js (when both exist): window.hostus.files (upload, list, url, remove). See Files. |
| ai-sdk | start of <head> | The HTML references hostus.ai or /_hostus/ai.js, or destructures { ai } from window.hostus. | <script src="/_hostus/ai.js" data-enabled="1\|0">: window.hostus.ai (generate, stream). With ai_enabled off every call rejects ai_disabled without a request. See AI. |
| room-sdk | start of <head> | The HTML references hostus.room or /_hostus/room.js, or destructures { room } from window.hostus. | <script src="/_hostus/room.js">: window.hostus.room (join, live, inviteUrl). See Rooms. |
| reader-widget | start of <head> | The owner turned on ask_enabled or reply_enabled. | <script src="/_hostus/reader.js">: the "ask this document" / "reply on a section" / "request a version" drawer for readers. The page does nothing for it. |
| noindex | start of <head> | Public or gated doc. | <meta name="robots" content="noindex,nofollow">. Hosted docs never reach search engines. |
| favicon | start of <head> | The HTML declares no <link rel=icon>. | The Hostus favicon link. |
| og-tags | start of <head> | The owner edited the link preview (title/description) in the dashboard. | og:/twitter: title and description; equivalent tags in the HTML are replaced. |
| forms | inside each eligible <form> + end of <body> | The HTML has at least one eligible <form> (see Forms). | Hidden token and honeypot inputs (_hostus_t, _hostus_hp) in each eligible form, up to the first 100 (a later form submits without a token and lands in Spam), plus <script src="/_hostus/forms.js"> at the end of <body> that records the submit. |
| attention | end of <body> | A reader (not the owner) opens a doc that has headings. | <script src="/_hostus/attention.js">: cookieless reading time per section. No IPs, no fingerprinting. |
| badge | end of <body> | Always. | A fixed pill "Published with Hostus" in one corner (#hostus-footer-badge, 12px from the edges), bottom-right by default. To move it, add <meta name="hostus:badge" content="top-left"> (top-left, top-right, bottom-left or bottom-right); the owner can also pick the corner in the dashboard, and that choice wins. Keep the badge corner free; do not hide or restyle it. |
| derived-banner | start of <body> | The doc is a version a reader requested from another doc. | A full-width banner with the signer and a link to the original. Derived docs are read-only. |

### Headers (Content-Security-Policy and friends)

```
Content-Security-Policy: default-src 'self' https: data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: data: blob:; style-src 'unsafe-inline' https: data:; img-src 'self' https: data: blob:; font-src 'self' https: data:; connect-src 'self' https: wss: data: blob:; frame-ancestors 'none'
Referrer-Policy: no-referrer
Cross-Origin-Opener-Policy: same-origin
X-Content-Type-Options: nosniff
```

What that means for the page:

- Inline <script> and <style> are allowed, including eval (needed by some charting and templating libraries).
- Scripts, styles, images, fonts, fetch/XHR and media may come from any https origin, data: or blob:. WebSocket connects to any wss:// server. Plain http:// and ws:// are blocked.
- The doc cannot be embedded in an iframe (frame-ancestors 'none') and sends no Referer.
- Each doc is its own origin (<id>.hostus.page): localStorage, cookies and hostus.data never leak between docs.

### No secrets in the client

**No secrets in the client. API keys, tokens, service-role JWTs, private keys and passwords must never be in the HTML or its assets: everything a reader can download. Hostus blocks the upload when it detects one. If a page needs a key, it needs a backend, and Hostus is not that backend; use hostus.data instead.**

## 2. hostus.data: storage without a backend

Injected only when the HTML references hostus.data, window.hostus or /_hostus/data.js. It runs before the page's scripts, so `const { data } = window.hostus` works at the top of any script. Belt and braces: `if (window.hostus?.data) start(); else document.addEventListener("hostus:data-ready", start)`.

Global: `window.hostus.data`. Script: `/_hostus/data.js`. Ready event: `hostus:data-ready` (on `document`). Every method returns a Promise.

### API

| Call | Returns | Note |
|---|---|---|
| `me()` | `Promise<{ role, id, email, mode, closed }>` | Who the reader is and what the doc allows (see Identity). Cached after the first call. |
| `collections()` | `Promise<{ collection, count, updated_at }[]>` | Collections of the doc (includes form:<name> when the reader may read responses). |
| `list(col, { limit?, since?, where?, orderBy?, order? })` | `Promise<Item[]>` | Oldest first, up to 200. since = ISO date: only items updated after it. Empty list never throws. Same filter/order options as query (SDK v2). |
| `get(col, key)` | `Promise<Item \| null>` | null when missing (no throw). |
| `add(col, value)` | `Promise<{ key, created_at, updated_at }>` | Creates an item with a generated key. value is any JSON. |
| `set(col, key, value)` | `Promise<{ key, created_at, updated_at }>` | Creates or overwrites the item with that key (idempotent writes, one record per reader, counters). |
| `remove(col, key)` | `Promise<{ removed }>` | Removes one item. remove(col) with no key clears the collection and is owner-only. |
| `query(col, { where?, orderBy?, order?, limit?, cursor?, since? })` | `Promise<{ items: Item[], next_cursor: string \| null }>` | SDK v2. where = flat object (up to 5 keys, scalar values) matched by containment ({ status: "done" }). orderBy = created_at \| updated_at \| key \| value.<field>; order = asc (default) \| desc. Pass next_cursor back as cursor for the next page. list(col, opts) accepts the same options and returns only items. |
| `count(col, { where? })` | `Promise<number>` | SDK v2. Count without loading items. |
| `patch(col, key, partial)` | `Promise<{ key, value, created_at, updated_at }>` | SDK v2. Shallow merge into an existing object value (keeps the other fields). not_found if the key does not exist; invalid if the value is not an object. |
| `inc(col, key, field, delta = 1)` | `Promise<{ key, value, created_at, updated_at }>` | SDK v2. Atomic increment of a numeric field, done in the database: 20 readers calling inc at once give exactly +20. Use it for votes, counters and scores instead of get + set. Creates { [field]: delta } when the key does not exist. |
| `watch(col, cb, { interval?, limit?, where?, orderBy?, order?, onError? })` | `() => void (unsubscribe)` | Calls cb(items) on the first read and whenever the list changes. Polling with ETag (304 when nothing changed), not a socket; backs off when quiet or hidden and wakes on focus and right after your own writes. Use it for every live view instead of your own setInterval. |

### Item

```ts
type Item = {
  key: string;
  value: any JSON;
  author: opaque id (u:<uuid> | g:<opaque, per doc> | a:<rid>);
  author_role: owner | member | gated | anon;
  mine: boolean: written by the current reader;
  created_at: ISO date;
  updated_at: ISO date;
  author_email: only when the reader is the owner; the author's proven email (session or gate), null for anon;
}
```

### me()

```ts
{
  role: owner | member | gated | anon;
  id: opaque, stable per reader and doc;
  email: the reader's own email on owner, member and gated; null on anon;
  verified: boolean: there is a Hostus account behind this reader (SDK v2);
  mode: shared | per_reader | owner_only | public_own;
  closed: boolean: writes frozen by the owner;
  can: { read: 'all' | 'own', write, delete_own, clear }: what this reader may do right now (SDK v2); adapt the UI from it;
  limits: { value_bytes, list, collections, where_keys, records }: the limits in force for this doc (SDK v2);
}
```

### Names and sizes

- Collection name: `^[a-z0-9][a-z0-9_.-]{0,59}$`, up to 20 collections per doc.
- form:<name> reads the responses of the page's own <form name="<name>"> as items ({ key, value: { field: value }, ... }). Read-only. Readers can read them only when the page itself uses hostus.data (a plain form page exposes nothing) and the mode is shared, owner_only or public_own; in per_reader they stay with the owner. Readers see only the fields the form marks with data-publico (<input name="name" data-publico>); every other field, and any email-looking value, stays with the owner, so an RSVP list can show names while phones stay private. Readers get at most 10 public fields per response, each cut at 500 characters, 2000 in all. watch answers 304 while the list is unchanged. count works; for readers, where only matches public fields (values compared as text).
- Key: `^[A-Za-z0-9_.:@-]{1,100}$`. Keys you pass to set() (e.g. the reader id from me().id, a slug, a date).
- Any JSON up to 32 KB serialized. Store small records, not blobs; no files, no base64 images.
- list() returns at most 200 items, created_at ascending. Use `since` for incremental reads.
- watch(): Polls every 3 s (minimum 1 s) with If-None-Match (304, no body, when nothing changed); after 60 s without changes every 10 s; after 30 min without changes every 60 s; 30 s while the tab is hidden, and it stops after 10 min hidden. Back to 3 s on focus/visibility and right after a local add/set/patch/inc/remove.

### Errors

Every rejection carries `err.code`:

| code | HTTP | Meaning |
|---|---|---|
| `forbidden` | 403 | The reader may not do this: owner_only write, per_reader or public_own write on someone else's item, no access to the doc. |
| `login_required` | 403 | The owner requires sign-in to write (data_require_login) and the reader is anonymous: call hostus.user.signIn() and retry. |
| `closed` | 409 | The owner froze the data (data_closed). Reads still work. |
| `quota` | 409 | The collection hit the plan's record limit, or the doc has too many collections. err.limit says which. |
| `rate_limited` | 429 | Too many writes from this reader or on this doc. Back off; never retry in a tight loop. |
| `too_large` | 413 | Value over 32 KB. |
| `invalid` | 400 | Bad collection or key name, missing value, or a write to form:<name>. |
| `not_found` | 404 | Unknown doc or key (get() turns this into null). |
| `network` | - | fetch failed (offline). |
| `error` | 500 | Server error. Show a retry. |

### Modes

| data_mode | Readers |
|---|---|
| `shared` | Default. Every reader reads and writes every item (team checklist, poll, leaderboard). |
| `per_reader` | Each reader sees and edits only their own items; the owner sees all (private answers, individual progress). |
| `owner_only` | Readers only read; only the owner writes (published list, owner-kept scoreboard). |
| `public_own` | Every reader reads everything; each reader writes and deletes only their own items; the owner can delete any (leaderboard, player profile, lobby, wall, one vote per person). |

data_closed: true freezes writes for everyone but the owner; reads continue. Both mode and closed are set by the owner (dashboard or hostus_update data_mode / data_closed), never by the page. Read them from me() and adapt the UI (hide the add button in owner_only for non-owners, show 'closed').

Writes are rate limited per reader and per doc (the owner is exempt), and the first write of each anonymous identity is limited per IP and doc. watch() is read-only and designed for this; do not poll faster than its default.

per_reader or public_own with a deterministic key per reader (a checklist, a form the reader edits, a leaderboard score): keys are unique per DOCUMENT, so use `<thing>@${me.id}` (me() gives the id) and count only items with mine: true. A key ending in another reader's id is refused (forbidden). A key without the suffix belongs to whoever writes it first, so a page that mixes the owner's content with readers' records should not use public_own. Each reader may hold up to 500 records per collection on free, 5000 on paid; past that, quota with reason "per_reader". After hostus.user signIn the id changes (a:<rid> becomes u:<uuid>): records written as anonymous keep the old suffix and stay mine: true, so derive state from the list and update a record by its own key, not by rebuilding it from me().id.

### Games

**Leaderboard.** The client sends the score and Hostus has no way to check it, so an anonymous leaderboard can be faked. data_require_login reduces that but does not solve it. A record is whatever its author wrote: read more than you show (limit 200), keep one line per author, validate with Number.isFinite and a ceiling, and render names with textContent. The email gate does not verify the address, so on a gated doc whoever types someone else's email acts as that person and can change their records.

```js
// public_own: everyone reads, each player writes only their own score.
const me = await hostus.data.me();
async function saveScore(name, points) {
  await hostus.data.set('scores', `score@${me.id}`, { name, points });
}
hostus.data.watch('scores', (items) => {
  const seen = new Set(); // one line per player, valid numbers only
  const top = items.filter((i) => {
    if (!Number.isFinite(i.value?.points) || i.value.points > 1000000 || seen.has(i.author)) return false;
    seen.add(i.author);
    return true;
  }).slice(0, 20);
  renderBoard(top.map((i) => ({ name: String(i.value.name ?? '').slice(0, 20), points: i.value.points })));
}, { orderBy: 'value.points', order: 'desc', limit: 200 });
```

**Cloud save.** In public_own everyone can read a save. A private save needs per_reader, and a doc has one mode only. Without an account the save lives in this browser's identity: hostus.user signIn carries it to other devices.

```js
const save = await hostus.data.get('saves', `save@${me.id}`); // null on the first run
await hostus.data.set('saves', `save@${me.id}`, { level: 3, coins: 120 });
```

**Turn-based game.** Keep the state in one key per room, in shared mode, changed once per move, and follow it with watch(). shared means any reader can write it, so it fits friends, not strangers.

**Real time.** watch() is for what changes in seconds, not for movement. For players moving on screen use hostus.room: no account, key or database to set up. See Rooms for a complete game.

### Canonical snippet

```html
<script data-hostus="keep">
const { data } = window.hostus;
data.me().then((me) => setup(me));              // { role, id, email, mode, closed }
data.watch("items", (items) => render(items));   // first read + every change
async function addItem(text) { await data.add("items", { text, done: false }); }
async function toggle(item) { await data.set("items", item.key, { ...item.value, done: !item.value.done }); }
async function remove(item) { await data.remove("items", item.key); }
</script>
```

## 3. Identity: who the reader is

| role | Who |
|---|---|
| `owner` | The account that published the doc (signed in). |
| `member` | A signed-in Hostus user with access: invited to a private doc or folder, or any signed-in user on a public doc. |
| `gated` | A reader who passed the email gate of a gated doc. me().email is that email. |
| `anon` | A reader of a public doc without a Hostus session. |

- Anonymous identity is a signed httpOnly cookie per doc (hostus_rid_<doc>), stable per browser for a year, revoked by clearing cookies. No IP, no fingerprint. 'One vote per visitor' means one per browser; a determined person can vote twice by clearing cookies.
- The page sees an email only for the owner and for gated readers (the email they typed at the gate, re-checked against the allowlist). Members and anonymous readers have email: null. If the app needs to know who is who, ask the owner to gate the doc.
- Item authors are opaque ids (u:<uuid>, g:<opaque per doc, not derived from the email alone>, a:<random>). Use item.mine to know what the current reader wrote; use me().id as the key for 'one record per reader' patterns.

### 3.1 `window.hostus.user`: the reader signs in

Injected after data.js when the HTML references hostus.user, or when the owner turned on data_require_login. Needs hostus.data (it reads /_me).

| Call | Returns | Note |
|---|---|---|
| `current()` | `Promise<{ role, id, email, verified, can }>` | Who the reader is now (same as data.me() minus mode/closed). role "anon" means not signed in. |
| `signIn({ email? })` | `Promise<user>` | Opens a small modal (or sends straight away when email is given), emails a magic link and WAITS for the click: resolves with the user when the session shows up in this tab (up to 15 min), rejects with err.code cancelled \| timeout \| rate_limited \| invalid \| network. Already signed in resolves at once; on a PUBLIC document a signed-in Hostus user who has not asked to enter this document yet gets me().email only after signIn() (it records the consent, sends nothing). No password, no account creation step: the account is the Hostus account. |
| `signOut()` | `Promise<{ ok }>` | Signs out of the Hostus account in this browser (the dashboard too). Say so in the UI: hostus.user.signOutNote has the sentence. |

- Owner flag data_require_login (dashboard, hostus_update or the API): anonymous readers get err.code "login_required" on any write; the page should call hostus.user.signIn() and retry. Reads stay open; email-gated readers pass. hostus.user.requireLogin tells the page the flag is on.
- What the reader wrote as anonymous in this browser is adopted by the account on the first read after signing in (author becomes u:<uuid>, author_email set), so mine/per_reader keep working across devices.
- After signIn/signOut the document fires hostus:user-changed; data.js resets its identity cache and wakes every watch(). Re-read me() in your handler.

Errors (`err.code`): `cancelled` (The reader closed the sign-in modal.); `timeout` (No click on the link within 15 minutes.); `rate_limited` (Too many links for this reader, email or doc (3/h, 5/h, 60/h). Tell the reader to check the inbox.); `invalid` (Malformed email.).

```html
<script>
const { data, user } = window.hostus;
async function save(item) {
  try { await data.add("items", item); }
  catch (err) {
    if (err.code !== "login_required") throw err;
    await user.signIn();            // modal, magic link, waits for the click
    await data.add("items", item);  // same call again, now as a signed-in reader
  }
}
document.addEventListener("hostus:user-changed", () => data.me().then(renderWho));
</script>
```

### 2.1 `window.hostus.files`: files from readers

Injected when the HTML references hostus.files (after data.js when both exist). Standalone: it has its own /_me. Same modes (shared, per_reader, owner_only, public_own), data_closed and data_require_login as hostus.data; the owner sets them, never the page.

| Call | Returns | Note |
|---|---|---|
| `me()` | `Promise<{ role, id, email, verified, mode, closed, can, limits, collections }>` | Who the reader is and what they can do here (can.write, can.delete_own, can.clear); limits is { file_bytes, files_per_doc, doc_bytes, list }. |
| `upload(file, { collection?, name?, onProgress? })` | `Promise<item>` | Takes a File or Blob. Signs on the server, PUTs straight to storage (never through Hostus) with onProgress(loaded, total), then confirms: the server checks the real size, MIME and first bytes. Collection defaults to "files" (a-z 0-9 _ . -, up to 40). |
| `list({ collection?, limit? })` | `Promise<item[]>` | Ready files, newest first, up to 200. In per_reader a reader sees only their own; the owner sees everything. |
| `url(id, { inline? })` | `string` | Same-origin URL (also item.url()) that answers 302 to a signed storage URL valid for 10 minutes. Attachment with the original name by default; inline: true only for images and PDF (use it in <img src> and <a href>). Rebuild it instead of storing it. |
| `remove(id)` | `Promise<{ removed }>` | Deletes the object and the record. shared: ANY reader can delete any file (same as data; a 10 MB photo does not come back): prefer per_reader for walls and galleries; per_reader: own; owner_only: owner. |
| `clear(collection)` | `Promise<{ removed }>` | Owner only: empties a collection. |

Item: `id` (Opaque id (16 chars).); `name` (Original name, sanitized (label only).); `size` (Real size in bytes, after commit.); `mime` (Accepted MIME type.); `collection` (Collection name.); `author` (Opaque id of who uploaded (u:, g: or a: prefix).); `author_role` (owner | member | gated | anon.); `mine` (true when the current reader uploaded it.); `created_at` (ISO date.); `url({ inline? })` (See url().).

- Accepted: png, jpg, gif, webp, avif, pdf, csv, txt, md, json, docx, xlsx, pptx; 10 MB each. Never html, svg, js, css or zip. A .html renamed .png is rejected at commit (mime_mismatch). There is no virus scan: say so in the UI when the page shows files from other readers.
- Files are never served from the document's origin: the browser is redirected to the storage origin, as a download by default. Uploads do not count against the owner's HTML storage; they have their own caps. Deleting the document deletes its files. On a public document anonymous readers can upload: data_require_login (sign-in before writing) and per_reader are the owner's levers against abuse.

Errors (`err.code`): `unsupported` (MIME type not accepted.); `too_large` (Over 10 MB (also a 413 from the storage on PUT).); `mime_mismatch` (The content does not match the declared type; the object was discarded.); `size_mismatch` (The bytes sent differ from the declared size; the object was discarded.); `quota` (Doc or account cap reached (files or bytes).); `rate_limited` (Per reader: 10 uploads/hour, 30 files or 100 MB a day; per doc: 100 uploads/hour.); `closed` (data_closed is on.); `login_required` (data_require_login and the reader is anonymous: call hostus.user.signIn().); `forbidden` (The mode does not allow it (owner_only, or not yours in per_reader or public_own).); `not_found` (No such file (or not visible to this reader).).

```html
<input type="file" id="pick">
<ul id="grid"></ul>
<script>
const { files } = window.hostus;
async function render() {
  const items = await files.list({ collection: "photos" });
  grid.replaceChildren(...items.map((f) => {
    const li = document.createElement("li");
    const img = document.createElement("img");
    img.src = f.url({ inline: true });
    img.alt = "";
    const name = document.createElement("span");
    name.textContent = f.name;   // the name comes from whoever uploaded it
    li.append(img, name);
    return li;
  }));
}
pick.onchange = async (e) => {
  for (const file of e.target.files) {
    try { await files.upload(file, { collection: "photos", onProgress: (done, total) => bar.value = done / total }); }
    catch (err) { alert(err.code); }   // unsupported, too_large, quota, login_required...
  }
  render();
};
render();
</script>
```

### 2.2 `window.hostus.ai`: a model call from the page

Injected when the HTML references hostus.ai. Standalone (no data.js needed). The page calls a model on behalf of whoever opens it; Hostus resolves the reader, checks the quotas and pays the provider on the owner's behalf. No key in the page, ever.

**OFF by default. The owner turns ai_enabled on per document (dashboard: Share → AI on the page; or hostus_update / PATCH with ai_enabled). While off, hostus.ai.enabled is false and every call rejects with ai_disabled before any request. Design the page to work (or explain itself) in that state.**

| Call | Returns | Note |
|---|---|---|
| `generate({ prompt, system?, json?, maxTokens?, model? })` | `Promise<string \| object>` | One call, whole answer. Text by default; with json (a JSON Schema whose root is an object) it returns an object in that shape. meta of the last call in hostus.ai.last ({ id, model, usage, latency_ms, truncated }). |
| `stream({ prompt, system?, maxTokens?, model? }, { onDelta, onDone, onError })` | `() => void (abort)` | Text as it is generated: onDelta(delta, soFar), onDone(text, meta), onError(err). Returns a function that aborts. No json with stream. |
| `enabled` | `boolean` | Whether the owner turned AI on for this document. |

Options:

- `prompt`: Required, up to 8000 characters. Put the reader's input and the context here.
- `system`: Optional, up to 4000 characters: the page's instructions (role, format, language). Hostus prepends a short fixed guard.
- `json`: Optional JSON Schema (root type "object", up to 4096 bytes, 5 levels): the answer comes as an object in that shape.
- `maxTokens`: Optional, 1 to 1024 (default 512). Output tokens; keep it as small as the task allows.
- `model`: "fast" (default) or "better". The page never picks a model id.

Models: `fast` (Every plan. Summaries, extraction, classification, short answers.); `better` (Paid plans only; on free it runs fast and meta.model says so.).

The owner pays for every call, so the quotas are hard caps, not a budget: per reader per hour, per document per day and per account per day (all documents together). Over a cap the call rejects with rate_limited (reader) or quota (document/account) and nothing is charged. Counting fails closed. No tools, no agent loops, no images: one prompt in, one answer out.

Limits: prompt 8000 chars, system 4000 chars, schema 4096 bytes / 5 levels, maxTokens 1024 (default 512); 15 calls per reader per hour; per day: 50 (free) / 300 (paid) per document and per account.

Errors (`err.code`): `ai_disabled` (The owner has not turned AI on for this document (no request is made).); `forbidden` (The reader cannot open this document, or the call did not come from the document's origin.); `too_long` (prompt or system over the limit.); `invalid_schema` (json is not a valid schema (root object, size, depth).); `invalid` (Malformed body, or json together with stream.); `rate_limited` (This reader made more than 15 calls in an hour.); `quota` (The document or the account reached its daily cap (scope says which).); `refused` (The model refused the request.); `upstream_busy` (The model is overloaded; try again shortly.).

```html
<script>
const { ai } = window.hostus;
if (!ai.enabled) note.textContent = "Ask the owner to turn AI on for this page.";
summarize.onclick = async () => {
  try {
    out.textContent = await ai.generate({ system: "Summarize in one sentence.", prompt: input.value, maxTokens: 200 });
  } catch (err) { out.textContent = err.code; }  // ai_disabled, quota, rate_limited, refused...
};
extract.onclick = async () => {
  const obj = await ai.generate({ prompt: input.value, json: { type: "object", properties: { title: { type: "string" }, bullets: { type: "array", items: { type: "string" } } }, required: ["title", "bullets"] } });
  render(obj.title, obj.bullets);
};
const stop = ai.stream({ prompt: input.value }, { onDelta: (d, all) => (out.textContent = all), onDone: (text, meta) => console.log(meta.usage), onError: (e) => alert(e.code) });
</script>
```

### 2.3 `window.hostus.room`: real-time rooms

Injected when the HTML references hostus.room. Standalone (no data.js needed). Real-time rooms between people who open the page, on any network, with nothing to set up: no account, key or database.

| Call | Returns | Note |
|---|---|---|
| `hostus.room.join(name?, { max, name })` | `Promise<Room>` | Enters a room. max is the number of seats (2 to 8), set by whoever opens the room. The second name is the player's nickname. Without a room name it opens a new room. If the page was opened from an invite link, the room in the link wins, so the same code serves both. |
| `hostus.room.invited` | `string \| null` | The room in the invite link. Use it to change the entry screen. |
| `hostus.room.inviteUrl(name)` | `string` | A link that lands in that room. |
| `hostus.room.defaultName()` | `Promise<string>` | The nickname saved in this browser, or empty. It never comes from the email. Put it in a visible field so the player sees it before joining. |
| `hostus.room.live(name?)` | `Promise<{ total, players?, rooms? }>` | How many people are playing now. total is the whole doc; players is the room you asked for; rooms lists every room, for the owner only. Call it when the page opens or gets focus, never in a loop. |

The room:

| Call | Returns | Note |
|---|---|---|
| `room.me` | `string` | This player's id. Stable through reconnections. |
| `room.players` | `[{ id, name, avatar }]` | Who is in the room, including you. An id has 18 characters: count it when budgeting the 1024 bytes of a message. |
| `room.host` | `string` | Id of the player who decides (starts the round, deals the cards). Changes if that player leaves. |
| `room.max` | `number` | Seats. |
| `room.status` | `string` | connecting, open, reconnecting, paused or closed. paused: the connection is down because the tab was hidden; it comes back when the tab reappears. |
| `room.inviteUrl` | `string` | Link to this room. Pasted in a group, everyone lands here with no code to type. |
| `room.send(data)` | `boolean` | Sends JSON to everyone else, not back to you. false means it did not go out (disconnected, stalled network or over a limit). A position needs no retry: the next update replaces it. An event (a move, a new round, the game state) has no next update: keep it and send it again when status returns to open. |
| `room.on(event, fn)` | `() => void` | Listens to an event. Returns the function that stops listening. |
| `room.leave()` | `void` | Leaves now. Everyone else sees it at once. |

Events:

| Event | Arguments | Note |
|---|---|---|
| `message` | `(data, from)` | Data from another player. from is { id, name, avatar }. |
| `join` | `(player)` | Someone came in. |
| `leave` | `(player)` | Someone left. A dropped connection only counts after the grace period. |
| `name` | `(player)` | A player came back with another nickname. Redraw the list. |
| `host` | `(id)` | The host changed. |
| `status` | `(status)` | Show reconnecting in the UI. The SDK reconnects by itself. |
| `close` | `(err)` | The room ended for this player. err.code says why. |
| `error` | `(err)` | A message was refused (too_large). The room goes on. |

- A player is { id, name, avatar }. name is the nickname given to join(), up to 24 characters; without one the SDK uses the saved nickname or a generated one. avatar is a generated image for <img src>, drawn from the id. Two tabs of the same browser are two players, which is how to test alone.
- Invite links carry ?room=<name>. Show a copy button with room.inviteUrl right after joining: the game spreads through group chats, so the link is the invitation. Room names are not listed anywhere, so a random room is only reachable by its link. Keep the random room as the default. A fixed name like join('room-1') is open to anyone who guesses it, and seats are not guaranteed: whoever opens the room first sets max, and anyone can take the seats.
- The SDK reconnects by itself. A player who drops keeps the seat for 10 seconds, with no leave for the others, and comes back with the same id. Neither a reconnection nor a page reload inside those 10 seconds fires join for the others, so the host gets no signal: whoever comes back must ask. Send a hello right after join() and whenever status returns to open; everyone answers with their position, and the host also sends the state. If the host drops, the host passes to the longest-connected player at once and is not given back; a phone that dies without closing the connection can hold the role for up to 2 minutes. Keep the game running on reconnecting; drive it from room.host, never from who connected first. A tab hidden for 10 minutes leaves the room and comes back when it reappears. A host in a hidden tab keeps the role and browsers slow its timers, so a game driven by the host's timer can stall until it returns.
- Rooms work on the published link. Doc blocked or reader without access get no room: join() rejects and the page should say so. Each account has a daily play-time budget, UTC, adding up every player on every doc: 160 player-hours, 16 for a free account in its first week. Only connected time counts, so reloading costs nothing. Past it, join() rejects daily_limit and people already playing get the close event within about 30 seconds. Always catch join() and handle close.

**Rooms only relay messages: the server does not run the game. A player can cheat: change the code, send a false position, claim any score. Say so to whoever asks for a competitive game. Let room.host decide what matters, and treat this as a game among friends. Nothing is stored: a player who joins later only knows what the others send, so the host should send the current state on join and on every hello. To keep a score, use hostus.data.**

**data in a message and every name come from strangers. Show text with textContent, never innerHTML. Check types and ranges before using a value (Number.isFinite and a ceiling), and ignore what does not fit.**

Limits: 8 players per room, 1024 bytes per message, 30 messages per second per player (aim for 15); past that `send()` returns false. A message takes 100 to 200 ms to arrive, so ease other players toward their last position instead of jumping. Room names match `^[A-Za-z0-9_-]{1,40}$`.

Errors (`err.code`): `unavailable` (Rooms are off right now. Show a message; the rest of the page still works.); `forbidden` (The reader has no access to this doc.); `not_found` (The doc is gone, blocked, under review, or its HTML does not mention hostus.room (code loaded from a separate file does not count).); `daily_limit` (The owner's account used up today's play time. Rooms come back tomorrow (UTC).); `rate_limited` (Too many attempts. Wait before trying again.); `invalid` (Bad room name.); `network` (No connection after several attempts.); `full` (The room has no free seat.); `rooms_limit` (This doc has too many open rooms.); `closed` (The room was closed by Hostus.); `replaced` (This player joined from another connection.); `left` (The page called leave().); `preview_readonly` (Rooms do not run in a draft preview.); `too_large` (Message over 1024 bytes, in the error event. It was not sent.).

#### Complete game: entry screen, invite, reconnection

Start from this page. Keep the entry screen (name, who is in the room, copy invite), the status line and the catch on join(). Send at most 15 times a second, and only when something changed.

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dots</title>
<style>
  body { font: 16px system-ui, sans-serif; max-width: 420px; margin: 0 auto; padding: 16px; }
  ul { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
  li { display: flex; align-items: center; gap: 6px; }
  li img { width: 24px; height: 24px; border-radius: 6px; }
  canvas { width: 100%; touch-action: none; border: 1px solid #ccc; border-radius: 8px; }
</style>
</head>
<body>
<section id="entry">
  <h1>Dots</h1>
  <p id="invited"></p>
  <label>Your name <input id="name" maxlength="24" autocomplete="nickname"></label>
  <button id="play">Play</button>
  <p id="problem" role="alert"></p>
</section>
<section id="game" hidden>
  <p id="status"></p>
  <ul id="who"></ul>
  <button id="copy">Copy invite link</button>
  <button id="restart" hidden>New round</button>
  <canvas id="field" width="400" height="400"></canvas>
</section>
<script>
const $ = (id) => document.getElementById(id);
const SIZE = 400;
const SEND_EVERY_MS = 66; // about 15 times a second
const PROBLEMS = {
  unavailable: "Rooms are not available right now. Try again soon.",
  full: "This room is full.",
  daily_limit: "Today's play time is over. Rooms come back tomorrow.",
  preview_readonly: "Rooms work on the published link.",
  replaced: "You joined from another tab.",
  closed: "This room was closed.",
};
const problem = (err) => PROBLEMS[err.code] || "Could not connect. Try again.";

let room = null;
const dots = new Map(); // player id -> { x, y }
const mine = { x: SIZE / 2, y: SIZE / 2 };
let changed = true;

hostus.room.defaultName().then((name) => { $('name').value = name; });
if (hostus.room.invited) {
  $('invited').textContent = 'You were invited to play.';
  hostus.room.live(hostus.room.invited)
    .then((now) => { if (now.players) $('invited').textContent = `${now.players} playing in this room.`; })
    .catch(() => {});
}

$('play').onclick = async () => {
  $('problem').textContent = '';
  $('play').disabled = true; // two taps would open two rooms
  try {
    room = await hostus.room.join(undefined, { max: 8, name: $('name').value });
  } catch (err) {
    $('problem').textContent = problem(err);
    $('play').disabled = false;
    return;
  }
  history.replaceState(null, '', room.inviteUrl); // reloading stays in the room
  $('entry').hidden = true;
  $('game').hidden = false;

  room.on('message', (data, from) => {
    // Comes from another player: check it before using it.
    if (data?.hello) changed = true; // someone came back: a reload fires no join
    if (data?.restart && from.id === room.host) mine.x = mine.y = SIZE / 2;
    if (Number.isFinite(data?.x) && Number.isFinite(data?.y)) {
      dots.set(from.id, { x: Math.min(SIZE, Math.max(0, data.x)), y: Math.min(SIZE, Math.max(0, data.y)) });
    }
  });
  room.on('join', () => { changed = true; renderWho(); }); // whoever arrives learns where you are
  room.on('leave', (p) => { dots.delete(p.id); renderWho(); });
  room.on('name', renderWho);
  room.on('host', renderWho);
  room.on('status', renderStatus);
  room.on('close', (err) => {
    $('problem').textContent = problem(err);
    $('game').hidden = true;
    $('entry').hidden = false;
    $('play').disabled = false;
  });
  renderWho();
  renderStatus(room.status);
  room.send({ hello: true }); // after a reload, the others have no signal
  setInterval(sendPosition, SEND_EVERY_MS);
  requestAnimationFrame(draw);
};

let away = false;
function renderStatus(status) {
  $('status').textContent = status === 'open' ? '' : 'Reconnecting...';
  if (status === 'open' && away) {
    room.send({ hello: true });
    if (restartPending) sendRestart();
  }
  away = status !== 'open';
}

function renderWho() {
  // Names come from other people: textContent, never innerHTML.
  $('who').replaceChildren(...room.players.map((p) => {
    const li = document.createElement('li');
    const img = document.createElement('img');
    img.src = p.avatar;
    img.alt = '';
    const name = document.createElement('span');
    name.textContent = p.id === room.host ? `${p.name} (host)` : p.name;
    li.append(img, name);
    return li;
  }));
  $('restart').hidden = room.host !== room.me;
}

function sendPosition() {
  if (!changed) return;
  if (room.send(mine)) changed = false;
}

function draw() {
  const c = $('field').getContext('2d');
  c.clearRect(0, 0, SIZE, SIZE);
  for (const [id, dot] of [...dots, [room.me, mine]]) {
    c.fillStyle = id === room.me ? '#2383e2' : '#e2573b';
    c.beginPath();
    c.arc(dot.x, dot.y, 10, 0, Math.PI * 2);
    c.fill();
  }
  requestAnimationFrame(draw);
}

$('field').onpointermove = (e) => {
  const box = e.currentTarget.getBoundingClientRect();
  mine.x = ((e.clientX - box.left) / box.width) * SIZE;
  mine.y = ((e.clientY - box.top) / box.height) * SIZE;
  changed = true;
};
$('copy').onclick = async () => {
  try {
    await navigator.clipboard.writeText(room.inviteUrl);
    $('copy').textContent = 'Copied';
  } catch {
    // Some in-app browsers refuse: show the link to copy by hand.
    $('status').textContent = room.inviteUrl;
  }
};
// An event has no next update: keep it until it goes out.
let restartPending = false;
function sendRestart() {
  restartPending = !room.send({ restart: true });
}
$('restart').onclick = () => {
  sendRestart();
  mine.x = mine.y = SIZE / 2;
  changed = true;
};
</script>
</body>
</html>
```

## 4. Forms: responses without a backend

Eligible (Hostus stores the responses):

- Any <form> with no action, action="", action="#..." or a same-origin action (relative path).
- Fields need a name. Use <form name="rsvp"> to read responses back as form:rsvp. File inputs are ignored.

Ignored (left entirely to the page):

- action pointing to another origin (Formspree, your own API...).
- data-hostus="ignore" on the <form>: the form is yours (a search box, a filter, a form your JS turns into hostus.data writes).

Hostus records the submit in the capture phase, before your handlers. If your JS calls preventDefault, Hostus still stores the response and leaves the UI to you. If not, Hostus prevents the navigation and shows an inline success message (or follows the redirect). It also works with JS disabled (native POST to the doc URL).

- Hostus injects hidden fields named `_hostus_t` and `_hostus_hp`; do not use those names.
- A hidden field named _next (or _redirect / redirect) redirects after a successful submit and wins over the owner's form_redirect_url. With JavaScript on, the page navigates to any same-origin or https URL. Without JavaScript (native POST), the server only follows a URL on the doc itself or on the origin of the owner's form_redirect_url; anything else returns to the doc.
- Owner settings (dashboard or `hostus_update`, no HTML change): `form_closed`, `form_limit`, `form_notify`, `form_success_message`, `form_redirect_url`.
- The owner reads responses in the dashboard, by email, over MCP (hostus_responses) and the page itself can read them with hostus.data.list("form:<name>").
- Free plan: 100 responses a month across the account. Extra responses are stored but held (hidden) until the owner upgrades; the visitor's form never breaks.

## 5. Rules for whoever generates the HTML

1. Never put an API key, token or password in the HTML or in an asset. The upload is blocked. If the idea needs one, say so instead of hiding it in the client.
2. State that another person or another device must see goes to hostus.data, never to localStorage: localStorage lives in one browser only, nobody else sees it, and clearing the browser erases it. Use it only for a local preference (theme, collapsed panel, draft of an unsent field). If the request has records (players, expenses, entries, scores), it is hostus.data.
3. Hostus never strips inline scripts. data-hostus="keep" on the <script> that is the app is reserved for future tools that rewrite the doc; today "apply review" simply refuses sections that contain code.
4. Never call /_data, /_form or any /_ path directly, and never hand-roll the cookie or the token. Use hostus.data and plain <form>. The endpoints are internal and change with the runtime version.
5. Test as an anonymous reader (incognito window), not only as the owner: the owner is exempt from mode, closed and rate limits. Handle err.code (forbidden, closed, quota, rate_limited) in the UI instead of letting the promise reject silently.
6. hostus.ai is opt-in per document and the OWNER pays for every call: it only works after they turn ai_enabled on (dashboard or hostus_update); until then every call rejects with ai_disabled and no request leaves the page. Handle that in the UI, keep prompts short, never put the model call in a loop or on every keystroke, and tell the user the page needs AI turned on. Login is hostus.user and file upload is hostus.files.
7. For anything real time between people use hostus.room, never a third-party realtime database that needs keys. Room messages and names come from strangers: show them with textContent, and tell the user a player can cheat.
8. A <form> your JS handles (search, filter, writes to hostus.data) takes data-hostus="ignore", otherwise every submit is stored as a form response.
9. Do not remove, hide or restyle what Hostus injects (badge, scripts, hidden form fields). Leave the badge corner free: bottom-right unless you set <meta name="hostus:badge" content="top-left">. A game with a control in that corner moves the badge.
10. Games: a public page detected as a game can be added to the phone's home screen (after the account's first week on free) and reports players' JS errors to the owner. When a new version is published, open tabs get a small notice; listen for window 'hostus:update' and call preventDefault() to show it your own way, for example at the end of the round. For phones use viewport-fit=cover with env(safe-area-inset-*), touch-action: none on the canvas, and start audio only after the first touch.
11. Use watch() for live views; never poll list() in your own interval. Keep values small and collections few (see limits).
12. Include the viewport meta and make the layout work at phone width.

## 6. Limits by plan

Free is the default plan (no clock, no card). Paid covers Pro and grandfathered beta accounts. Limits apply on write: existing pages keep working when a limit is hit.

|  | Free | Paid |
|---|---|---|
| HTML file | 5 MB | 5 MB |
| Storage per account | 2 MB | 200 MB |
| Documents | 20 | unlimited |
| Assets per doc | 40 files, 25 MB each, 150 MB total | 40 files, 25 MB each, 150 MB total |
| Form responses per month | 100 (extra ones held) | unlimited |
| Records per collection | 1,000 | 20,000 |
| Collections per doc | 20 | 20 |
| Value per record | 32 KB | 32 KB |
| Items per list() | 200 | 200 |
| Reader uploads per doc (hostus.files) | 200 files, 10 MB each, 100 MB total | 200 files, 10 MB each, 1024 MB total |
| Reader uploads per account | 200 MB | 5120 MB |

## 7. What readers can do (owner flags)

Readers can ask the document (answers anchored in it), reply on a section (private thread with the owner) and request a version (summary, excerpt, translation). These are owner flags set with hostus_update (ask_enabled, reply_enabled, derive_enabled); the page needs no code for them and cannot turn them on.

## 8. Coming (runtime 2)

**Nothing announced. Everything a page can call today is above. When something new ships in a way that breaks pages written against this document, the runtime version changes.**

---

Generated from the Hostus source (`src/lib/runtime/spec.ts`). Questions: team@hostus.io.
