# Realtime def shapes

> Read when the workspace defines a `realtimeServer()`, `realtimeChannel()`, or `realtimeMessage()` handler.

- **Realtime** — the only three-level chain: `realtimeServer` owns `realtimeChannel`s, which own `realtimeMessage` handlers. Pass the HANDLE, not a name (a channel path is unique only within its server).
  - `realtimeServer({ name, guid?, description?, enabled?, canonical?, tags?, history? })` — the container.
    - `enabled` defaults to **false** — the one `enabled` in the SDK that does.
    - An enabled server with no active channel still refuses the handshake.
  - `realtimeChannel({ name, server, guid?, description?, active?, input?, anonymousClients?, presence?, publish?, conversation?, delivery?, rateLimit?, tags?, history? })`
    - `name` is a PATH (`"lobby"`, `"rooms/{room_id}"`); `input` types its `{param}` segments, NOT the payload. Every `{param}` MUST have a matching input, and that input must be a SCALAR and not a list (`json`/`object`/`array: true` have no URL form), or `realtimeChannel()` THROWS. `required: true` is NOT checked and is not needed — segment counts must match, so the segment is always present at join. Name charset as query (`A-Za-z0-9_-/{}`, max 200), and so is `tool`; `realtimeMessage` is NARROWER — no `/` or `{}`.
    - Matching is STRICT: a literal segment beats a param (`rooms/lobby` and `rooms/{room_id}` coexist); segment counts must be EQUAL (`rooms/{room_id}` does NOT match `rooms/42/edit`); literals are CASE-SENSITIVE; an empty segment is REJECTED, not collapsed (a leading/trailing/doubled `/` matches nothing). `getChannel()` throws on an empty or slash-bearing param for that reason.
    - An INACTIVE channel reports the same error as a nonexistent one — deactivating leaks nothing.
    - `anonymousClients` is gated TWICE: the server admits the connection, then the channel admits the join. Setting it here alone is not enough.
    - `publish?: { who?: "nobody"|"anyone"|"authenticated", direct? }` — `who` defaults to `nobody`: nobody can publish until you set it. `direct` (default false) lets a client address ANOTHER CLIENT via a frame's `options.socketId`, and is checked BEFORE `who`.
    - `conversation?: { enabled?, limit?, ttl? }` — the client-visible TRANSCRIPT replayed to a joiner (distinct from `history`, which is execution history). ⚠ `limit` DEFAULTS TO 0 AND 0 MEANS OFF: `{ enabled: true }` alone records nothing and replays nothing, silently. `ttl` is an IDLE expiry of the WHOLE transcript, refreshed by every write (an active channel never ages out; a silent one loses all of it at once) — NOT a per-message age cap.
    - `delivery?: { guarantee?: "at_most_once"|"at_least_once", perRecipient? }` — `perRecipient` is independent of the guarantee, is a NO-OP unless the channel declares a `deliver` trigger, and costs a stack PER RECIPIENT PER MESSAGE. Per-viewer redaction needs BOTH HALVES — this flag AND an active `deliver` trigger bound to the channel — and with either missing the payload is delivered UNCHANGED to everyone; `export()` warns on each half alone.
    - `rateLimit?: { messagesPerMinute? }` — 0 = unlimited, checked BEFORE the handler runs. A COST guardrail, not a security control: an anonymous client is bucketed per CONNECTION (reconnecting resets it), and it fails OPEN when its store is down.
  - `realtimeMessage({ name, channel, server?, guid?, description?, active?, auth?, deliverTo?, input?, middleware?, stack?, response?, responseShape?, history?, disabled?, tags? })` — the invocable unit (the realtime analogue of a query).
    - `input` types the message PAYLOAD. `server` is required only when `channel` is a bare path.
    - `deliverTo?`: `"channel"` (default) | `"sender"` | `"others"` | `"explicit"`. ⚠ `"explicit"` still delivers to NOBODY — nothing selects recipients from inside a handler, and `s.realtime.publish` (which originates an event INTO a channel) is not a substitute.
    - Only `"channel"`/`"others"` fan out AND are written to the `conversation` transcript — a `"sender"` response is invisible to every future joiner.
  - **Both input surfaces read as ordinary inputs:** `inp("body")` for a payload field, `inp("room_id")` for the channel's `{room_id}`. No session lookup, no frame parsing.
    - A path param is bound ONCE at join and read from the connection thereafter, never from the frame — a sender cannot claim a room it did not join. The same values reach a channel `join`/`leave` trigger's stack.
  - `s.realtime.get_session({ as })` — the CALLER's realtime session for the current frame. FLAT shape:
    - `authenticated` bool · `client_id` text (the AUTHED ROW ID as text, `""` anonymous) · `dbo_id` int (the auth TABLE's id — NOT the user's row id; `0` anonymous — to look the caller up use `client_id`. `dbo_id` is an int in the same position and typechecks, so a gate that keys on it finds no user and refuses EVERYONE) · `socket_id` int (transport id) · `channel` text (resolved path, `""` in a server trigger) · `params` object (bound path params, `{}` when none — `ref("session.params.room_id")`) · `extras` object · `opened_at` decimal.
    - Works in a realtime MESSAGE stack and in CHANNEL and SERVER trigger stacks; off that path it degrades to an anonymous session.
    - For a path param prefer `inp("room_id")`. Reach for the session when you need the CONNECTION (identity/extras) — "who is this sender" on an anonymous-client channel.
    - ⚠ THREE UNRELATED THINGS ARE CALLED A CLIENT ID: `session.client_id` (app-facing identity), `session.socket_id` (transport), and a frame's `options.client_id` (the at_least_once CURSOR handle). Conflating the first and last breaks at_least_once for anonymous clients.
  - `s.realtime.publish({ server, channel, data, message?, authTable?, authId? })` — the PUSH direction: originate a server-authored event onto a channel from ANY stack, no client frame first.
    - `server` is the handle or its NAME (resolved by name, not guid); `channel` is the FILLED-IN path (`channel.getChannel({ room_id: 42 })`), never the template — a constant still carrying `{param}` THROWS at author time, and a constant `server`/`channel` naming nothing this workspace registers WARNS at export.
    - A PER-ROW path whose id is only known at runtime is built as a value, not with `getChannel()` (which needs the id at author time): `withFilters(c.text("rooms/"), fl.concat(ref("room.id")))`, or `s.set_var` + `s.text.prepend`. A computed `channel`/`server` — a `ref`/`inp`, or a constant carrying a filter chain — is left alone by the export check.
    - DELIVERY-ONLY — fanned out as-is; does NOT invoke a `realtimeMessage()` handler even when `message` names one (a channel `deliver` trigger still runs).
    - SERVER-AUTHORITATIVE — bypasses `publish.who`, which governs CLIENTS. Authorize in your own stack.
    - ⚠ FAIL-SOFT — a missing/disabled server or dead bus is swallowed engine-side, so a mis-targeted publish is SILENT with no result to check.
    - `authTable`/`authId` are ASSERTED attribution on the frame — not a credential, nothing validates them.
  - **Client recipe (derive, never hardcode):**
    - `server.getUrl(baseUrl)` → `wss://<host>/ws/<canonical>` — accepts the `https://…` instance base URL and normalizes the scheme. `channel.getChannel({ room_id: 42 })` → `"rooms/42"`, the path that goes in a frame's `channel` field. Both throw rather than guess. A canonical is minted by `xanots export <entry> --lock`.
    - Auth is a bearer token passed as the websocket SUBPROTOCOL: `new WebSocket(url, token)`. No token = an anonymous client, admitted only where `anonymousClients: true`.
    - Frames are JSON `{ action: "join"|"leave"|"broadcast"|"ack"|"ping"|"presence", channel, type?: <message name>, payload?, options?, id? }`. You must `join` before you may `broadcast`, and the server's context is ready only a moment after `open` — an immediate first frame is refused.
    - `options` is `{ socketId?, client_id?, channel? }` — `socketId` addresses another client directly (needs `publish.direct`), `client_id` is the at-least-once cursor handle, and `options.channel` WINS over a top-level `channel`.
    - ⚠ KEEP THE SOCKET ALIVE: an idle connection is REAPED after ~10 minutes. A LISTEN-ONLY client (a feed or dashboard that joins and rarely publishes) MUST send `{ action: "ping" }` (answered `pong`) or any frame periodically or it silently drops.
    - Server frames: `join` (ack `{ joined: true, params }`, + `cursor`/`resumed` on at_least_once) · `message` · `replay` · `broadcast` · `presence_full`|`presence_join`|`presence_leave` · `conversation_start`|`conversation_end` (replayed frames flagged `conversation: true`) · `pong` · `ack` · `error`.
    - ⚠ `broadcast` is a RECEIPT to the sender, not a delivery confirmation: `payload.delivered_local` counts recipients on the ANSWERING NODE ONLY, not the channel. It also carries `id` on at_least_once and `dropped: true` when the handler returned null.
    - `error` carries `payload.message`, plus `code`/`limit`/`retry_after` when rate limited. `rate_limited` is the ONLY code — do NOT switch on `code`.
    - An `error` is a per-frame refusal, NOT a disconnect — EXCEPT a failed handshake and a REFUSED `connect` trigger, which each send one and then CLOSE with code 4401.
  - **Tenant instances (isolated DB):** a tenant's realtime objects live in the TENANT's database, so BOTH halves of a client must name the tenant.
    - Socket: `server.getUrl(base, { tenant })` → `/ws/<tenant>:<canonical>`. ⚠ A bare canonical on a tenant host resolves against the INSTANCE workspace instead.
    - That colon form is PECULIAR TO THE SOCKET. Every other tenant URL gives the tenant its OWN segment — the HTTP half of the same client is `https://<host>/tenant/<tenant>/api:<canonical>/…`. NO request header is required for either.
    - Because the shapes differ, `getUrl` TRANSLATES a tenant base URL instead of concatenating: pass the `https://<host>/tenant/<name>` that `sandbox details` prints (and that deploy injects as `window.XANO_HOST`) and the tenant is LIFTED into the socket form. So `getUrl(window.XANO_HOST)` needs no `{ tenant }`, and a CONFLICTING `{ tenant }` alongside it throws.
    - ⚠ `getUrl`/`socketUrl` are NOT idempotent — a `baseUrl` that already carries a `/ws/<…>` path (an earlier result of either) THROWS. Resolve ONCE from the http(s) base; pass that result to `new WebSocket`, never back in as a base.
    - Still pass `{ tenant }` explicitly for a tenant on its OWN DOMAIN — the hostname carries it for HTTP, but there is nothing in the URL for the socket to lift.
    - ⚠ Tokens are tenant-scoped (audience `<tenant>:<license>`, not the bare license), so one minted through the instance workspace is REJECTED by a tenant's realtime server — authenticate and dial through the same tenant.
  - **Presence frames** (a `presence: true` channel only):
    - `presence_full` carries `payload.members` — an ARRAY holding the WHOLE roster, including the receiving client. `presence_join`/`presence_leave` carry a single `payload.member`.
    - A member is `{ id, dbo_id, authenticated, extras, joined_at }`: `id` the auth row id as a string (`""` anonymous), `dbo_id` the auth table's id (`0` anonymous), `extras` the connection's extras object, `joined_at` epoch SECONDS.
    - Render from `presence_full`, then apply the deltas. The roster counts MEMBERS, not connections (refcounted per identity — a second tab fires no second `presence_join`).
    - Join order: `join` ack → `presence_full` → (others get `presence_join`) → conversation replay → `replay` frames.
    - A joined client can re-request the snapshot any time with `{ action: "presence", channel }`, answered to the SENDER only. A socket that never joined is REFUSED — the roster is not readable without membership.
  - **Conversation frames — the transcript hydrates the client, so DO NOT build a hydration endpoint.**
    - On a `conversation` channel the replay is PUSHED automatically at join, unasked: `conversation_start` (`payload.count`) → the last `limit` messages, each a normal `action: "message"` frame carrying its ORIGINAL `type` and `payload` plus `conversation: true` and the original `ts` → `conversation_end`.
    - So the client needs NO fetch, no `GET /messages`, and no table read to paint the initial view. Render `message` frames identically either way; the backfill paints itself.
    - ⚠ `{ enabled: true }` ALONE IS A NO-OP: `limit` defaults to 0, and 0 means RETAIN NONE (not retain everything), so the transcript is never written and never replayed, with no error. ALWAYS PASS `limit`.
    - The POST-HANDLER broadcast payload IS the stored transcript row — a handler must broadcast everything the UI needs to render a past message (author name, id, `created_at`). Nothing else is replayed.
    - Only `deliverTo` `"channel"`/`"others"` are RECORDED, so a `"sender"` response is invisible to every future joiner by construction.
    - The transcript is a capped ring (`limit`, `ttl`), not storage. Persist to a table only for durability, search, or reads BEYOND that window — never merely to hydrate a joiner.
  - **`delivery.guarantee: "at_least_once"` is a CLIENT CONTRACT, not just a channel setting.**
    - The client must ACK what it receives — `{ action: "ack", channel, id }`, confirmed by `{ action: "ack", channel, payload: { cursor } }`.
    - ⚠ An ANONYMOUS client must ALSO send a durable `options.client_id` in its JOIN frame (once; later acks need not repeat it). WITHOUT one it has no cursor, its acks are SILENTLY IGNORED, and it degrades to at_most_once. An AUTHENTICATED client is keyed by identity and needs no `client_id`.
    - The missed gap arrives after join as `replay` frames, oldest-first, each with an `id` to ack.
    - DISTINCT from the conversation transcript: `conversation_*` is the SHARED "what was said before I arrived", `replay` is the PER-CLIENT "what I missed while disconnected". Both may be on.
    - How far back `replay` reaches is sized by `conversation.ttl` (here a REAL per-message age cut, and it BEATS `limit`), else `conversation.limit`, else 1000 — even on a channel with no transcript enabled.
  - **What a message handler RETURNS decides delivery, and the failure directions are NOT symmetric.**
    - A returned value fans out per `deliverTo` and becomes the transcript row.
    - Returning NULL delivers NOTHING — the supported way to veto a message (the sender is told `dropped: true`).
    - A payload REJECTED by the declared `input` also delivers nothing,; the detail goes ONLY to the sender.
    - ⚠ But a handler that CRASHES FAILS OPEN: the sender's ORIGINAL, UNVALIDATED payload is broadcast to the channel unchanged. A handler doing redaction or authorization must NOT be the only thing between client input and subscribers.
