# Backlit — full SDK documentation

> Backlit is managed hosting for AI-generated static HTML apps ("glows"), served from `*.backlit.run` with built-in auth and per-app data persistence. This file concatenates the complete runtime SDK reference and its TypeScript type surface so an agent can ingest the whole `window.backlit` contract in one fetch.

Generated from `sdk/docs/sdk-reference.md` and `sdk/sdk.d.ts` — do not edit by hand.

---

# Backlit JavaScript SDK — `window.backlit`

The Backlit SDK is a small browser library that AI-generated static apps
("glows") use at runtime: read the signed-in user's identity, read and
write glow-shared data, per-user data, and owner-scoped records,
subscribe to realtime change events, and post write-only telemetry. This
page is the public contract for everything `window.backlit` exposes.

This document is published as HTML at [/reference](https://sdk.backlit.run/reference)
and as plain Markdown at [/sdk.md](https://sdk.backlit.run/sdk.md) so AI agents can
fetch and parse the surface without HTML scraping. A machine-readable
TypeScript declaration of the whole surface is published at
[/sdk.d.ts](https://sdk.backlit.run/sdk.d.ts) (hosted alongside the bundle at
`sdk.backlit.run/sdk/v1/sdk.d.ts`), and a routing index for agents at
[/llms.txt](https://sdk.backlit.run/llms.txt).

<!-- QUICKREF:START -->
## Quickref

Drop in the SDK, then call `window.backlit.*`. Every method is async and
throws `BacklitError` on failure — except `auth.me()`, which resolves `null`
instead. The full prose, semantics, and examples are below; this section is
the ~80% an agent needs to write the calls.

```html
<script src="https://sdk.backlit.run/sdk/v1/sdk.js"></script>
```

Machine-readable types: [`/sdk.d.ts`](https://sdk.backlit.run/sdk.d.ts).

### Pick a store

| Store | Who reads / writes | Use for |
| --- | --- | --- |
| `backlit.data` | every visitor reads one shared set; signed-in non-`viewer` users write (anonymous sessions are read-only) | leaderboards, shared docs, published content |
| `backlit.userdata` | each signed-in user, private isolated silo (requires sign-in) | per-user settings, drafts, history |
| `backlit.records` | any signed-in user creates + reads all; only the creator or an admin edits (requires sign-in) | comments, forum posts, submissions, galleries |
| `backlit.capture` | anyone writes; only an admin reads / lists / deletes | form submissions, telemetry the app must not read back |

### Methods

```ts
backlit.version: string

backlit.auth.me(): Promise<BacklitUser | null>             // never throws; null when signed out
backlit.auth.users({ cursor?, limit? }?): Promise<{ items: BacklitUser[], next: string }>  // admin only; THROWS
backlit.auth.login(returnPath?): void                      // send the visitor to sign in (no-op off-host)
backlit.auth.logout(returnPath?): void                     // sign the visitor out (no-op off-host)

// data, userdata, and records share this shape. records.* additionally carries
// `owner` on get / list / getBatch / getBatchJSON / putIfMatch / updateJSON, and adds reassign().
backlit.data.get(key): Promise<{ value: Blob, contentType: string, crc32c: string }>
backlit.data.put(key, body, contentType): Promise<{ crc32c: string }>
backlit.data.putIfMatch(key, body, contentType, expectedCrc32c): Promise<PutIfMatchResult>
backlit.data.delete(key): Promise<void>
backlit.data.list(prefix?, { cursor?, limit? }?): Promise<{ items: Array<{ key, crc32c, contentType, size }>, next: string }>
backlit.data.getBatch(prefix?, { cursor?, limit? }?): Promise<{ items: Array<BatchEntry>, next: string }>       // list + each value; oversized entries arrive metadata-only (omitted:true)
backlit.data.getBatchJSON<T>(prefix?, { cursor?, limit? }?): Promise<{ items: Array<BatchJSONEntry<T>>, next: string }>  // getBatch, each value JSON.parsed
backlit.data.getJSON<T>(key): Promise<{ value: T, crc32c: string }>
backlit.data.putJSON(key, value): Promise<{ crc32c: string }>
backlit.data.putJSONIfMatch<T>(key, value, expectedCrc32c): Promise<PutJSONIfMatchResult<T>>
backlit.data.updateJSON<T>(key, mutate, options?): Promise<{ value: T, crc32c: string }>

backlit.records.reassign(key, newOwnerUid): Promise<void>  // admin only

backlit.capture.create(prefix, body, contentType): Promise<{ handle: string, crc32c: string }>
backlit.capture.update(handle, body, contentType): Promise<{ crc32c: string }>
backlit.capture.get(handle): Promise<{ value: Blob, contentType: string, crc32c: string }>  // admin only
backlit.capture.list(prefix?, { cursor?, limit? }?): Promise<{ items: Array<{ handle, crc32c, contentType, size }>, next: string }>  // admin only
backlit.capture.delete(handle): Promise<void>                                                 // admin only

backlit.on("storage.change", handler): () => void  // handler({ store, operation, key, crc32c, owner? })

backlit.push.supported(): { supported, reason?, requiresInstall, installed }   // SYNC; never throws
backlit.push.state(): Promise<{ permission, subscribed, endpointId }>          // requires sign-in
backlit.push.subscribe(): Promise<{ endpointId, permission: "granted" }>       // requires sign-in; prompts for permission
backlit.push.unsubscribe(): Promise<void>
backlit.push.test(): Promise<void>                                             // send yourself a test push
backlit.push.watch(cb): () => void   // cb({ type: "notification-click", url } | { type: "subscription-changed" } | { type: "permission-changed", permission })

backlit.hideUI(): void  // remove the Backlit button this SDK injects (paid plans only; no-op otherwise; if hidden, offer your own sign-out via auth.logout)
```

`body` is `string | Blob | ArrayBuffer | ArrayBufferView` (plain objects are
rejected — use the `*JSON` helpers, or `JSON.stringify` + `"application/json"`).
`contentType` is required. Keys/prefixes are `[A-Za-z0-9._~-]`, 1–256 bytes
(prefix may be empty); capture handles are the `"{prefix}-{guid}"` value
`create` returned.

### Shared writes — the one rule

For any `data` / `records` key more than one user can write, use
`updateJSON` (load → mutate → compare-and-swap → rebase-and-retry), never a
bare `putJSON` (an unconditional last-writer-wins overwrite that silently
drops a concurrent edit). `userdata` has a single writer, so `putJSON` is fine
there. Shared `data` writes need a signed-in non-`viewer` user — anonymous
sessions can only read.

```js
await backlit.data.updateJSON("todos", (todos) => {
  todos ??= { items: [] };
  todos.items.push({ text, done: false });
  return todos; // mutate MUST be replayable — it reruns against a fresher value on conflict
});
```

### Error codes

Discriminate on `err.code` (`err instanceof backlit.BacklitError`). `err.status`
is the HTTP status, or `0` for client-side/network failures.

| `code` | Meaning |
| --- | --- |
| `not_found` | `get` / `delete` on a key that was never written |
| `unauthenticated` | a shared `data` write, `userdata.*` / `records.*`, an admin-only `capture` op, or `auth.users()` with no user session |
| `forbidden` | a `viewer` wrote shared `data`; a non-creator/non-admin wrote a `records` key; a non-admin called an admin-only op |
| `invalid_key` / `invalid_prefix` / `invalid_handle` | argument failed the charset / length / handle-form rule |
| `invalid_cursor` | a `list` (or `auth.users()`) `cursor` was not a valid pagination token |
| `unknown_handle` | well-formed `capture` handle with no stored record |
| `invalid_body` | `getJSON` read non-JSON bytes, or the server could not decode the body |
| `invalid_argument` | bad `body` type, missing `contentType`, a non-serializable `putJSON` value, or a malformed `reassign` owner id |
| `unsupported_content_type` | content-type off the allowlist (or an always-denied executable type) |
| `payload_too_large` | body exceeds the per-object cap (10 MiB) |
| `quota_exceeded` | a write exceeded the account's storage allowance (413), or any call landed after the monthly bandwidth allowance ran out (429) |
| `conflict` | `updateJSON` exhausted its retries (`maxAttempts`, default 6) under contention; or a `records` delete/reassign lost a concurrent-write race (412) — retry |
| `unsupported_host` | the SDK was loaded off a deployed glow host |
| `unsupported` / `requires_install` / `permission_denied` | `push.subscribe()` — the browser can't do push, needs the app installed first (iOS), or the user declined the permission prompt |
| `cross_glow_token` · `glow_not_found` · `glow_dark` · `region_mismatch` · `invalid_host` · `method_not_allowed` · `network` · `internal` | transport / glow-state failures |

A `putIfMatch` mismatch is **not** an error — it resolves
`{ matched: false, witness }` carrying the current value.

### Deploy bundle contract

Every deploy bundle must contain `index.html`, `APP.md` (what the app
is, who it's for), and `DATA.md` (how it uses the data namespaces) at
the archive root, or the upload is rejected with
`missing_required_files`; `.html`/`.htm`/`.js`/`.mjs` files that
reference `localStorage` or `indexedDB` are rejected with
`browser_storage_forbidden` — use `window.backlit.*` instead. APP.md
and DATA.md are served publicly: no secrets, credentials, or PII.
An optional root `notify.json` turns on push notifications for the
glow (see `backlit.push`); without one, push is simply off.
<!-- QUICKREF:END -->

## Loading

The SDK is a single UMD bundle hosted on the Backlit CDN. Drop one
`<script>` tag in your HTML; no build step required.

```html
<!doctype html>
<script src="https://sdk.backlit.run/sdk/v1/sdk.js"></script>
<script>
  (async () => {
    const user = await window.backlit.auth.me();
    // ...
  })();
</script>
```

(The async IIFE matters: classic `<script>` blocks can't use top-level
`await` — that's a SyntaxError outside module scripts.)

Two URL channels are supported:

| URL                                          | Stability  | Use for                                       |
| -------------------------------------------- | ---------- | --------------------------------------------- |
| `https://sdk.backlit.run/sdk/v1/sdk.js`      | Stable     | Production glows. Pinned to the v1 surface.   |
| `https://sdk.backlit.run/sdk/latest/sdk.js`  | Rolling    | Quick experiments. Aliases the newest v1.     |

The bundle is an IIFE that attaches a single global, `window.backlit`. It
takes no constructor — the namespaces below are ready to use the moment
the script tag finishes parsing.

`window.backlit.version` is a string identifying the bundled SDK version
(e.g. `"1.0.0"`).

The SDK runs only on a deployed glow host (a `*.backlit.run` subdomain,
or a recognised dev host — `*.backlitdev.run`, `*.localtest.me`,
`localhost`, `*.localhost`).
Loaded anywhere else — straight from `file://`, an AI design tool's
preview iframe, or any other origin — every `data.*` / `userdata.*` /
`records.*` / `capture.*` call (and `auth.users()`) rejects with
`BacklitError(code: "unsupported_host")`; only `auth.me()` resolves
`null` rather than throws. There is no
local/offline fallback — deploy the glow to exercise the data surface.

## Storing app state

Most glows keep some state — a list, a document, user preferences. Backlit
gives you three key/value stores; pick by **who owns the data**:

| Store              | Who can read & write                                          | Use for                                          |
| ------------------ | ------------------------------------------------------------ | ------------------------------------------------ |
| `backlit.data`     | every visitor reads (one shared set); signed-in non-`viewer` users write | leaderboards, shared docs, published content     |
| `backlit.userdata` | each signed-in user, isolated (requires sign-in)             | per-user settings, drafts, history               |
| `backlit.records`  | any signed-in user creates + reads all; only the creator/admin edits (requires sign-in) | forum posts, comments, user submissions, galleries |

All three stores hold **bytes**, but the `*JSON` helpers serialize and parse
objects for you — so most apps never touch the raw byte API:

```js
// Save an object. Returns its { crc32c } — an integrity/version hash.
await backlit.data.putJSON("todos", { items: [], updatedAt: 0 });

// Load it back. `value` is the parsed object; `crc32c` is its current hash.
const { value: todos, crc32c } = await backlit.data.getJSON("todos");
```

The first read of a key — before anything is written — throws `not_found`.
Catch it and start from a default:

```js
let todos;
try {
  todos = (await backlit.userdata.getJSON("todos")).value;
} catch (e) {
  if (e.code !== "not_found") throw e;
  todos = { items: [] }; // first run
}
```

### Saving shared state safely

`putJSON` is an **unconditional overwrite — last writer wins.** That is fine
for `userdata` (you are the only writer) and for any `data` key only one
user ever writes. But when two users can edit the *same* `data` key, a
plain `putJSON` silently drops one of their changes.

For shared, multi-writer state use **`updateJSON`** instead. It loads the
current value, runs your merge function, and commits with a compare-and-swap;
if another session wrote in between, it rebases onto the latest value and
retries — so no edit is ever lost, and you never write a loop yourself:

```js
// `mutate(current)` receives the current value (undefined on the first write)
// and returns the new value. It may run more than once — it replays against a
// fresher value on conflict — so keep it a pure function of its input (no side
// effects).
await backlit.data.updateJSON("todos", (todos) => {
  todos ??= { items: [] };
  todos.items.push({ text, done: false });
  return todos;
});
```

`updateJSON` resolves the committed `{ value, crc32c }`. It retries up to
`maxAttempts` (default 6) and throws `BacklitError(code: "conflict")` only if
it can't win the race in that many attempts — a sign of extreme contention.
It's built on `putJSONIfMatch`; reach for
[that](#datagetjson--dataputjson--dataputjsonifmatch) directly only when you
want create-only semantics or to inspect the conflicting value yourself.

Rule of thumb: **`putJSON`** when last-writer-wins is genuinely fine (single
writer, or idempotent content); **`updateJSON`** for anything several people
edit at once. Full method docs are under
[`backlit.data`](#backlitdata--glow-shared-keyvalue).

## Migrations

When porting an existing standalone HTML app to a glow, replace browser-
local persistence (`localStorage`, `sessionStorage`, raw `indexedDB`)
with the equivalent Backlit surface. The mapping is straightforward
once you decide who the data belongs to:

| Browser API                                  | Backlit equivalent              | When to pick it                                                                                  |
| -------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------ |
| `localStorage` / `sessionStorage` (per-user) | `backlit.userdata.*`            | Settings, drafts, history — anything scoped to *this signed-in user*. Requires sign-in (a Private or Accounts glow). |
| `localStorage` (app-global state)            | `backlit.data.*`                | Content that every visitor should see: a wiki page, a leaderboard, a published gallery. Writes need a signed-in non-`viewer` user. |
| `IndexedDB` keyed records                    | `backlit.data.*` / `userdata.*` / `records.*` | Same split as above. Backlit's keyspace is flat — collapse object-store + key into one string; one entry per author that others can read but not edit → `records.*`.  |
| Hidden form posts / `fetch("/submit")`       | `backlit.capture.create`    | Contact forms, telemetry events, support requests — write-only captures the page can't read back.|
| File uploads to a separate server            | `backlit.data.put` (binary)     | Accepts `Blob` / `ArrayBuffer` / typed arrays with a matching raster content type (`image/png`, `image/jpeg`, `image/gif`, `image/webp`). |

Three differences will trip you up if you don't plan for them:

1. **Everything is async.** `localStorage.getItem` is synchronous;
   `backlit.data.get` returns a `Promise`. Wrap reads in `await` and
   move any work that depended on synchronous storage into an `async`
   initialiser.
2. **Objects use the `*JSON` helpers.** `localStorage` stores strings;
   for object state use [`putJSON` / `getJSON`](#storing-app-state),
   which serialize and parse for you. (The underlying `put` / `get` take
   raw `string | Blob | ArrayBuffer | ArrayBufferView` with an explicit
   `contentType` — that's the path for binary like images.)
3. **Keys are restricted.** `[A-Za-z0-9._~-]` only, no slashes. If you
   used `localStorage.setItem("user/42/profile", …)` translate to
   `user.42.profile` (or any other slash-free scheme).

A typical port — the `localStorage` version on the left, the Backlit
version on the right:

```js
// before
const prefs = JSON.parse(localStorage.getItem("prefs") ?? "{}");
prefs.theme = "dark";
localStorage.setItem("prefs", JSON.stringify(prefs));

// after
let prefs = { theme: "light" };
try {
  prefs = (await backlit.userdata.getJSON("prefs")).value;
} catch (e) {
  if (e.code !== "not_found") throw e;
}
prefs.theme = "dark";
await backlit.userdata.putJSON("prefs", prefs);
```

> ⚠️ This load → mutate → save is safe here **only because `userdata`
> has a single writer — you.** The identical shape on shared
> `backlit.data.*` is a lost-update bug. For any shared key more than one
> session can write, use [`updateJSON`](#saving-shared-state-safely)
> instead of `putJSON`.

When you need a key gone, call `backlit.data.delete(key)` or
`backlit.userdata.delete(key)` — see the [delete sections](#datadeletekey-string-promisevoid)
below. Reach for `backlit.capture.create` whenever the page must
*capture* something — a contact form, an event — but ordinary app code
should never be able to *read* it back even if the page's JavaScript is
compromised. (An **admin** user can later read, enumerate, and delete
those captures with `backlit.capture.{get,list,delete}`; non-admin
sessions cannot.)

### Offer advanced features that fit the app

A faithful port reproduces what the original app already did — but
moving onto Backlit unlocks capabilities a plain static page never
had, and the person you're building for usually doesn't know to ask
for them. **If you're an AI agent migrating an app, read what it does
and proactively offer the matching upgrades below before you deploy.**
Describe the benefit in plain terms, suggest only what genuinely fits
the app, and wire one in only if the user says yes.

| If the app…                                                                            | Offer                                                                                                    | Backlit surface                                                                                |
| -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| shows shared state several people view at once — leaderboard, kanban board, presence, a collaborative doc | **Realtime sync + safe writes** — live updates across tabs and users (no polling), and `updateJSON` so concurrent edits merge instead of clobbering. Realtime makes a conflict *visible*; `updateJSON` makes it *impossible to lose*. | `backlit.on("storage.change", …)` + `data.updateJSON`                                          |
| lets people post content others see but shouldn't edit on each other's behalf — comments, chat, forum threads, reviews, profiles, a shared gallery, contest entries | **Owner-scoped records** — anyone signed in can post and read everyone's, but only each item's author (or an admin) can edit or delete it, so users can't tamper with one another's content. Emits realtime events and has `updateJSON`, just like `data`. | `backlit.records.*` (requires sign-in)                                                        |
| keeps per-user settings, drafts, or history in one global blob                         | **Per-user data** — each signed-in user gets an isolated silo                                             | `backlit.userdata.*` (requires sign-in)                                                        |
| collects form submissions, contact requests, or telemetry                              | **Private capture + an admin review page** — visitors write submissions the page can't read back; an admin page lists and reads them | `backlit.capture.create` to capture; `capture.list`/`get`/`delete` behind an `admin`   |
| shows or hides controls by role — an editor vs. a read-only viewer                     | **Permission-aware UI** — branch on the signed-in user's role                                             | `backlit.auth.me()` (the `permission` field)                                                   |
| needs sign-in, or should be limited to specific people or domains                      | **Private or Accounts auth mode** — Private restricts sign-in to an allowlist; Accounts lets any visitor create an account themselves; leave it Public for an open app | the glow's `auth_mode` setting                                                                  |
| lets users upload or attach images                                                     | **Binary storage** — store images directly, no separate file server                                      | `backlit.data.put(key, blob, "image/png")`                                                     |

Don't bolt realtime onto a single-user notepad or force sign-in on a
public toy — suggest only what the app's purpose calls for. When the
user opts in, the relevant section of this reference has the full
signatures.

### Packaging the deploy bundle

A glow is deployed as a single **bundle** — a gzipped tar
(`.tar.gz`) of its built static assets, posted as the `archive`
field of a `multipart/form-data` request (see the glow deploy API's
OpenAPI at `glow.backlit.run/openapi.yaml` for the direct and
ticket-based upload endpoints). A few rules for what to put inside.

**Required files at the bundle root.** Every deploy bundle must
contain three files at the top level of the archive (exact names,
exact case):

- **`index.html`** — the serving root. Glows are served from
  `https://{name}.backlit.run/`, and `/` always returns `index.html`.
  A request path **without a file extension** serves the matching
  `.html` file when it exists — e.g. `/about` serves `about.html`.
  `/about` is served as-is (it is not redirected to `/about/`), so
  relative links on the page resolve against the site root; reference
  assets by root-relative or absolute paths. A path that already has an
  extension is served unchanged, and there is no directory-index or
  trailing-slash behavior.
- **`APP.md`** — 1–2 paragraphs of documentation: what the app is
  and who it's for.
- **`DATA.md`** — how the app uses the `window.backlit` data
  namespaces: what goes where (each namespace's keys/prefixes and
  what they mean).

A bundle missing any of them is rejected with
`missing_required_files` (HTTP 400); the message lists every missing
name — add the file(s) and re-upload. A short skeleton is enough:

```md
<!-- APP.md -->
# Trail Crew Scheduler

A weekend roster for the volunteer trail-maintenance crew: members
claim shifts, see who else is coming, and leave notes for the lead.

Built for the crew's ~20 members; the crew lead administers it.
```

```md
<!-- DATA.md -->
# Data layout

| Namespace | Keys / prefixes | Meaning |
| --- | --- | --- |
| `data` | `roster` | the shared shift roster (JSON, written via `updateJSON`) |
| `userdata` | `prefs` | each member's notification preferences |
| `capture` | `signup-…` | new-member signup submissions (admin reads them back) |
```

**APP.md and DATA.md are public.** They are served like any other
asset (e.g. `https://{name}.backlit.run/APP.md`) — they are public
documentation files; never put secrets, credentials, or PII in them.

**Optional `notify.json`.** A bundle may additionally carry a
`notify.json` at the archive root to turn on
[push notifications](#backlitpush--notifications) for the glow — its
rules declare which data writes send a notification to whom (see the
[notify.json schema](https://sdk.backlit.run/schema/notify.schema.json)
for every field, and the
[push guide](https://sdk.backlit.run/guides/push-end-to-end.md) for the
end-to-end story). A bundle without one deploys with push off; an
invalid one is rejected at upload.

**No browser storage.** At upload, the content of every `.html`,
`.htm`, `.js`, and `.mjs` file in the bundle is scanned for
browser-storage usage; any hit rejects the deploy with
`browser_storage_forbidden` (HTTP 400), and the message lists the
offending files and the first matched token in each. The platform's
published scan list is these case-sensitive substrings, for each of
`localStorage` and `indexedDB`:

`localStorage.` · `localStorage[` · `.localStorage` ·
`=localStorage` · `= localStorage` · `indexedDB.` · `indexedDB[` ·
`.indexedDB` · `=indexedDB` · `= indexedDB`

Comments and string literals count — the scan is a best-effort
heuristic that steers apps toward the `window.backlit` data APIs,
not a security boundary. On a reject, replace the storage calls with
`backlit.data.*` / `backlit.userdata.*` (see [Migrations](#migrations)
above) and delete the replaced code — don't comment it out.

**Path rules.** Bundle paths must be forward-slash-relative —
absolute paths, parent traversal (`..`), NUL bytes, and backslashes
are rejected by the unpacker.

**Split inline data out of the HTML.** AI generators often emit a
single self-contained HTML file with JSON state and base64 images
embedded. Once that page runs on a real glow, the habit costs you
cache hits: the HTML is fetched on every navigation, while sibling
files at `/_v/{ver}/...` carry `Cache-Control: public, max-age=31536000,
immutable` (`private` on Private glows) and become free on repeat
visits. Two refactors are worth doing before packing:

- **JSON blocks over ~1 KiB.** Move them to a sibling file
  (`data.json` is conventional) and fetch at runtime:
  `const data = await fetch("./data.json").then(r => r.json());`.
  The HTML stays small; the JSON is cached independently.
- **`data:` URIs over ~1 KiB.** Decode them into real files
  (`images/hero.png`, `fonts/inter.woff2`, …) and reference by
  relative URL. Same caching win, plus `<img loading="lazy">` and
  font subsetting become available.

Anything under ~1 KiB is fine inline — the round-trip to fetch a
separate file dwarfs the parse cost.

**Building the bundle.** From the directory containing your built
site:

```sh
tar -czf site.tar.gz -C ./dist .
```

The trailing `.` matters: it produces entries like `index.html`
rather than `dist/index.html`, which is what the server expects at
the bundle root.

### Deploy with an agent

You don't have to build and upload the bundle by hand. Backlit runs a
hosted **MCP server**, so an AI assistant — Claude, ChatGPT, or any
client that speaks the Model Context Protocol — can create the glow,
deploy your files as a draft, hand you a preview URL, and promote it
live, all from a chat.

Connect it once, then just ask ("deploy this to Backlit and give my team
access"):

- **MCP server URL** — `https://mcp.backlit.run/mcp`
- **Claude** — [set up a custom connector](https://support.anthropic.com/en/articles/11175166-about-custom-connectors-remote-mcp)
- **ChatGPT** — [set up an MCP connector](https://platform.openai.com/docs/mcp)

The deploy tools move the bundle **out of band** rather than over the MCP
connection, and there are **two transports** — pick by what your agent's
sandbox can reach:

- **GitHub push** (for sandboxed agents that can reach `github.com` but
  not `glow.backlit.run`): the agent leases a repo, `git push`es your
  files to it, and signals the push — Backlit pulls and deploys the
  draft. The push goes directly to **`github.com`**.
- **URL upload** (otherwise): the agent uploads the bundle directly to
  **`https://glow.backlit.run`**.

So if your agent runs in a sandbox with a domain allow-list, **add the
host for whichever transport it uses** (`github.com` or
`glow.backlit.run`) to its allowed/permitted domains, or the deploy is
blocked.

The agent acts as you (no API key to copy around), and every deploy lands
as a **draft** you preview before promoting — so nothing goes live
without your go-ahead.

## Public, Private, and Accounts glows

What works for a given visitor depends on the glow's `auth_mode` —
**Public** (serves anonymously; sign-in is optional, and when the app
offers it only allowlisted users — e.g. an admin — are let in),
**Private** (only allowlisted users may sign in, and they must), or
**Accounts** (serves anonymously like Public, but any visitor can create
an account and sign in themselves) — and whether the visitor has signed
in. A visitor with no signed-in user — a signed-out Public or Accounts
visitor — is an **anonymous session**.

| Surface                       | Public glow / Accounts glow, signed out (anonymous) | Private or Accounts glow, signed in | Private glow, signed out         |
| ----------------------------- | ---------------------------- | --------------------------- | -------------------------------- |
| `backlit.auth.me()`           | returns `null`               | returns `{ uid, name, email, mobile, permission }` (`permission` ∈ `admin`/`user`/`viewer`) | n/a — the visitor never reaches the glow; Backlit redirects them to sign in |
| `backlit.auth.users()`        | throws `unauthenticated`     | works only for `admin` (else `forbidden`) | n/a                              |
| `backlit.auth.login()`        | navigates to sign-in — Accounts admits any visitor (self-serve); a Public glow admits only allowlisted users | n/a — already signed in | n/a               |
| `backlit.data.* (reads)`      | works                        | works                       | n/a                              |
| `backlit.data.put/delete/putIfMatch` | throw `unauthenticated` — anonymous sessions cannot write shared data | work (blocked with `forbidden` for `viewer`) | n/a |
| `backlit.userdata.*`          | throws `unauthenticated`     | works (per-user silo)       | n/a                              |
| `backlit.records.* (reads)`   | throws `unauthenticated`     | works (read every record)   | n/a                              |
| `backlit.records.put/delete`  | throws `unauthenticated`     | works on your own records (others' → `forbidden`) | n/a            |
| `backlit.records.reassign`    | throws `unauthenticated`     | works only for `admin` (else `forbidden`) | n/a                |
| `backlit.capture.create/update` | works                  | works                       | n/a                              |
| `backlit.capture.get/list/delete` | throw `unauthenticated` (no admin) | work only for `admin` (else `forbidden`) | n/a               |

Signed-out Private visitors never execute JavaScript on the glow —
Backlit intercepts the request and redirects them to sign in. So inside
a glow page, "signed out on a **Private** glow" is not a state your code
has to handle. An **Accounts** glow is different: it serves anonymously,
so your code DOES run for signed-out visitors there — branch on
`auth.me()` returning `null` and offer `auth.login()`.

One Accounts-mode trap to design around: a self-serve account is created
with permission **`viewer`**, and viewers cannot write shared `data` —
so signing in does not, by itself, grant shared-data write access. The
glow's owner promotes accounts to `user` / `admin` in the Backlit
console. Apps on Accounts glows should treat shared-data writes as a
privileged action (hide or disable the control unless
`me.permission === "user" || me.permission === "admin"`), or use
`records.*` — where any signed-in user, including a `viewer`, can create
their own entries.

---

## `backlit.auth`

### `auth.me(): Promise<BacklitUser | null>`

Returns the signed-in user, or `null` when there is no user session —
an anonymous or signed-out visitor and the SDK loaded off a deployed glow
host both resolve to `null`. **Never throws**:
network failures and missing sessions both resolve to `null` (off-host it
resolves to `null` too, rather than throwing `unsupported_host`), since
they all render the same anonymous fallback UI in practice.

`BacklitUser` is a plain object with five string fields:

| Field        | Notes                                                                          |
| ------------ | ------------------------------------------------------------------------------ |
| `uid`        | The user's stable id — survives an email change; keys their `userdata` silo.   |
| `name`       | Display name, or `""` if unset.                                                |
| `email`      | The user's verified email, or `""` if they signed in without one.              |
| `mobile`     | Mobile number, or `""` if unset.                                               |
| `permission` | Permission level — `"admin"`, `"user"`, `"viewer"`, or `""` (legacy/unknown).  |

Every field is a string; absent values are `""` (never `null` or
`undefined`). The whole result is `null` only when there is no signed-in
user. `name`, `email`, and `mobile` are user-controlled display strings —
**escape them before inserting into HTML.**

`"viewer"` users are blocked from writing **shared** data: `data.put`
and `data.delete` reject with `BacklitError(code: "forbidden")` (HTTP 403).
Their `userdata.*` and `capture.*` writes are unaffected. The server is
the source of truth — `permission` exists so apps can branch ahead of a
write, e.g. hiding a Save button.

You stay signed in across visits, and while a page is open it is kept
signed in and your access level stays up to date automatically — no app
code required.

```js
const user = await backlit.auth.me();
if (user === null) {
  // Show the anonymous experience.
} else {
  greeting.textContent = `Hello, ${user.name || user.email}`;
  saveButton.disabled = user.permission === "viewer"; // the server would 403 a data.put anyway
}
```

### `auth.users(opts?: { cursor?: string, limit?: number }): Promise<{ items: BacklitUser[], next: string }>`

Lists this glow's users. Each entry is the same `BacklitUser` object
`auth.me()` returns (`{ uid, name, email, mobile, permission }`).
**Admin only** and **read-only** — it's an operator view, not an editing
surface.

The result is **paginated**: it returns one bounded page of
`BacklitUser` entries plus an opaque `next` cursor. To walk the whole
roster, re-call `users` with `{ cursor: next }` until `next` is `""`
(the empty string means the roster is exhausted).

- **Parameters**
  - `opts.cursor` — optional opaque string. Pass a prior page's `next` to
    fetch the following page; omit (or `""`) for the first page. A
    malformed cursor is rejected with `BacklitError(code:"invalid_cursor")`.
  - `opts.limit` — optional page size. Clamped to `[1, 1000]`; omitting it
    uses the default of 500. An over-range value is clamped, never
    rejected.

Unlike `auth.me()`, this method **throws** `BacklitError` rather than
resolving to a fallback:

| `code`             | When                                                            |
| ------------------ | -------------------------------------------------------------- |
| `forbidden`        | The signed-in user's `permission` is not `"admin"`.           |
| `unauthenticated`  | No signed-in user (an anonymous or signed-out visitor).       |
| `invalid_cursor`   | `opts.cursor` is not a valid pagination token.                 |
| `unsupported_host` | The SDK was loaded outside a deployed glow host.               |
| `network`          | The request could not be completed.                            |

Each `name`, `email`, and `mobile` is a user-controlled display string —
**escape it before inserting into HTML.**

```js
// Render an admin-only roster of the glow's users, one page at a time.
try {
  let cursor = "";
  do {
    const page = await backlit.auth.users({ cursor });
    for (const u of page.items) {
      const row = document.createElement("li");
      row.textContent = `${u.name || u.email} — ${u.permission}`;
      roster.appendChild(row);
    }
    cursor = page.next;
  } while (cursor);
} catch (err) {
  if (err.code === "forbidden") {
    // Not an admin — hide the roster.
  } else {
    throw err;
  }
}
```

### `auth.login(returnPath?: string): void` / `auth.logout(returnPath?: string): void`

Send the visitor to sign in, or sign them out. Both are **top-level
navigations** — the page unloads — so they return `void` (not a Promise) and
nothing runs after them. `returnPath` is where the visitor lands afterwards: an
**absolute same-origin path** (e.g. `/dashboard`); it defaults to the current
page, and anything that isn't a plain same-origin path is replaced with `/`.
Off a deployed glow host both are a **silent no-op**.

Use `login()` to add a "Sign in" button on a glow whose auth mode is
**Accounts** (open self-serve sign-in — signed-out visitors can create an
account) — or on a **Public** glow to sign in an allowlisted user for an
admin-only view (only allowlisted users are let in; the general public is
not, and the built-in Backlit button never shows a Login option on a Public
glow, so this is app-driven). Use `logout()` for a "Sign out" button. After
`login()` completes,
`auth.me()` returns the signed-in user and `userdata.*` works. (A fresh
self-serve account starts as a `viewer`, so shared `data` writes stay blocked
until the owner promotes it — see the auth-mode matrix above.)

```js
// A sign-in / sign-out button that reflects the current session.
const user = await backlit.auth.me();
if (user) {
  signOutBtn.onclick = () => backlit.auth.logout();      // returns to this page
} else {
  signInBtn.onclick = () => backlit.auth.login("/welcome");
}
```

---

## The Backlit button (and `backlit.hideUI()`)

The SDK adds a small **Backlit button** to the bottom-right corner of your glow.
A visitor can open it to sign in or out (when the glow offers accounts) and to
discover Backlit. You don't have to add or configure anything — it appears
automatically.

`backlit.hideUI()` removes it. Removing the button is a **paid feature**: on a
free glow the call is a silent no-op (the button stays); on a paid glow it is
removed. It never throws and is safe to call as early as you like.

```js
backlit.hideUI(); // paid glows: removes the button. free glows: no-op.
```

There is also a per-glow setting in the Backlit console that removes the button
without any code (also paid). Either one is enough.

The button is also where a signed-in visitor signs out. If you remove it — by
either route — you're encouraged to give visitors your own "Sign out" control
that calls `backlit.auth.logout()`.

---

## `backlit.data` — glow-shared key/value

Readable by any visitor with a valid glow session — every visitor of a
Public or Accounts glow, and every signed-in visitor of a Private glow.
**Writes require a signed-in user**: `put` / `delete` / `putIfMatch` (and
the `*JSON` write wrappers) throw `unauthenticated` for an anonymous
session (a signed-out Public or Accounts visitor) and `forbidden` for a
signed-in `viewer`. There is one shared keyspace
per glow, and more than one user can write the same key at the same
time.

**For any shared key more than one session can write,
[`updateJSON`](#saving-shared-state-safely) is the default** — it runs a
load-modify-commit loop that rebases and retries on a concurrent write, so
nothing is ever silently lost. (It's built on the
[`putIfMatch`](#dataputifmatchkey-string-body-bodyinit-contenttype-string-expectedcrc32c-string-promiseputifmatchresult)
compare-and-swap *primitive* below — reach for that directly only for
create-only locks or a custom merge.) Plain [`data.put`](#dataputkey-string-body-bodyinit-contenttype-string-promise-crc32c-string)
is an unconditional, last-writer-wins overwrite; reserve it for a single
writer or genuinely idempotent content. This is the one habit that does
**not** survive the jump from `userdata` (one writer per silo) to shared
`data`.

**Working with objects?** The
[`*JSON` helpers](#datagetjson--dataputjson--dataputjsonifmatch)
(`getJSON` / `putJSON` / `putJSONIfMatch` / `updateJSON`) wrap the byte
methods below with serialize/parse — most apps use those. The raw `get` /
`put` below are for strings and binary (e.g. images).

**Sizes and integrity.** Your storage allowance and the 10 MiB
per-object cap both measure the data you pass, so the limit you hit
is the size of your own bytes. Every `put`/`create`/`update` response
carries `{ crc32c }` and every `get` / `list` entry carries the body's
CRC32C — the same value the [storage.change event](#backliton--realtime-subscriptions)
broadcasts on writes, so app code can dedup local state against
server state without an extra fetch. Each `list` entry also carries the
stored `contentType` and the body's `size` in bytes, so a listing alone
tells you each value's type and size without a per-key `get`. A `get` or `list` against a
server that omits the CRC (a legacy object, or a proxy that stripped
the non-standard `X-Backlit-CRC32C` header) tolerates the gap and
returns `crc32c: ""` rather than failing the read; only `put` /
`create` / `update`, where the CRC is the whole success payload, treat
a missing value as a protocol error.

**Debounce frequent writes.** Each `put` / `putIfMatch` / `updateJSON`
is a network round-trip that counts toward your glow's monthly request
and bandwidth allowance, and every successful write broadcasts a
[`storage.change` event](#backliton--realtime-subscriptions) to every
connected session. Don't write on every keystroke, drag, or animation
frame — coalesce rapid edits (debounce ~250–500 ms, or save on blur /
pause) and send one write when the value settles. This keeps you well
under your allowance and stops connected peers from being flooded with
intermediate updates. The same applies to `userdata.*` and `records.*`.

**Writing identical content is a no-op.** A write that stores exactly
what the key already holds — the same bytes with the same `contentType`
— succeeds normally and returns the same `{ crc32c }`, but nothing is
observed to change: it emits **no
[`storage.change` event](#backliton--realtime-subscriptions)**, so open
peer sessions don't hear it and no
[notification rule](#backlitpush--notifications) fires. This applies to
overwrites in every store (`userdata`, `records`, and `capture.update`
included, and the `*JSON` wrappers); a first write to a key is never a
no-op, and changing only the `contentType` is a real write. If a repeat
of the same value must still be observed — for example a key written
purely to trigger a notification — include something that varies in the
payload, like a timestamp or counter.

### `data.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string }>`

Read the value stored at `key`. Returns the raw bytes as a `Blob`, the
`Content-Type` the value was written with, and the Castagnoli CRC32C
of the body (base64-encoded big-endian uint32 — same format as the
[storage.change event](#backliton--realtime-subscriptions)).

- **Parameters**
  - `key` — string, 1–256 bytes, alphanumerics + `-` `.` `_` `~` only. See [Validation](#validation).
- **Returns** — a `Promise` for `{ value, contentType, crc32c }`.
- **Throws** — `BacklitError` with `code === "not_found"` if the key has
  never been written. See [Errors](#errors) for the full list.

```js
try {
  const { value, contentType, crc32c } = await backlit.data.get("greeting");
  console.log(await value.text(), contentType, crc32c);
  // → "hello world" "text/plain" "+IxOLg=="
} catch (e) {
  if (e.code === "not_found") {
    // First-run path.
  } else {
    throw e;
  }
}
```

### `data.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>`

Write `body` to `key` under the glow-shared keyspace.

> ⚠️ **`put` is an unconditional overwrite — last writer wins.**
> Read-modify-write on a shared key loses any write that landed in
> between, worst with the one-big-JSON-blob state AI generators emit,
> where every edit rewrites the whole blob so even edits to unrelated
> records clobber each other. Reach for bare `put` only when
> last-writer-wins is genuinely fine — a single writer, or idempotent
> content. For any shared mutable state, [`updateJSON`](#saving-shared-state-safely)
> is the default (or the [`putIfMatch`](#dataputifmatchkey-string-body-bodyinit-contenttype-string-expectedcrc32c-string-promiseputifmatchresult)
> primitive for non-JSON bodies).

- **Parameters**
  - `key` — same rules as `data.get`.
  - `body` — one of `string`, `Blob`, `ArrayBuffer`, or any `ArrayBufferView`
    (e.g. `Uint8Array`). Plain objects are rejected — for JSON, call
    `JSON.stringify(obj)` yourself and pass `"application/json"`.
  - `contentType` — required, non-empty string. Forwarded verbatim as
    the request `Content-Type` header. The server enforces a strict
    allowlist; the SDK pre-flights the always-denied set
    (`text/html`, `application/xhtml+xml`, `image/svg+xml`, JavaScript
    MIME variants) so you get a clearer error than a 415.
- **Returns** — a `Promise<{ crc32c }>`. `crc32c` is the Castagnoli
  checksum of the body, matching the value the server recorded and any
  subsequent `data.get` / `data.list` exposes for the same key.
- **Throws** — `BacklitError`. Common codes: `invalid_key`,
  `invalid_argument` (bad body type), `unauthenticated` (no signed-in
  user — shared-data writes require one), `forbidden` (a `viewer` is
  blocked from shared-data writes), `unsupported_content_type`,
  `payload_too_large`, `quota_exceeded`. See [Errors](#errors).

```js
// String.
const { crc32c } = await backlit.data.put("greeting", "hello world", "text/plain");

// JSON — the byte API takes a serialized string; prefer putJSON for objects.
await backlit.data.put(
  "config",
  JSON.stringify({ theme: "dark" }),
  "application/json",
);

// Binary — pass the bytes with the matching image content-type.
const png = await fetch("/icon.png").then(r => r.arrayBuffer());
await backlit.data.put("icon", png, "image/png");
```

If you pass a `Blob` whose `.type` disagrees with the explicit
`contentType` argument, the explicit argument wins and the SDK emits one
`console.warn` per page.

### `data.putIfMatch(key: string, body: BodyInit, contentType: string, expectedCrc32c: string): Promise<PutIfMatchResult>`

Atomic compare-and-swap, and **the default way to write any shared key
more than one session can touch** — bare [`data.put`](#dataputkey-string-body-bodyinit-contenttype-string-promise-crc32c-string)
is the exception, for a single writer or idempotent content. Writes
`body` to `key` **only if** the current stored value's `crc32c` equals
`expectedCrc32c`, otherwise resolves `{ matched: false, witness }`
carrying the current value — so two callers racing on the same key can
detect the conflict and merge instead of silently clobbering each other
(see [`data.put`'s last-writer-wins note](#backlitdata--glow-shared-keyvalue)).

The result is a discriminated union:

```ts
type StoredValue = { value: Blob; contentType: string; crc32c: string };
type PutIfMatchResult =
  | { matched: true;  crc32c: string }        // your write landed; crc32c of the new body
  | { matched: false; witness: StoredValue }; // someone else's value is there now
```

- **Parameters**
  - `key` — same rules as `data.get`.
  - `body` / `contentType` — same rules as `data.put`.
  - `expectedCrc32c` — the `crc32c` you expect the key to currently hold
    (the value a prior `get` / `put` / `list` returned). The **empty
    string `""` means create-only**: write only if the key is currently
    absent.
- **Returns** — a `Promise<PutIfMatchResult>`.
  - `{ matched: true, crc32c }` — the write landed; `crc32c` is the
    checksum of the body you just stored.
  - `{ matched: false, witness }` — your `expectedCrc32c` did **not**
    match the current value (you lost the race). `witness` is the current
    `{ value, contentType, crc32c }`, exactly as `data.get` would return
    it — use it to re-apply your change and retry.
- **Throws** — `BacklitError`. A **mismatch never throws** — it resolves
  `{ matched: false }`. The thrown cases are real failures: `not_found`
  (a **non-empty** `expectedCrc32c` against a key that does not exist —
  there is nothing to compare against), plus the same write-path codes as
  `data.put` (`invalid_key`, `invalid_argument`, `unauthenticated` for an
  anonymous session, `forbidden` for a `viewer`,
  `unsupported_content_type`, `payload_too_large`, `quota_exceeded`).
  See [Errors](#errors).

```js
// Create-only: claim a key iff nobody else has it yet.
const r = await backlit.data.putIfMatch("lock", "held", "text/plain", "");
if (!r.matched) {
  // Already taken — r.witness.value is the current holder's bytes.
}
```

For JSON state — the common case — use
[`data.putJSONIfMatch`](#datagetjson--dataputjson--dataputjsonifmatch), which
wraps this method with serialize/parse and an already-parsed witness, or
[`data.updateJSON`](#saving-shared-state-safely), which runs the whole
load-mutate-commit-retry loop for you.

The check-and-set is one atomic operation, so there is no read-then-write
window to lose data in — the only failure mode left is *contention* (too
many conflicting writers), which the retry cap surfaces honestly rather
than hides. The one constraint `putIfMatch` imposes is that **your merge
must be replayable**: on a conflict it runs again against the witness's
fresher state, so write it as a pure rebase ("apply my change to *this*
base"), never baking in "apply my one edit" assumptions that break on the
second pass.

Storing a whole dataset under one key is **correct** under `putIfMatch`,
just higher-contention — every edit competes for that one key's `crc32c`.
If independent records start colliding, split them into per-record keys
(`students.{id}`) so unrelated edits never touch the same key and retries
approach zero. A small team can keep one blob; a busy multi-writer board
should split. It's a contention optimization, not a correctness
requirement.

### `data.getJSON` / `data.putJSON` / `data.putJSONIfMatch`

```ts
backlit.data.getJSON<T = unknown>(key: string): Promise<{ value: T, crc32c: string }>
backlit.data.putJSON(key: string, value: unknown): Promise<{ crc32c: string }>
backlit.data.putJSONIfMatch<T = unknown>(key: string, value: unknown, expectedCrc32c: string): Promise<PutJSONIfMatchResult<T>>
backlit.data.updateJSON<T = unknown>(key: string, mutate: (current: T | undefined) => T, options?: { maxAttempts?: number }): Promise<{ value: T, crc32c: string }>
```

Convenience wrappers over `get` / `put` / `putIfMatch` that JSON-serialize
on write and parse on read, always using `application/json`. Keys, caps,
errors, and compare-and-swap semantics are identical to the byte methods —
only the body shape changes. The differences worth knowing:

- **`getJSON`** resolves `{ value, crc32c }` — `value` is the parsed object
  and `crc32c` is its hash, ready to pass straight into `putJSONIfMatch`.
  Throws `not_found` on a missing key (like `get`), and `invalid_body` if
  the stored bytes are not JSON (e.g. the key was written via raw `put`).
- **`putJSON`** stores `JSON.stringify(value)`. Throws `invalid_argument`
  if `value` has no JSON form (a bare `undefined`, a function, a circular
  reference, or a `BigInt`).
- **`putJSONIfMatch`** is the safe shared-write *primitive*. On a mismatch it
  resolves `{ matched: false, witness }` where `witness` is
  `{ value, crc32c }` with `value` **already parsed** — the retry loop
  reads `witness.value` directly, never a Blob. `expectedCrc32c === ""`
  means create-only. A mismatch never throws.
- **`updateJSON`** is the safe shared-write you actually reach for: it runs
  the `getJSON` → `mutate` → `putJSONIfMatch` → rebase-and-retry loop for you
  and resolves the committed `{ value, crc32c }`. `mutate(current)` gets the
  current value (`undefined` on first write) and returns the new value; it
  **must be replayable** (it reruns against a fresher value on conflict).
  Throws `BacklitError(code: "conflict")` if it can't win within `maxAttempts`
  (default 6). See [Saving shared state safely](#saving-shared-state-safely)
  for the worked example.

```ts
type PutJSONIfMatchResult<T> =
  | { matched: true;  crc32c: string }
  | { matched: false; witness: { value: T; crc32c: string } };
```

```js
// Single-writer save/load (userdata, or a data key only you write).
await backlit.data.putJSON("config", { theme: "dark" });
const { value: config } = await backlit.data.getJSON("config");
```

The canonical multi-writer retry loop is under
[Saving shared state safely](#saving-shared-state-safely).

### `data.delete(key: string): Promise<void>`

Remove the value stored at `key`. The deletion is observable via
[`backlit.on("storage.change")`](#backliton--realtime-subscriptions)
under `{store:"data", operation:"delete"}`.

- **Parameters**
  - `key` — same rules as `data.get`.
- **Returns** — a `Promise<void>`.
- **Throws** — `BacklitError`. Common codes: `invalid_key`,
  `unauthenticated` (no signed-in user — shared-data writes require
  one), `forbidden` (a `viewer` is blocked from shared-data writes),
  `not_found` (no value at this key — deletion is **not** idempotent;
  call `get` first or catch the code if you want it to be).

```js
try {
  await backlit.data.delete("greeting");
} catch (e) {
  if (e.code !== "not_found") throw e;
}
```

### `data.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ key: string, crc32c: string, contentType: string, size: number }>, next: string }>`

List the keys in the glow-shared keyspace whose names start with
`prefix`. The empty prefix (or omitting the argument) lists everything.

Each entry carries the body's CRC32C, the stored `contentType`, and the
body's `size` in bytes, so apps can dedup against locally-cached state
and learn each value's type and size in one round trip — no per-key
`get` needed just to discover what changed.

The result is **paginated**: it returns one bounded page of entries plus
an opaque `next` cursor. To walk the whole listing, re-call `list` with
`{ cursor: next }` until `next` is `""` (the empty string means the
listing is exhausted). Entries come back in ascending key order, and that
same order is stable across pages.

- **Parameters**
  - `prefix` — optional string, 0–256 bytes, same charset as a key. May
    be empty.
  - `opts.cursor` — optional opaque string. Pass a prior page's `next` to
    fetch the following page; omit (or `""`) for the first page. A
    malformed cursor is rejected with `BacklitError(code:"invalid_cursor")`.
  - `opts.limit` — optional page size. Clamped to `[1, 1000]`; omitting it
    uses the default of 500. An over-range value is clamped, never
    rejected.
- **Returns** — a `Promise<{ items, next }>`: `items` is the array of
  matching entries for this page (each `{ key, crc32c, contentType, size }`);
  `next` is the cursor for the following page, or `""` when there are no
  more entries.
- **Throws** — `BacklitError` with `code === "invalid_prefix"` if the
  prefix violates the charset or length rule, or `code === "invalid_cursor"`
  if `opts.cursor` is not a valid pagination token.

```js
// Walk every "posts." key, one page at a time.
const stale = [];
let cursor = "";
do {
  const page = await backlit.data.list("posts.", { cursor, limit: 100 });
  for (const e of page.items) {
    if (e.crc32c !== cache.get(e.key)) stale.push(e);
  }
  cursor = page.next;
} while (cursor);
// page.items entries look like:
// { key: "posts.0001", crc32c: "+IxOLg==", contentType: "application/json", size: 128 }
```

### `data.getBatch(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<BatchEntry>, next: string }>`

Like [`list`](#datalistprefix-string-opts--cursor-string-limit-number--promise-items-array-key-string-crc32c-string-contenttype-string-size-number--next-string),
but each page entry **carries the value** — so a whole page of small keys
comes back in one round trip instead of a `list` followed by a `get`
per key. Same inputs, same ascending-key order, same
`{ items, next }` pagination.

Each entry is the `list` metadata (`key`, `crc32c`, `contentType`,
`size`) plus one of two arms — discriminate on `omitted`, exactly as you
narrow a `putIfMatch` result on `matched` (both arms are **normal**
outcomes, not errors):

- **included** (`omitted` absent or `false`) — carries `value`, a `Blob`
  of the stored bytes. A zero-byte `Blob` is a real empty value, never an
  omission.
- **metadata-only** (`omitted: true`) — no `value`, plus an optional
  `reason`. A single value **larger than the whole per-response budget**
  arrives this way with `reason: "too_large"`: a normal result to plan
  for, not an error — read that one key with a plain
  [`get`](#datagetkey-string-promise-value-blob-contenttype-string-crc32c-string).
  `reason` is an open vocabulary; tolerate values you don't recognize.

A page's included entries stop when their combined stored-data size
reaches the per-response budget — **default 10 MiB, the same measure as
the per-object cap** (the size of the data you stored, not of the
response payload). So a page can hold **fewer** entries than `limit`
asked for, and end before the listing is exhausted. **`next` is the sole
completion signal:** walk until `next` is `""`, never stop because a page
came back short. (Every page makes progress — it always includes or omits
at least one entry, so the walk can't stall.)

- **Parameters**
  - `prefix` — optional string, same rules as
    [`data.list`](#datalistprefix-string-opts--cursor-string-limit-number--promise-items-array-key-string-crc32c-string-contenttype-string-size-number--next-string).
  - `opts.cursor` — optional opaque string; pass a prior page's `next`.
    Omit (or `""`) for the first page. A malformed cursor throws
    `BacklitError(code:"invalid_cursor")`.
  - `opts.limit` — optional page size, **clamped to `[1, 500]`; omitting
    it uses the default of 100** (an over-range value is clamped, never
    rejected). The per-response value budget may end a page below this
    count regardless.
- **Returns** — a `Promise<{ items, next }>`: `items` is this page's
  entries (each a `BatchEntry` — an included or metadata-only arm);
  `next` is the cursor for the following page, or `""` when the listing is
  exhausted.
- **Throws** — `BacklitError` with `code === "invalid_prefix"` or
  `code === "invalid_cursor"` (same as `list`), or `quota_exceeded` if the
  call lands after the monthly bandwidth allowance ran out. A response
  that fails to parse is a **transient** failure — retry the same cursor;
  a page that parses as complete JSON is a complete page.

```js
// Load every "posts." value in bulk, page by page.
const posts = new Map();
let cursor = "";
do {
  const page = await backlit.data.getBatch("posts.", { cursor, limit: 200 });
  for (const e of page.items) {
    if (e.omitted) {
      // Too big for a batch page — fetch it on its own.
      if (e.reason === "too_large") posts.set(e.key, await backlit.data.get(e.key));
      continue;
    }
    posts.set(e.key, e.value); // e.value is a Blob
  }
  cursor = page.next;
} while (cursor); // next === "" means done
```

### `data.getBatchJSON<T>(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<BatchJSONEntry<T>>, next: string }>`

The [`getBatch`](#datagetbatchprefix-string-opts--cursor-string-limit-number--promise-items-arraybatchentry-next-string) sibling
that JSON-parses each included value for you — the bulk companion to
[`getJSON`](#datagetjson--dataputjson--dataputjsonifmatch), the way
`getBatch` is the bulk companion to `get`. Same inputs, same pagination,
same budget and `omitted` discrimination; only the value shape differs.

Per included entry, the value is the parsed `T` (parsing is
content-type-agnostic — every included value is `JSON.parse`d, matching
`getJSON`). A JSON `null` (or `0` / `""` / `false`) is a **legal parsed
value** — the discriminant is `omitted`, never value-nullness. A value
that is **not valid JSON** degrades that one entry to the metadata-only
arm with `reason: "not_json"` (the raw bytes are not carried; the rest of
the page is unaffected), so you can spot a key written via raw
[`put`](#dataputkey-string-body-bodyinit-contenttype-string-promise-crc32c-string)
without a failed `getJSON`. A server-omitted entry passes through with its
own `reason` (e.g. `"too_large"`). Each entry still carries `crc32c`, so a
value you read here feeds straight into
[`putJSONIfMatch`](#datagetjson--dataputjson--dataputjsonifmatch) — the
bulk way to seed an `updateJSON` retry loop with values you already have.

- **Parameters** — identical to `data.getBatch`. One `T` applies to the
  whole page.
- **Returns** — a `Promise<{ items, next }>` where each entry's included
  `value` is the parsed `T`.
- **Throws** — same as `data.getBatch`.

```js
// Read a page of JSON posts; a non-JSON key degrades to omitted, not a throw.
const page = await backlit.data.getBatchJSON("posts.");
for (const e of page.items) {
  if (e.omitted) continue;      // too_large, or not_json
  render(e.key, e.value);       // e.value is the parsed object
}
```

---

## `backlit.userdata` — per-user data silo

Same shape as `backlit.data`, but each signed-in user has their own
isolated silo. Two users on the same glow cannot see each other's
`userdata`.

**Available to signed-in users only** — a Private glow, an Accounts glow
after the visitor signs in, or a Public glow's allowlisted user after they
sign in. With no user session (an anonymous or signed-out visitor), every
method throws `BacklitError` with `code === "unauthenticated"` and an
actionable message. Call `backlit.auth.me()` first if you need to branch.

### `userdata.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string }>`
### `userdata.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>`
### `userdata.delete(key: string): Promise<void>`
### `userdata.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ key: string, crc32c: string, contentType: string, size: number }>, next: string }>`
### `userdata.getBatch(prefix?, opts?)` / `userdata.getBatchJSON<T>(prefix?, opts?)`
### `userdata.putIfMatch(key: string, body: BodyInit, contentType: string, expectedCrc32c: string): Promise<PutIfMatchResult>`
### `userdata.getJSON<T>(key: string): Promise<{ value: T, crc32c: string }>`
### `userdata.putJSON(key: string, value: unknown): Promise<{ crc32c: string }>`
### `userdata.putJSONIfMatch<T>(key: string, value: unknown, expectedCrc32c: string): Promise<PutJSONIfMatchResult<T>>`
### `userdata.updateJSON<T>(key: string, mutate: (current: T | undefined) => T, options?): Promise<{ value: T, crc32c: string }>`

Signatures, parameters, return types, validation rules, and the `*JSON`
[convenience wrappers](#datagetjson--dataputjson--dataputjsonifmatch) are
identical to the `backlit.data.*` methods of the same name. The only
behavioural difference is the `unauthenticated` rewrite above.

`userdata.delete` removes a key from the calling user's silo — never
from another user's. Successful deletes emit a privacy-scoped
[storage.change event](#backliton--realtime-subscriptions) (`store:
"userdata"`) that only the writing user's own sessions receive.

```js
const user = await backlit.auth.me();
if (user === null) {
  // userdata not available — fall back to glow-shared.
  return;
}

await backlit.userdata.putJSON("preferences", { theme: "dark" });

const { value: prefs } = await backlit.userdata.getJSON("preferences");
```

---

## `backlit.records` — public-read, owner-write store

A third key/value store, between `backlit.data` (everyone reads and writes one
shared set) and `backlit.userdata` (each user's private silo). With
`backlit.records`, **any signed-in user creates records and reads everyone's,
but only the record's creator — or an `admin` — may overwrite or delete it.**
It's the store for user-generated content many people see but shouldn't be able
to edit on each other's behalf: forum posts, comments, profiles, submitted
entries, a shared gallery where each image belongs to whoever uploaded it.

**Available to signed-in users only** — a Private glow, an Accounts glow
after the visitor signs in, or a Public glow's signed-in user. With no user
session every method throws `BacklitError` with `code === "unauthenticated"`,
exactly like `backlit.userdata`. The store is **free** — no subscription required. (Unlike
shared `data`, a `viewer` CAN create records — so on an Accounts glow, records
is the store self-serve users can write to out of the box.)

### `records.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string, owner: string }>`
### `records.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>`
### `records.delete(key: string): Promise<void>`
### `records.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ key: string, crc32c: string, contentType: string, size: number, owner: string }>, next: string }>`
### `records.getBatch(prefix?, opts?)` / `records.getBatchJSON<T>(prefix?, opts?)`
### `records.putIfMatch(key, body, contentType, expectedCrc32c): Promise<{ matched: true, crc32c, owner } | { matched: false, witness: { value, contentType, crc32c, owner } }>`
### `records.getJSON<T>(key)` / `records.putJSON(key, value)` / `records.putJSONIfMatch<T>(key, value, expectedCrc32c)` / `records.updateJSON<T>(key, mutate, options?)`

Signatures, parameters, validation rules, and the `*JSON`
[convenience wrappers](#datagetjson--dataputjson--dataputjsonifmatch) match the
`backlit.data.*` methods of the same name, with these differences:

- **`owner`** — `get` and every `list` entry carry an `owner` string: the user
  id of the record's creator (an opaque per-user id, not the email). Every
  `getBatch` / `getBatchJSON` entry carries it too. `putIfMatch` reports it —
  on a successful write and on the mismatch witness — and so do the JSON
  wrappers (`getJSON`, `putJSONIfMatch`, and `updateJSON` all resolve
  `{ value, crc32c, owner }`), so an `updateJSON` loop can tell who owns a key.
- **Anyone signed in may create.** Writing a key that does not exist yet claims
  it for you. A `viewer` *can* create records — the shared-data viewer write
  block does not apply here. Overwriting or deleting a record you do not own
  throws `BacklitError(code: "forbidden")`, unless you are an `admin`.
- **First-come claim.** `records.putIfMatch(key, body, ct, "")` (create-only)
  writes only while the key is still unclaimed, so two users racing for the same
  key resolve deterministically — the loser gets `{ matched: false }`.
- **Concurrent-edit safety.** A `records.delete` or `records.reassign` that
  races a concurrent overwrite or reassign of the same key throws
  `BacklitError(code: "conflict")` (HTTP 412) — the record changed between the
  ownership check and the action. Re-read (`get`) and retry.

```js
// Anyone signed in posts a comment under their own key.
await backlit.records.putJSON(`comment.${Date.now()}`, { text: "nice!" });

// Everyone reads every comment, and sees who wrote each one.
// list() is paginated — `items` is this page, `next` continues it.
const { items } = await backlit.records.list("comment.");
for (const { key, owner } of items) {
  const { value } = await backlit.records.getJSON(key);
  render(value, owner);
}
```

### `records.reassign(key: string, newOwnerUid: string): Promise<void>`

**Admin only.** Transfers a record's ownership to `newOwnerUid` (a user id — for
example an `owner` value read from `get` / `list`). The body is untouched; only
the owner moves. A non-`admin` caller throws `BacklitError(code: "forbidden")`,
an unknown key throws `not_found`, a malformed `newOwnerUid` throws
`invalid_argument` (HTTP 400), and a call that races a concurrent
write/reassign of the same key throws `conflict` (HTTP 412) — retry. The SDK
does not pre-check admin — the server is the boundary.

```js
// An admin hands a record over to another user.
await backlit.records.reassign("entry.42", someUserId);
```

---

## `backlit.capture` — write-only capture store

For data the glow's app code needs to capture but never read back from
the browser: form submissions, telemetry events, contact requests,
support tickets. The server returns an opaque `{prefix}-{guid}` **handle**
on create.

Writes (`create` / `update`) are available on any glow — no user session
required. Reads, enumeration, and deletion (`get` / `list` / `delete`) are
**admin only** — they need a signed-in user whose `auth.me()` `permission`
is `admin` (a Public glow's app can sign an allowlisted admin in for exactly
this — see `auth.login()`), so ordinary app code can capture data but can
never read it back even if the page is compromised.

### `capture.create(prefix: string, body: BodyInit, contentType: string): Promise<{ handle: string, crc32c: string }>`

Store a new value and get back its handle plus the body's CRC32C. The
handle is `"{prefix}-{guid}"` — the `prefix` you supply, prepended
to a server-generated 36-character lower-hex GUID in 8-4-4-4-12 format
(e.g. `create("contact", ...)` → `"contact-3b1f4a2c-1d2e-4f5a-8b9c-0d1e2f3a4b5c"`).
The prefix is a human-meaningful label that lets whoever reads these
objects out of band group and recognise them; the GUID keeps each name
unguessable and unique.

- **Parameters**
  - `prefix` — required string, 1-256 bytes, alphanumerics plus `-` `.`
    `_` `~` (same charset as `data.*` keys — see [Validation](#validation)).
  - `body`, `contentType` — same rules as `data.put`.
- **Returns** — a `Promise<{ handle, crc32c }>` for the new `{prefix}-{guid}`
  handle and its integrity hash.
- **Throws** — `BacklitError`. Common codes: `invalid_key`,
  `invalid_argument`, `unsupported_content_type`, `payload_too_large`,
  `quota_exceeded`.

```js
const { handle } = await backlit.capture.create(
  "contact",
  JSON.stringify({ email: form.email.value, message: form.body.value }),
  "application/json",
);
// handle === "contact-3b1f4a2c-..." — show the user the handle if you
// want them to be able to reference their submission later, or just
// store it for your own records.
form.querySelector(".receipt").textContent = `Submitted (ref ${handle})`;
```

### `capture.update(handle: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>`

Replace the value stored at an existing handle. Open to any session —
the handle is the capability.

- **Parameters**
  - `handle` — string, the full `"{prefix}-{guid}"` handle returned by
    `create`. A bare GUID is **not** accepted. See
    [Validation](#validation).
  - `body`, `contentType` — same rules as `create` / `data.put`.
- **Returns** — a `Promise<{ crc32c }>` for the integrity hash of the
  new value.
- **Throws** — `BacklitError`. Common codes: `invalid_handle`,
  `unknown_handle` (no record at that handle), plus the body / size
  codes from `create`.

```js
// handle is the value create returned, e.g. "contact-3b1f4a2c-..."
await backlit.capture.update(
  handle,
  JSON.stringify({ ...previous, resolved: true }),
  "application/json",
);
```

### `capture.get(handle: string): Promise<{ value: Blob, contentType: string, crc32c: string }>`

**Admin only.** Read the value stored at a handle. Resolves to the same
shape as `data.get` (the `value` Blob, `contentType` as stored,
`crc32c` of the body).

- **Parameters**
  - `handle` — the `"{prefix}-{guid}"` handle from `create`.
- **Throws** — `BacklitError`. `forbidden` when the signed-in user is
  not an admin; `unauthenticated` for an anonymous or signed-out visitor
  (no user session); `unknown_handle` if nothing is stored at
  that handle; `invalid_handle` for a malformed handle.

```js
// Only an admin can read captured submissions back.
const { value } = await backlit.capture.get(handle);
const submission = JSON.parse(await value.text());
```

### `capture.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ handle: string, crc32c: string, contentType: string, size: number }>, next: string }>`

**Admin only.** Enumerate the handles stored in this glow's private
store, each with its body's CRC32C, stored `contentType`, and `size` in
bytes. Returns handles and metadata only, never values.

The result is **paginated**, exactly like `data.list`: one bounded page
of handles plus an opaque `next` cursor — re-call with `{ cursor: next }`
until `next` is `""`.

- **Parameters**
  - `prefix` — optional string, same charset as a `data.*` prefix. It
    matches against the handle, so passing a `create` key lists that
    group — `list("contact")` returns every `contact-*` handle. Omit (or
    pass `""`) to list every handle.
  - `opts.cursor` — optional opaque continuation token (a prior page's
    `next`); omit for the first page. A malformed cursor throws
    `BacklitError(code:"invalid_cursor")`.
  - `opts.limit` — optional page size, clamped to `[1, 1000]` (default 500);
    an over-range value is clamped, never rejected.
- **Returns** — a `Promise<{ items, next }>`: `items` is this page's
  handles (each `{ handle, crc32c, contentType, size }`); `next` is the
  cursor for the following page, or `""` when exhausted.
- **Throws** — `BacklitError`. `forbidden` when the signed-in user is
  not an admin; `unauthenticated` for an anonymous or signed-out visitor
  (no user session); `invalid_prefix` for a bad prefix;
  `invalid_cursor` for a bad `opts.cursor`.

```js
// Only meaningful for an admin user; branch on auth.me() first.
if ((await backlit.auth.me())?.permission === "admin") {
  let cursor = "";
  do {
    const page = await backlit.capture.list("contact", { cursor });
    for (const { handle } of page.items) {
      const { value } = await backlit.capture.get(handle);
      console.log(handle, await value.text());
    }
    cursor = page.next;
  } while (cursor);
}
```

### `capture.delete(handle: string): Promise<void>`

**Admin only.** Remove the object stored at a handle.

- **Parameters**
  - `handle` — the `"{prefix}-{guid}"` handle from `create`.
- **Throws** — `BacklitError`. `forbidden` / `unauthenticated` (same
  gating as `get`); `unknown_handle` if the handle was never created;
  `invalid_handle` for a malformed handle.

```js
await backlit.capture.delete(handle);
```

Writes (`create` / `update`) are open to any session; reads, enumeration,
and deletion (`get` / `list` / `delete`) are admin-only. That asymmetry
is intentional — ordinary app code can *capture* data here but can never
*read it back*, even if the page's JavaScript is compromised, while an
admin operator retains a way to inspect and clean up what was captured.

---

## `backlit.on` — realtime subscriptions

The SDK delivers realtime notifications whenever a `data.*`,
`userdata.*`, or `records.*` key is set or deleted, so apps can keep two tabs in
sync, react to a peer's write, or invalidate a cached read without polling.
Subscribe with `backlit.on("storage.change", handler)`.

```js
const off = backlit.on("storage.change", (event) => {
  // event = { store, operation, key, crc32c, owner? }
  if (event.store === "data" && event.key === "leaderboard") {
    refreshLeaderboard();
  }
});

// Later — stop receiving events.
off();
```

### Event shape

```ts
interface StorageChangeEvent {
  readonly store:     "data" | "userdata" | "records";
  readonly operation: "set"  | "delete";
  readonly key:       string;
  readonly crc32c:    string; // base64 big-endian uint32; empty on delete
  readonly owner?:    string; // records events only — the record's owner uid
}
```

`crc32c` is the base64-encoded Castagnoli CRC32C of the body — the
same value `get` / `list` entries and the `data.put` response carry. On a `"delete"` event it is the empty string. Apps that maintain a local
cache can compare a freshly-fetched body's CRC32C against the event's
to detect duplicate deliveries.

### Scoping rules

- **`data.*` events** reach every connected session of the glow.
- **`records.*` events** are unscoped, like `data.*` — every connected
  session receives them — and additionally carry `owner` (the record's
  owner uid). A `reassign` arrives as a `set` event with the new owner.
- **`userdata.*` events** reach only the writing user's own sessions.
  Two users on the same Private glow never see each other's `userdata`
  keys via `on` (matching the read-side privacy guarantee).
- **`capture.*` is silent.** Handle creation and updates do not
  emit events — the store is opaque write-only telemetry and
  broadcasting handle minting would defeat its purpose.

### Delivery semantics

- **Best-effort.** Events that arrive during a reconnect window are
  dropped. If your app's correctness depends on never missing a
  change, call `data.list()` / `userdata.list()` once on the first
  event after registration and treat `on` as a hint to re-check rather
  than as the source of truth.
- **Deferring a remote change is safe with a compare-and-swap.** If you
  postpone applying an event — say, to avoid disrupting a form the user
  has open — your in-memory copy is now known-stale. Write it back with
  [`updateJSON`](#saving-shared-state-safely) (or the
  [`putIfMatch`](#dataputifmatchkey-string-body-bodyinit-contenttype-string-expectedcrc32c-string-promiseputifmatchresult)
  primitive directly), not bare `put`: it rebases onto the newer value and
  retries instead of silently overwriting it. Deferral plus a compare-and-swap
  is safe; deferral plus bare `put` is a guaranteed lost update.
- **At-least-once is possible.** Duplicate deliveries can occur under
  retries. `crc32c` is the easiest dedup key.
- **Same-tab writes fire too.** A `data.put` from this tab triggers
  `storage.change` for handlers registered on this same tab.
- **Handler order matches registration.** Handlers are called in the
  order they were registered. A thrown handler does not prevent the
  others from running; the exception is reported via `console.error`.

### Reconnect

The SDK opens a single WebSocket on the first
`on("storage.change", …)` call and reuses it for every subsequent
subscription. If the socket drops the SDK reconnects with exponential
backoff (1s → 2s → 4s → … capped at 30s, with ±25% jitter). The socket
closes 5 seconds after the last `unsubscribe()` so a tab that briefly
drops to zero handlers (a route change in an SPA) doesn't pay the
reconnect cost.

### Future events

The first argument is currently restricted to the literal string
`"storage.change"`. Other event names will be added in future v1 minor
releases without breaking existing callers.

---

## `backlit.push` — notifications

`backlit.push` lets your app enroll the signed-in user for **push
notifications** — messages that reach the user even when the glow's tab is
closed, delivered by the browser and the operating system. Backlit installs
and runs the notification machinery for you; your app decides *when* to offer
to enable notifications and reacts to taps.

Every glow is also **installable** — a visitor can add it to their home screen
or app launcher as a standalone app. That happens automatically; there is no
API to call, and it works whether or not you use push.

**Requirements.** `subscribe` / `state` / `unsubscribe` / `test` need a
**signed-in user** (a Private glow, an Accounts glow after the visitor signs
in, or a Public glow's signed-in user) — an anonymous or signed-out visitor
gets `BacklitError(code: "unauthenticated")`. Push must be **enabled for the
glow** (turned on at deploy time); when it isn't, `subscribe` throws
`unsupported`. Browser support varies — always gate your UI on
[`supported()`](#pushsupported--supported-reason-requiresinstall-installed)
first.

**Enabling push.** To turn push on for a glow, ship a root `notify.json` in
the deploy bundle declaring when a notification fires — see the
[notify.json schema](https://sdk.backlit.run/schema/notify.schema.json) for its
fields and the [Push notifications end-to-end guide](https://sdk.backlit.run/guides/push-end-to-end.md)
for the full author story.

> **Don't register your own service worker.** Backlit owns the glow's service
> worker and registers it for you on page load. Registering your own would
> replace Backlit's and break push + install. (A deploy bundle that reaches for
> `localStorage` / `indexedDB` is rejected for the same reason — the platform
> owns that layer; use `window.backlit.*`.)

```js
// Offer a notification opt-in only where it can actually work.
const cap = backlit.push.supported();
if (cap.supported) {
  enableBtn.onclick = async () => {
    try {
      await backlit.push.subscribe();          // prompts the user for permission
      await backlit.push.test();               // send yourself a test push
    } catch (e) {
      if (e.code === "permission_denied") showTip("You blocked notifications.");
      else throw e;
    }
  };
} else if (cap.requiresInstall) {
  showTip("Install this app to your home screen to turn on notifications.");
} else {
  enableBtn.hidden = true;                      // this browser can't do push
}

// React to a notification tap (or a permission change) while a tab is open.
backlit.push.watch((e) => {
  if (e.type === "notification-click") location.assign(e.url);
});
```

### `push.supported(): { supported, reason?, requiresInstall, installed }`

A **synchronous** capability probe — the only SDK method that is not `async`,
and the only one that never throws. Call it to decide whether to show an
"Enable notifications" control at all.

- **Returns** (synchronously):
  - `supported` — `true` when this browser can subscribe to push right now.
  - `reason` — when `supported` is `false`, a short tag for *why*:
    `"off_host"` (the SDK isn't running on a deployed glow), `"insecure_context"`,
    `"no_service_worker"`, `"no_push_manager"`, or `"requires_install"`. Omitted
    when `supported` is `true`.
  - `requiresInstall` — `true` on a platform where push works **only** after the
    app is installed to the home screen, and it isn't installed yet (this is
    iOS/iPadOS Safari). Prompt the user to install, then re-probe.
  - `installed` — whether the page is currently running as an installed
    (standalone) app.
- **Never throws**, even off a glow host or in an unusual embedding (it returns
  `{ supported: false, requiresInstall: false, installed: false }`).

### `push.state(): Promise<{ permission, subscribed, endpointId }>`

Report the current push state for this browser and signed-in user.

- **Returns** — a `Promise<{ permission, subscribed, endpointId }>`:
  - `permission` — the OS notification permission: `"granted"`, `"denied"`, or
    `"default"` (not yet asked).
  - `subscribed` — whether this browser currently holds a push subscription.
  - `endpointId` — the subscription's id when subscribed, else `""`.
- **Throws** — `BacklitError`. `unsupported` / `requires_install` when
  `supported()` is `false`; `unsupported_host` off a glow host.

### `push.subscribe(): Promise<{ endpointId, permission: "granted" }>`

Enroll the signed-in user's browser for push. This prompts the user for
notification permission (if not already granted), sets up the subscription, and
registers it with Backlit — so a later `push.test()` (or a notification your
glow's rules send) reaches this browser.

- **Returns** — a `Promise<{ endpointId, permission: "granted" }>` for the new
  subscription's id. (It only resolves when permission was granted.)
- **Throws** — `BacklitError`:
  - `permission_denied` — the user declined (or had already blocked) the
    notification prompt.
  - `unsupported` — the browser can't do push, or push isn't enabled for this
    glow.
  - `requires_install` — the platform needs the app installed first (iOS).
  - `unsupported_host` — the SDK isn't running on a deployed glow.
  - `unauthenticated` — no signed-in user.

```js
try {
  const { endpointId } = await backlit.push.subscribe();
  console.log("subscribed", endpointId);
} catch (e) {
  if (e.code === "permission_denied") {
    // The user said no — don't nag; offer it again later from a clear action.
  } else {
    throw e;
  }
}
```

### `push.unsubscribe(): Promise<void>`

Remove this browser's push subscription (from the browser and from Backlit). A
no-op when there is no active subscription. Requires a signed-in user; throws
`unsupported` / `requires_install` / `unsupported_host` on the same conditions
as `subscribe`.

### `push.test(): Promise<void>`

Send a test push to the signed-in user's own subscriptions — a quick way for a
just-enrolled user to confirm notifications work.

- **Throws** — `BacklitError`. `not_found` when the user has no active
  subscription (call `subscribe` first); `unauthenticated` with no signed-in
  user; `unsupported_host` off a glow host.

### `push.watch(callback): () => void`

Observe push-related events **while a tab is open**, and get back an
unsubscribe function. The callback receives one of:

- `{ type: "notification-click", url }` — the user tapped a delivered
  notification; `url` is its tap target (navigate there, or refresh a view).
- `{ type: "subscription-changed" }` — the browser rotated the subscription and
  Backlit re-registered it; re-read `state()` if you display subscription
  status.
- `{ type: "permission-changed", permission }` — the OS notification permission
  changed to `permission`.

`watch` never throws and is a no-op off a glow host. It observes only; it does
not deliver the notification payloads themselves (the browser shows those).

```js
const off = backlit.push.watch((e) => {
  if (e.type === "notification-click") location.assign(e.url);
});
// Later: off();
```

---

## Errors

Every method (except `auth.me()`, which never throws) rejects its
returned `Promise` with a single error class,
`BacklitError`. Discriminate on `.code`.

```ts
class BacklitError extends Error {
  readonly code: string;     // one of the codes in the table below
  readonly status: number;   // HTTP status, or 0 for network / client-side
  readonly backlitCause: unknown; // underlying Error or fetch failure, when there is one
}
```

`instanceof BacklitError` works as expected. `.code` matches the
server's `{error:{code,message}}` envelope verbatim so the same switch
statement handles both server-emitted and client-side errors.

```js
try {
  await backlit.data.put("key", new Date(), "application/json");
} catch (e) {
  if (e instanceof backlit.BacklitError) {
    switch (e.code) {
      case "invalid_argument": /* the Date isn't a BodyInit */ break;
      case "invalid_key":      /* charset / length problem */ break;
      case "quota_exceeded":   /* account storage/bandwidth allowance hit */ break;
      default: throw e;
    }
  } else {
    throw e;
  }
}
```

### Error codes

| `code`                       | Source       | Meaning                                                                                          |
| ---------------------------- | ------------ | ------------------------------------------------------------------------------------------------ |
| `not_found`                  | server       | `get` / `getJSON` / `delete` on a key that has never been written (any store), a `putIfMatch` with a non-empty `expectedCrc32c` against an absent key, or `records.reassign` on an unknown key. |
| `unauthenticated`            | server / SDK | A shared-data write (`data.put`/`delete`/`putIfMatch` and the JSON write wrappers), `userdata.*`, or `records.*` called without a user session, or `capture.get`/`list`/`delete` or `auth.users()` with no signed-in user (an anonymous or signed-out visitor, an expired session). |
| `cross_glow_token`           | server       | Your session cookie is bound to a different glow. Rare; indicates cookie reuse.                  |
| `forbidden`                  | server       | A `viewer` attempted `data.put` / `data.delete`; a non-creator/non-`admin` attempted to overwrite, delete, or `reassign` a `records` key; or a non-`admin` attempted `capture.get`/`list`/`delete` or `auth.users()`. (`capture.create`/`update` and `records` *create* are unaffected.) |
| `invalid_key`                | both         | Key violates the charset (`[A-Za-z0-9._~-]`) or length (1–256 bytes) rule.                       |
| `invalid_prefix`             | both         | Prefix violates the charset or length rule (length 0 is allowed).                                |
| `invalid_cursor`             | server       | A `list` `opts.cursor` is not a valid pagination token. Pass a prior page's `next` verbatim, or omit it for the first page. |
| `invalid_handle`             | both         | `capture.update`/`get`/`delete` handle is not the `{prefix}-{guid}` form (a bare GUID is rejected). |
| `unknown_handle`             | server       | `capture.update`/`get`/`delete` against a well-formed handle that has no record.             |
| `invalid_body`               | both         | SDK-side when `getJSON` reads a key whose stored bytes are not valid JSON; server-side when the request body could not be decoded. |
| `invalid_argument`           | both         | Client-side rejection (status 0): wrong `body` type, missing `contentType`, or a `putJSON` value with no JSON form (`undefined` / function / circular / `BigInt`). Also server-side (HTTP 400) for a malformed `records.reassign` owner id. |
| `unsupported_content_type`   | both         | `Content-Type` isn't on the allowlist (or is one of the always-denied executable types).         |
| `payload_too_large`          | server       | Body exceeds the server's per-object cap (default 10 MiB).                                       |
| `quota_exceeded`             | server       | Two cases, split by `status`: **413** — the write would push you over your account's storage allowance (the just-written object has already been rolled back); **429** — the account's monthly bandwidth/request allowance is exhausted, which rejects **every** call, reads and lists included, until the monthly reset. |
| `method_not_allowed`         | server       | A non-SDK caller used an unsupported HTTP method. Should never fire from the SDK.                |
| `invalid_host`               | server       | The request reached Backlit with no recognisable glow Host. Should never fire from the SDK.      |
| `glow_not_found`             | server       | The glow has been deleted (or never existed) since the page loaded.                              |
| `glow_dark`                  | server       | The glow has been switched to `Dark` since the page loaded.                                      |
| `region_mismatch`            | server       | The request reached a Backlit region different from the glow's region.                           |
| `network`                    | SDK          | `fetch` itself rejected: offline, CORS, aborted, etc. `.backlitCause` holds the underlying error. |
| `conflict`                   | both         | SDK-side (status 0): `updateJSON` exhausted its compare-and-swap retries (`maxAttempts`, default 6) under write contention. Server-side (HTTP 412): a `records.delete` / `records.reassign` lost a concurrent-write race — re-read and retry. |
| `unsupported_host`           | SDK          | A `data.*` / `userdata.*` / `records.*` / `capture.*` call — or `auth.users()` / `push.*` — ran while the SDK was loaded outside a deployed glow host. The SDK has no data surface off-glow; deploy the glow to use it. (`auth.me()` and `push.supported()` do not throw it — they resolve `null` / `{ supported:false }` instead.) |
| `unsupported`                | SDK          | `push.subscribe`/`state`/`unsubscribe` — this browser can't do push (no service worker / Push API / secure context), or push isn't enabled for this glow. Gate on `push.supported()` first. |
| `requires_install`           | SDK          | `push.subscribe`/`state`/`unsubscribe` on a platform where push works only from an installed home-screen app (iOS Safari) and it isn't installed yet. `push.supported()` reports this as `requiresInstall: true`. |
| `permission_denied`          | SDK          | `push.subscribe` — the user declined (or had previously blocked) the notification-permission prompt.                                                    |
| `internal`                   | both         | Server returned 5xx (or a non-JSON 4xx), or the response body wasn't parseable.                  |

A few patterns worth pulling out:

- **`not_found` vs. `glow_not_found`** — `not_found` is "no value at
  this key under a live glow"; `glow_not_found` is "the glow itself is
  gone". Handle them differently.
- **`payload_too_large` vs. `quota_exceeded`** — the first is a per-
  object limit (don't upload a 100 MB file); the second is an account
  allowance — storage (a 413 on writes) or monthly bandwidth (a 429 on
  any call, reads included).
- **`unauthenticated` from `userdata.*`** — the SDK rewrites the
  server's terse message to one that names the actual constraint
  ("requires a signed-in user; an anonymous or signed-out visitor cannot
  use this surface").
- **A `putIfMatch` mismatch is not an error** — it **resolves**
  `{ matched: false, witness }` rather than rejecting, so it carries no
  `code`. The only thrown case unique to `putIfMatch` is `not_found`: a
  **non-empty** `expectedCrc32c` against a key that does not exist.
  `updateJSON` (which loops on `putIfMatch` for you) instead absorbs
  mismatches as retries and throws `conflict` only when it gives up.

### `BacklitError.status`

The HTTP status code that produced the error, when one exists. `0` for
client-side validation failures (`invalid_argument`, `invalid_key`,
`invalid_prefix`, `invalid_handle`, the always-denied content-type
pre-flight), for `conflict` raised by `updateJSON` (gave up after its
retries — a server-raised `records` race carries HTTP 412 instead), for
`unsupported_host` (the SDK was loaded off a deployed glow host), and
for transport failures (`network`).

---

## Validation

The SDK pre-flights all inputs so you see a `BacklitError` from the
caller's `await` rather than from a wasted round-trip. The rules below
mirror the server exactly.

### Keys (every `data.*` / `userdata.*` / `records.*` method that takes a key — `get`, `put`, `putIfMatch`, `delete`, `getJSON`, `putJSON`, `putJSONIfMatch`, `updateJSON`)

- 1–256 bytes long.
- Characters: the RFC 3986 unreserved set — ASCII letters (`A–Z`, `a–z`),
  digits (`0–9`), hyphen (`-`), period (`.`), underscore (`_`), tilde (`~`).
- **No slashes**, no whitespace, no other punctuation.

Failures throw `BacklitError` with `code === "invalid_key"`.

### Prefixes (`data.list`, `userdata.list`, `records.list`)

Applies to every method that takes a `prefix` — the three `list`s above
and the matching `getBatch` / `getBatchJSON` on `data` / `userdata` /
`records`:

- 0–256 bytes long. The empty prefix (or omitting the argument) lists
  the whole keyspace.
- Same charset as a key.

Failures throw `BacklitError` with `code === "invalid_prefix"`.

### Handles (`capture.update` / `get` / `delete`)

A handle is the keyed string `"{prefix}-{guid}"` returned by `create`:

- `{prefix}` is the prefix you passed to `create` (the key charset above).
- `{guid}` is a server-generated 36-char lower-hex 8-4-4-4-12 GUID
  (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, each `x` is `[0-9a-f]`).

A bare GUID with no prefix is **not** a valid handle. The GUID is
always server-generated; never construct a handle yourself — pass back
exactly what `create` returned.

Failures throw `BacklitError` with `code === "invalid_handle"`.

### Content-Type

`contentType` is required on every `put` and `create`/`update`. It must
be a non-empty string. The server enforces a strict allowlist (by
default `text/plain`, `text/css`, `text/csv`, `text/markdown`, the
raster image families `image/{png,jpeg,gif,webp}`, `application/json`,
and `application/octet-stream` — note these are the exact `text/`
subtypes, **not** a `text/*` wildcard); the exact allowlist may be
tuned per deployment.

A small set is always denied, regardless of the allowlist, because
serving them back on a GET could execute as same-origin script:

- `text/html`
- `application/xhtml+xml`
- `image/svg+xml` (an SVG can embed `<script>` that runs on navigation
  to the object URL)
- `application/javascript`, `text/javascript`,
  `application/ecmascript`, `text/ecmascript`

The SDK pre-flights this always-denied set client-side
(`unsupported_content_type`). The full allowlist is enforced server-
side. On reads the server also sends `X-Content-Type-Options: nosniff`
so stored bytes can't be MIME-sniffed into executable markup.

---

## Notes

- **No SDK guard against double-load.** If a page accidentally
  `<script src>`s the bundle twice, the second copy rebinds
  `window.backlit` with an equivalent surface. There is no internal
  state to corrupt; a guard would break legitimate hot-reload flows.
- **`window.backlit._versionStale`.** After a deploy, sessions issued
  against an older live version of the glow stay valid (you don't get
  signed out), but the server signals that the page is now serving
  stale code. The SDK flips `window.backlit._versionStale = true` on
  first sighting so apps that care can prompt the user to reload.
  **The leading underscore means this is not part of the v1 contract**
  and may evolve.
- **Cookies and credentials.** Requests carry the glow's session
  cookies automatically — the SDK never reads or writes cookies itself.

## Changelog

- **`data` / `userdata` / `records` gained `getBatch` and `getBatchJSON`.**
  Bulk reads that return the values alongside the same list-shaped
  pagination — one round trip for a whole page of keys instead of a `list`
  plus a `get` per key. An entry too large for a page arrives metadata-only
  with `omitted: true` (fetch it with a plain `get`); a page can end early
  at its per-response value budget, so `next` is the sole completion signal.
  `getBatchJSON<T>` parses each value like `getJSON` and degrades a non-JSON
  entry to the omitted arm. `capture` has no batch read.
- **`backlit.push` was added — notifications + universal install.** A new
  namespace for Web Push: `supported()` (a synchronous, never-throwing
  capability probe), `state()`, `subscribe()`, `unsubscribe()`, `test()`, and
  `watch()`. Push needs a signed-in user and must be enabled for the glow;
  three new client-side error codes surface the browser-capability cases
  (`unsupported`, `requires_install`, `permission_denied`). Separately, **every
  glow is now installable** — the SDK sets up the platform service worker on
  load automatically. Don't register your own service worker on a Backlit glow.
- **`auth.users()` is now paginated.** It takes `{ cursor?, limit? }` and
  returns `{ items: BacklitUser[], next: string }` — the same cursor shape
  as the `data` / `userdata` / `records` / `capture` `list()` surfaces.
  Previously it returned a flat `BacklitUser[]` capped at the first 1000
  users. Walk the pages with `{ cursor: next }` until `next` is `""`.
- **Shared-data writes now require a signed-in user.** Anonymous
  sessions (a signed-out Public or Accounts visitor) can still *read*
  `backlit.data.*`, but `put` / `delete` / `putIfMatch`
  and the JSON write wrappers now throw
  `BacklitError(code: "unauthenticated")`. Previously anonymous sessions
  could write shared data. Apps that collect input from anonymous
  visitors should use `backlit.capture.create` (open to any session) or
  require sign-in.
- **`auth.users()` was added.** An admin-only, read-only roster of the
  Private glow's users — each a `BacklitUser`
  (`{ uid, name, email, mobile, permission }`). Unlike `auth.me()` it
  **throws** `BacklitError` (`forbidden` for a non-admin, `unauthenticated`
  with no signed-in user, `unsupported_host` off a glow host, `network`).
- **`auth.user()` and `auth.permission()` were replaced by `auth.me()`.**
  A single call now returns the whole signed-in user —
  `{ uid, name, email, mobile, permission }` — or `null` when there is no
  user. Read `email`/`permission` off the result instead of the two old
  methods.
- **`window.backlit.mode` and the `__BACKLIT_LOCAL` override were removed.**
  The SDK is always remote and runs only on a deployed glow host; there
  is no local/offline (browser-storage) fallback. Off a glow host every
  `data.*` / `userdata.*` / `records.*` / `capture.*` call throws
  `BacklitError(code: "unsupported_host")`, while `auth.me()` resolves
  `null`.
- **`list()` entries now carry `contentType` and `size`.** Every
  `data.list` / `userdata.list` / `records.list` / `capture.list` entry
  includes the stored content-type and the body's byte size alongside
  `crc32c` (and, for records, `owner`).

---

## TypeScript type surface (`sdk.d.ts`)

The canonical machine contract for `window.backlit`, hosted alongside the bundle at `https://sdk.backlit.run/sdk/v1/sdk.d.ts` and mirrored at `https://sdk.backlit.run/sdk.d.ts`.

```ts
// Backlit runtime SDK — ambient type surface for `window.backlit`.
//
// This is the canonical MACHINE contract for the global the hosted bundle
// (`<script src="https://sdk.backlit.run/sdk/v1/sdk.js">`) attaches to
// `window`. It is hand-authored but kept in lockstep with the SDK source in
// `sdk/src/*` — every type below is derived from those modules (the prose
// reference at /sdk can drift; the source and this file cannot). If you change
// a signature in `sdk/src`, change it here and run `task sdk:dts:check`.
//
// It is a *script-style* declaration file (no top-level import/export), so it
// applies globally the moment a consumer's TypeScript project references it —
// matching how the bundle works at runtime. Drop it next to your code, or add
// `/// <reference types="./sdk.d.ts" />`, and `window.backlit` is fully typed.
//
// Hosted alongside the bundle at `https://sdk.backlit.run/sdk/v1/sdk.d.ts`
// (and mirrored at `https://sdk.backlit.run/sdk.d.ts`).

// ===========================================================================
// Identity
// ===========================================================================

/**
 * Access level of a signed-in user on a Private glow — the lowercase wire
 * enum. A `"viewer"` is blocked from glow-shared writes (`data.put` /
 * `data.delete`); `userdata.*`, `records.*`, and `capture.*` writes are
 * unaffected.
 */
type Permission = "admin" | "user" | "viewer";

/**
 * The signed-in user, as returned by {@link AuthNamespace.me} (or one row of
 * {@link AuthNamespace.users}). Every field is a string — absent
 * `uid`/`name`/`mobile` decode to `""`, and an absent/unrecognized
 * `permission` to `""` (never `null`/`undefined`). `name`/`email`/`mobile`
 * are user-controlled display strings: escape them before inserting into HTML.
 */
interface BacklitUser {
  /** Stable per-user id; survives an email change and keys the `userdata` silo. */
  readonly uid: string;
  /** Display name, or `""` if unset. */
  readonly name: string;
  /** Verified email, or `""` if the user signed in without one. */
  readonly email: string;
  /** Mobile number, or `""` if unset. */
  readonly mobile: string;
  /** Permission level, or `""` for a legacy/unknown session. */
  readonly permission: Permission | "";
}

// ===========================================================================
// Stored-value shapes
// ===========================================================================

/** The value at a key: the bytes, the stored content-type, and the body CRC32C. */
interface StoredValue {
  /** The raw bytes (the browser auto-decodes any transport compression). */
  readonly value: Blob;
  /** The content-type the value was written with. */
  readonly contentType: string;
  /** Base64-encoded Castagnoli CRC32C of the body. `""` if the server omitted it. */
  readonly crc32c: string;
}

/** The result of a `get` on `data` / `userdata`. Identical to {@link StoredValue}. */
type BlobResult = StoredValue;

/** The result of a `put` / `capture.update`: the body's integrity hash. */
interface PutResult {
  /** Base64-encoded Castagnoli CRC32C of the body just stored. */
  readonly crc32c: string;
}

/**
 * Outcome of a compare-and-swap write ({@link DataNamespace.putIfMatch}). A CRC
 * mismatch (you lost the race) is a NORMAL result, not an error — narrow on
 * `.matched`. Real failures (`not_found`, `forbidden`, `quota_exceeded`, …)
 * still throw {@link BacklitError}.
 */
type PutIfMatchResult =
  | { readonly matched: true; readonly crc32c: string }
  | { readonly matched: false; readonly witness: StoredValue };

/** One entry of a `data.list` / `userdata.list` response. */
interface ListEntry {
  /** The SDK-visible key. */
  readonly key: string;
  /** Base64-encoded Castagnoli CRC32C of the body (`""` if omitted). */
  readonly crc32c: string;
  /** The stored content-type (`""` if omitted). */
  readonly contentType: string;
  /** The body's logical size in bytes (`0` if omitted). */
  readonly size: number;
}

/**
 * Optional pagination controls for every `list()` surface
 * (`data` / `userdata` / `records` / `capture`).
 */
interface ListOptions {
  /**
   * Opaque continuation token. Pass a prior page's {@link ListPage.next} to
   * fetch the following page; omit (or `""`) for the first page.
   */
  cursor?: string;
  /**
   * Requested page size, clamped server-side to `[1, 1000]` (omitted ⇒ the
   * server default of 500). An over-range value is clamped, never rejected.
   */
  limit?: number;
}

/**
 * One bounded page returned by every `list()` surface: the entries plus an
 * opaque `next` cursor. Paginate by re-calling `list(prefix, { cursor: next })`
 * until `next` is `""` (the listing is exhausted).
 */
interface ListPage<E> {
  /** This page's entries (the per-entry shape — unchanged from the flat array). */
  readonly items: readonly E[];
  /** The cursor for the next page, or `""` when exhausted. */
  readonly next: string;
}

// ===========================================================================
// Batch-read shapes (data/userdata/records getBatch / getBatchJSON)
// ===========================================================================

/**
 * Optional pagination controls for every `getBatch()` / `getBatchJSON()`
 * surface (`data` / `userdata` / `records`). Structurally like
 * {@link ListOptions}, but the page-size clamp differs — hence a dedicated type.
 */
interface GetBatchOptions {
  /**
   * Opaque continuation token. Pass a prior page's {@link ListPage.next} to
   * fetch the following page; omit (or `""`) for the first page. `next` is the
   * **sole** continuation signal — a page can end early even when more entries
   * exist (see below), so always paginate on `next`, never on item count.
   */
  cursor?: string;
  /**
   * Requested page size, clamped server-side to `[1, 500]` (omitted ⇒ the
   * default of 100). An over-range value is clamped, never rejected. A page may
   * still hold **fewer** entries than requested: the server also caps each
   * response at a per-response value budget (about 10 MiB by default) and ends
   * the page early, with `next` pointing at the first entry it did not include.
   */
  limit?: number;
}

/**
 * One entry of a `data.getBatch` / `userdata.getBatch` response — the value-
 * bearing superset of {@link ListEntry}. Discriminated on `omitted` (narrow on
 * it like {@link PutIfMatchResult}'s `.matched` — a **normal** outcome, not an
 * error):
 *  - **included** (`omitted` absent/`false`): `value` is the {@link Blob} —
 *    a zero-byte Blob is a real empty value, never omission;
 *  - **metadata-only** (`omitted: true`): `value` is absent and `reason` (when
 *    present) says why. A single value larger than the whole per-response budget
 *    arrives this way with `reason: "too_large"`; fetch it with a plain `get`.
 *
 * `reason` is an **open vocabulary** — tolerate unknown values. The only wire
 * value today is `"too_large"`.
 */
type BatchEntry = ListEntry & (
  | { readonly omitted?: false; readonly value: Blob }
  | { readonly omitted: true; readonly value?: never; readonly reason?: string }
);

/** A {@link BatchEntry} plus the record's owner uid — one `records.getBatch` row. */
type RecordBatchEntry = BatchEntry & { readonly owner: string };

// ===========================================================================
// JSON convenience shapes
// ===========================================================================

/** What `getJSON` / `updateJSON` resolve to: the parsed value plus its body CRC32C. */
interface JSONResult<T> {
  /** The parsed value. */
  readonly value: T;
  /** Base64-encoded Castagnoli CRC32C of the stored body — feeds `putJSONIfMatch`. */
  readonly crc32c: string;
}

/**
 * JSON analogue of {@link PutIfMatchResult}: on a mismatch the witness carries
 * an already-parsed `value` (no Blob to decode).
 */
type PutJSONIfMatchResult<T> =
  | { readonly matched: true; readonly crc32c: string }
  | { readonly matched: false; readonly witness: JSONResult<T> };

/** Options for `updateJSON`. */
interface UpdateJSONOptions {
  /** Max compare-and-swap attempts before throwing `conflict`. Default 6. */
  maxAttempts?: number;
}

// ===========================================================================
// Records-surface shapes (window.backlit.records) — base shapes plus `owner`
// ===========================================================================

/** A {@link StoredValue} plus the record's owner uid (creator). */
type RecordResult = StoredValue & { readonly owner: string };

/** A {@link ListEntry} plus the record's owner uid. */
type RecordListEntry = ListEntry & { readonly owner: string };

/** {@link PutIfMatchResult} for records — both arms surface the `owner` uid. */
type RecordPutIfMatchResult =
  | { readonly matched: true; readonly crc32c: string; readonly owner: string }
  | { readonly matched: false; readonly witness: RecordResult };

/** {@link JSONResult} for records — carries the record's owner uid. */
type RecordJSONResult<T> = JSONResult<T> & { readonly owner: string };

/** {@link PutJSONIfMatchResult} for records — both arms surface the `owner` uid. */
type RecordPutJSONIfMatchResult<T> =
  | { readonly matched: true; readonly crc32c: string; readonly owner: string }
  | { readonly matched: false; readonly witness: RecordJSONResult<T> };

/**
 * One entry of a `data.getBatchJSON` / `userdata.getBatchJSON` response — the
 * {@link BatchEntry} shape with each included value already `JSON.parse`d to
 * `T` (parsing is content-type-agnostic, matching `getJSON`). Discriminated on
 * `omitted` (narrow on it — a **normal** outcome, not an error):
 *  - **included** (`omitted` absent/`false`): `value` is the parsed `T`. A JSON
 *    `null` / `0` / `""` / `false` is a legal included value — the discriminant
 *    is `omitted`, never value-nullness;
 *  - **metadata-only** (`omitted: true`): `value` is absent and `reason` (when
 *    present) says why. A server-omitted entry passes through with its wire
 *    `reason` (`"too_large"`); an included value that is **not valid JSON**
 *    degrades to this arm with the SDK-synthesized `reason: "not_json"` (never a
 *    wire value).
 *
 * `reason` is an **open vocabulary** — tolerate unknown values. Known values:
 * `"too_large"` (server) and `"not_json"` (SDK-side, `getBatchJSON` only).
 */
type BatchJSONEntry<T> = {
  readonly key: string;
  readonly crc32c: string;
  readonly contentType: string;
  readonly size: number;
} & (
  | { readonly omitted?: false; readonly value: T }
  | { readonly omitted: true; readonly value?: never; readonly reason?: string }
);

/** A {@link BatchJSONEntry} plus the record's owner uid — one `records.getBatchJSON` row. */
type RecordBatchJSONEntry<T> = BatchJSONEntry<T> & { readonly owner: string };

// ===========================================================================
// Capture-surface shapes (window.backlit.capture)
// ===========================================================================

/** {@link PutResult} plus the `"{prefix}-{guid}"` handle the object was stored under. */
interface CaptureCreateResult {
  /** The full `"{prefix}-{guid}"` handle. Pass it back verbatim to update/get/delete. */
  readonly handle: string;
  /** Base64-encoded Castagnoli CRC32C of the body. */
  readonly crc32c: string;
}

/** One entry of a `capture.list` response — handle + metadata, never the value. */
interface CaptureListEntry {
  /** The full `"{prefix}-{guid}"` handle. */
  readonly handle: string;
  /** Base64-encoded Castagnoli CRC32C of the body. */
  readonly crc32c: string;
  /** The stored content-type. */
  readonly contentType: string;
  /** The body's logical size in bytes. */
  readonly size: number;
}

// ===========================================================================
// Push-surface shapes (window.backlit.push)
// ===========================================================================

/** The OS notification-permission tri-state (mirrors `NotificationPermission`). */
type PushPermission = "granted" | "denied" | "default";

/**
 * Synchronous capability probe returned by {@link BacklitPush.supported} — an
 * honest report of whether this browser can subscribe to push right now.
 */
interface PushSupport {
  /** Can this browser subscribe to push at all, in this context, right now? */
  readonly supported: boolean;
  /**
   * When `supported` is `false`, a short tag for why: `"off_host"` (not a
   * deployed glow), `"insecure_context"`, `"no_service_worker"`,
   * `"no_push_manager"`, or `"requires_install"`. Omitted when `supported` is
   * `true`.
   */
  readonly reason?: string;
  /**
   * `true` on a platform where push works only from an installed home-screen app
   * and it isn't installed yet (iOS Safari). Implies `supported: false`.
   */
  readonly requiresInstall: boolean;
  /** Whether the page is running as an installed (standalone) app. */
  readonly installed: boolean;
}

/** Current client-side push state, from {@link BacklitPush.state}. */
interface PushState {
  /** The OS notification permission. */
  readonly permission: PushPermission;
  /** Whether this browser currently holds a push subscription. */
  readonly subscribed: boolean;
  /** The server-side subscription id when subscribed, else `""`. */
  readonly endpointId: string;
}

/** What {@link BacklitPush.subscribe} resolves to. */
interface SubscribeResult {
  /** The server-side subscription id for this browser's subscription. */
  readonly endpointId: string;
  /** Always `"granted"` (subscribe rejects with `permission_denied` otherwise). */
  readonly permission: "granted";
}

/** An event delivered to a {@link BacklitPush.watch} callback. */
type PushWatchEvent =
  /** The user tapped a delivered notification; `url` is its tap target. */
  | { readonly type: "notification-click"; readonly url: string }
  /** The browser rotated the subscription and the service worker re-registered it. */
  | { readonly type: "subscription-changed" }
  /** The OS notification permission changed; `permission` is the new value. */
  | { readonly type: "permission-changed"; readonly permission: PushPermission };

/** Callback registered with {@link BacklitPush.watch}. */
type PushWatchCallback = (event: PushWatchEvent) => void;

/** Returned by {@link BacklitPush.watch}. Idempotent — calling it twice is a no-op. */
type PushUnwatch = () => void;

/**
 * `window.backlit.push` — Web Push subscription management. The platform owns
 * the service worker (auto-registered on load); do **not** register your own on
 * a Backlit glow. Requires a signed-in user for subscribe/state/unsubscribe/test.
 */
interface BacklitPush {
  /**
   * Synchronous capability probe — the **only** sync, never-throwing SDK
   * method. Off a glow host it returns `{ supported:false,
   * requiresInstall:false, installed:false }` (it does **not** throw
   * `unsupported_host`). Gate your "Enable notifications" UI on `.supported`;
   * when `false`, `.reason` / `.requiresInstall` say why.
   */
  supported(): PushSupport;
  /**
   * Report client-side push state: the OS `permission`, whether this browser is
   * `subscribed`, and the server-side `endpointId` (or `""`). Throws
   * `unsupported` / `requires_install` when {@link BacklitPush.supported} is
   * `false`, and `unsupported_host` off a glow host.
   */
  state(): Promise<PushState>;
  /**
   * Subscribe the signed-in user's browser to push: request permission (throws
   * `permission_denied` if declined), register the platform service worker,
   * subscribe via the Push API, and report the subscription to Backlit. Throws
   * `unsupported` / `requires_install` (unsupported context), `unsupported_host`
   * (off a glow host), `unauthenticated` (no signed-in user), or the server push
   * codes.
   */
  subscribe(): Promise<SubscribeResult>;
  /**
   * Remove this browser's push subscription (browser + Backlit). A no-op when
   * there is no active subscription. Requires a signed-in user.
   */
  unsubscribe(): Promise<void>;
  /**
   * Send a test push to the signed-in user's own subscriptions. Requires a
   * signed-in user with at least one active subscription (throws `not_found`
   * if none).
   */
  test(): Promise<void>;
  /**
   * Register a callback for push client events — a notification tap
   * (`notification-click`, with the `url`), a service-worker subscription
   * rotation (`subscription-changed`), and OS permission changes
   * (`permission-changed`). Returns an idempotent unsubscribe; a no-op off a
   * glow host or in a non-browser context.
   */
  watch(callback: PushWatchCallback): PushUnwatch;
}

// ===========================================================================
// Realtime
// ===========================================================================

/**
 * Payload delivered to `backlit.on("storage.change", …)` handlers on every
 * successful `data` / `userdata` / `records` set or delete. (`capture` is
 * silent by design.)
 */
interface StorageChangeEvent {
  /** Which store the write hit. */
  readonly store: "data" | "userdata" | "records";
  /** `"set"` for put/CAS, `"delete"` for delete. */
  readonly operation: "set" | "delete";
  /** The key written or deleted. */
  readonly key: string;
  /** Base64-encoded Castagnoli CRC32C of the body; `""` on a delete event. */
  readonly crc32c: string;
  /** Records events only: the record's owner uid. Absent for data/userdata. */
  readonly owner?: string;
}

type StorageChangeHandler = (event: StorageChangeEvent) => void;

/** Returned by `on(...)`. Idempotent — calling it twice is a no-op. */
type Unsubscribe = () => void;

// ===========================================================================
// Errors
// ===========================================================================

/**
 * Every `code` {@link BacklitError} can carry. Matches the server's
 * `{error:{code,message}}` envelope verbatim, plus the SDK-side client codes.
 */
type BacklitErrorCode =
  // Preamble / transport (server-emitted)
  | "invalid_host"
  | "glow_not_found"
  | "glow_dark"
  | "region_mismatch"
  | "unauthenticated"
  | "cross_glow_token"
  | "forbidden"
  | "internal"
  // Data plane (server-emitted)
  | "not_found"
  | "invalid_key"
  | "invalid_prefix"
  | "invalid_cursor"
  | "invalid_handle"
  | "unknown_handle"
  | "payload_too_large"
  | "quota_exceeded"
  | "unsupported_content_type"
  | "invalid_body"
  | "method_not_allowed"
  // Emitted BOTH server- and client-side: `conflict` is the SDK's
  // updateJSON retry-exhaustion (status 0) AND the server's 412 when a
  // records delete/reassign loses a concurrent-write race; `invalid_argument`
  // is the SDK's pre-flight validation (status 0) AND the server's 400 for a
  // malformed `records.reassign` owner uid.
  | "conflict"
  | "invalid_argument"
  // Client-side only (SDK-emitted, status 0)
  | "network"
  | "unsupported_host"
  // Push-only client codes (SDK-emitted by window.backlit.push.*, status 0):
  // `unsupported` — this browser can't do push at all (no service worker /
  // PushManager / secure context); `requires_install` — push works only from an
  // installed home-screen app and it isn't installed yet (iOS Safari);
  // `permission_denied` — the user declined the notification-permission prompt.
  | "unsupported"
  | "requires_install"
  | "permission_denied"
  // Edge document-path codes — emitted while the edge serves the glow's
  // assets or runs the signin cookie-handoff. A data/userdata/records/capture
  // call sees these only in rare edge cases; they pass through the envelope
  // verbatim, so they are part of the observable union.
  | "asset_not_found"
  | "no_live_version"
  | "version_not_found"
  | "invalid_asset_path"
  | "invalid_glow_state"
  | "missing_handoff"
  | "handoff_expired"
  | "handoff_invalid"
  | "handoff_replay"
  | "handoff_unknown_nonce"
  | "handoff_glow_mismatch";

/**
 * The single error class every SDK method throws (except {@link
 * AuthNamespace.me}, which never throws). Discriminate on `.code`.
 * `instanceof BacklitError` is reliable across realms.
 */
declare class BacklitError extends Error {
  readonly code: BacklitErrorCode;
  /** HTTP status, or `0` for network / client-side validation failures. */
  readonly status: number;
  /** The underlying Error or fetch failure, when there is one. */
  readonly backlitCause: unknown;
  constructor(code: BacklitErrorCode, message: string, status?: number, cause?: unknown);
}

// ===========================================================================
// Namespaces
// ===========================================================================

/** `window.backlit.auth` — signed-in user identity. */
interface AuthNamespace {
  /**
   * The signed-in user, or `null` when there is none (Public glows, signed-out
   * visitors, the SDK loaded off a glow host, and network failures all resolve
   * `null`). **Never throws.**
   */
  me(): Promise<BacklitUser | null>;
  /**
   * One cursor-paginated page of the glow's user roster as a {@link ListPage}
   * of {@link BacklitUser} — `{ items, next }`, reusing the {@link ListOptions}
   * / {@link ListPage} types the `data` / `userdata` / `records` / `capture`
   * `list()` surfaces use. Pass `opts` (`{ cursor?, limit? }`) to page;
   * paginate by re-calling with the previous page's `next` until it is `""`.
   * **Admin only and read-only.** Unlike {@link AuthNamespace.me} this THROWS
   * {@link BacklitError}: `forbidden` (non-admin), `unauthenticated` (no
   * signed-in user), `invalid_cursor` (a malformed cursor), `unsupported_host`
   * (off a glow host), or `network`.
   */
  users(opts?: ListOptions): Promise<ListPage<BacklitUser>>;
  /**
   * Send the visitor to sign in (top-level navigation; returns `void`).
   * `returnPath` is an absolute same-origin path to land on afterwards
   * (default: the current page; anything else is sanitised to `/`). A silent
   * no-op off a deployed glow host. Useful for a "Sign in" button.
   */
  login(returnPath?: string): void;
  /**
   * Sign the visitor out and return them to `returnPath` (an absolute
   * same-origin path; default the current page). Top-level navigation, returns
   * `void`; a silent no-op off a deployed glow host.
   */
  logout(returnPath?: string): void;
}

// ===========================================================================
// Key & prefix constraint
// ===========================================================================
//
// Every `key` and `prefix` argument to `data.*`, `userdata.*`, and `records.*`
// — and the `prefix` argument to `capture.create` / `capture.list` — may use
// only the RFC 3986 §2.3 *unreserved* characters:
//
//     A–Z   a–z   0–9   -   .   _   ~
//
// and must be 1–256 bytes (a `prefix` may be empty, meaning "match everything
// in the silo"). Slashes and every other character are rejected. The rule is
// enforced both client-side — the SDK pre-flights it and throws
// `invalid_key` / `invalid_prefix` before any request — and server-side (the
// data API rejects with the same code). Capture *handles* are NOT keys: they
// keep the stricter `"{prefix}-{guid}"` form (see {@link CaptureNamespace}).

/**
 * The glow-shared key/value store (`window.backlit.data`). Readable by any
 * session with a valid glow session — every Public-glow visitor and every
 * signed-in user. **Writes require a signed-in user with write permission**:
 * anonymous sessions (every Public-glow visitor, and signed-out visitors on
 * an Accounts glow) get `unauthenticated`, and `viewer`-permission users get
 * `forbidden`, on `put`/`delete`/`putIfMatch` and the JSON write wrappers.
 * More than one user can write the same key, so prefer
 * {@link DataNamespace.updateJSON} / {@link DataNamespace.putIfMatch} over a
 * bare {@link DataNamespace.put} for shared mutable state. `key` arguments
 * are `[A-Za-z0-9._~-]` and must be 1–256 bytes.
 */
interface DataNamespace {
  /** Read the value at `key`. Throws `not_found` if it was never written. */
  get(key: string): Promise<BlobResult>;
  /**
   * Unconditional overwrite (last-writer-wins). `body` is `string | Blob |
   * ArrayBuffer | ArrayBufferView`; `contentType` is required and forwarded
   * verbatim. For shared mutable state prefer `updateJSON`/`putIfMatch`.
   */
  put(key: string, body: BodyInit, contentType: string): Promise<PutResult>;
  /**
   * Atomic compare-and-swap: write only if the current value's CRC equals
   * `expectedCrc32c` (empty string = create-only / expect-absent). A mismatch
   * resolves `{ matched: false, witness }` and never throws.
   */
  putIfMatch(key: string, body: BodyInit, contentType: string, expectedCrc32c: string): Promise<PutIfMatchResult>;
  /** Remove `key`. Throws `not_found` if absent (delete is not idempotent). */
  delete(key: string): Promise<void>;
  /**
   * List one bounded page of entries whose key starts with `prefix` (omit/`""`
   * lists everything), plus an opaque `next` cursor. Pass `opts.cursor` (a prior
   * page's `next`) to continue and `opts.limit` to size the page (clamped to
   * `[1,1000]`, default 500); paginate until `next` is `""`.
   */
  list(prefix?: string, opts?: ListOptions): Promise<ListPage<ListEntry>>;
  /**
   * The value-bearing sibling of {@link DataNamespace.list}: the same list-shaped
   * input and `{ items, next }` page, but each entry carries its `value` as a
   * {@link Blob}. An entry the server dropped at the per-response value budget
   * arrives metadata-only with `omitted: true` (narrow on it — see
   * {@link BatchEntry}). `opts.limit` is clamped to `[1,500]` (default 100);
   * paginate on `next` until `""`.
   */
  getBatch(prefix?: string, opts?: GetBatchOptions): Promise<ListPage<BatchEntry>>;

  /** `get` + `JSON.parse`. Throws `not_found` on a miss, `invalid_body` if not JSON. */
  getJSON<T = unknown>(key: string): Promise<JSONResult<T>>;
  /**
   * {@link DataNamespace.getBatch} with each included value `JSON.parse`d to `T`
   * client-side (content-type-agnostic, `getJSON` parity). A value that fails to
   * parse degrades that entry to the omitted arm with `reason: "not_json"`; a
   * server-omitted entry passes through with its wire `reason`. One `T` per page
   * (see {@link BatchJSONEntry}).
   */
  getBatchJSON<T = unknown>(prefix?: string, opts?: GetBatchOptions): Promise<ListPage<BatchJSONEntry<T>>>;
  /** `JSON.stringify(value)` + `put` as `application/json`. Throws `invalid_argument` if not serializable. */
  putJSON(key: string, value: unknown): Promise<PutResult>;
  /** JSON form of `putIfMatch` — the witness carries an already-parsed value. */
  putJSONIfMatch<T = unknown>(key: string, value: unknown, expectedCrc32c: string): Promise<PutJSONIfMatchResult<T>>;
  /**
   * The safe read-modify-write for shared JSON state: load → `mutate` →
   * compare-and-swap → rebase-and-retry. `mutate(current)` (current is
   * `undefined` on first write) MUST be replayable. Throws `conflict` if it
   * can't win within `maxAttempts` (default 6).
   */
  updateJSON<T = unknown>(key: string, mutate: (current: T | undefined) => T, options?: UpdateJSONOptions): Promise<JSONResult<T>>;
}

/**
 * The per-user data silo (`window.backlit.userdata`) — identical to {@link
 * DataNamespace} but each signed-in user has a private, isolated keyspace.
 * **Requires a signed-in user** (Private glows, or signed-in visitors on an
 * Accounts glow): every method throws `unauthenticated` on a Public glow or
 * for a signed-out visitor (no user session). `key` arguments are
 * `[A-Za-z0-9._~-]` and must be 1–256 bytes.
 */
interface UserDataNamespace {
  get(key: string): Promise<BlobResult>;
  put(key: string, body: BodyInit, contentType: string): Promise<PutResult>;
  putIfMatch(key: string, body: BodyInit, contentType: string, expectedCrc32c: string): Promise<PutIfMatchResult>;
  delete(key: string): Promise<void>;
  /** Like `data.list`: one bounded page + an opaque `next` cursor. */
  list(prefix?: string, opts?: ListOptions): Promise<ListPage<ListEntry>>;
  /** Like `data.getBatch`, scoped to this user's silo: value-bearing entries + `next`. */
  getBatch(prefix?: string, opts?: GetBatchOptions): Promise<ListPage<BatchEntry>>;
  getJSON<T = unknown>(key: string): Promise<JSONResult<T>>;
  /** Like `data.getBatchJSON`, scoped to this user's silo: JSON-parsed entries + `next`. */
  getBatchJSON<T = unknown>(prefix?: string, opts?: GetBatchOptions): Promise<ListPage<BatchJSONEntry<T>>>;
  putJSON(key: string, value: unknown): Promise<PutResult>;
  putJSONIfMatch<T = unknown>(key: string, value: unknown, expectedCrc32c: string): Promise<PutJSONIfMatchResult<T>>;
  updateJSON<T = unknown>(key: string, mutate: (current: T | undefined) => T, options?: UpdateJSONOptions): Promise<JSONResult<T>>;
}

/**
 * The public-read, owner-write store (`window.backlit.records`) — between
 * `data` (shared) and `userdata` (private). Any signed-in user creates records
 * and reads everyone's; only the record's creator (`owner`) or an `admin` may
 * overwrite/delete/reassign. **Requires a signed-in user** (`unauthenticated`
 * on a Public glow or for a signed-out visitor) and **free** (no
 * subscription). `get`/`list`/`putIfMatch`/`updateJSON` surface the `owner`
 * uid. `key` arguments are `[A-Za-z0-9._~-]` and must be 1–256 bytes.
 */
interface RecordsNamespace {
  get(key: string): Promise<RecordResult>;
  /** Create (first write claims it) or overwrite your own. A viewer CAN create. */
  put(key: string, body: BodyInit, contentType: string): Promise<PutResult>;
  putIfMatch(key: string, body: BodyInit, contentType: string, expectedCrc32c: string): Promise<RecordPutIfMatchResult>;
  delete(key: string): Promise<void>;
  /** Like `data.list` but each entry carries the record's `owner` uid; one bounded page + `next`. */
  list(prefix?: string, opts?: ListOptions): Promise<ListPage<RecordListEntry>>;
  /** Like `data.getBatch` but each value-bearing entry carries the record's `owner` uid. */
  getBatch(prefix?: string, opts?: GetBatchOptions): Promise<ListPage<RecordBatchEntry>>;
  getJSON<T = unknown>(key: string): Promise<RecordJSONResult<T>>;
  /** Like `data.getBatchJSON` but each entry carries the record's `owner` uid. */
  getBatchJSON<T = unknown>(prefix?: string, opts?: GetBatchOptions): Promise<ListPage<RecordBatchJSONEntry<T>>>;
  putJSON(key: string, value: unknown): Promise<PutResult>;
  putJSONIfMatch<T = unknown>(key: string, value: unknown, expectedCrc32c: string): Promise<RecordPutJSONIfMatchResult<T>>;
  updateJSON<T = unknown>(key: string, mutate: (current: T | undefined) => T, options?: UpdateJSONOptions): Promise<RecordJSONResult<T>>;
  /** **Admin only.** Move a record's owner to `newOwnerUid` (metadata-only). */
  reassign(key: string, newOwnerUid: string): Promise<void>;
}

/**
 * The write-only capture store (`window.backlit.capture`) for telemetry / form
 * submissions the app can capture but never read back. `create`/`update` are
 * open to any session (the handle is the capability); `get`/`list`/`delete`
 * are **admin only**. `prefix` arguments are `[A-Za-z0-9._~-]` and must be
 * 1–256 bytes.
 */
interface CaptureNamespace {
  /** Store a value; returns its `"{prefix}-{guid}"` handle. `prefix` is required. */
  create(prefix: string, body: BodyInit, contentType: string): Promise<CaptureCreateResult>;
  /** Replace the value at an existing handle. Open to any session. */
  update(handle: string, body: BodyInit, contentType: string): Promise<PutResult>;
  /** **Admin only.** Read the value at a handle. Throws `unknown_handle` on a miss. */
  get(handle: string): Promise<BlobResult>;
  /** **Admin only.** Enumerate handles (never values); `prefix` matches the handle. One bounded page + `next`. */
  list(prefix?: string, opts?: ListOptions): Promise<ListPage<CaptureListEntry>>;
  /** **Admin only.** Remove the object at a handle. Throws `unknown_handle` on a miss. */
  delete(handle: string): Promise<void>;
}

/** The `window.backlit` global the hosted bundle attaches. */
interface Backlit {
  /** The bundled SDK version, e.g. `"1.0.0"`. */
  readonly version: string;
  readonly auth: AuthNamespace;
  readonly data: DataNamespace;
  readonly userdata: UserDataNamespace;
  readonly records: RecordsNamespace;
  readonly capture: CaptureNamespace;
  /**
   * Web Push subscription management — `supported()` (the only sync,
   * never-throwing SDK method), `state()`, `subscribe()`, `unsubscribe()`,
   * `test()`, and `watch()`. The platform service worker is auto-registered on
   * load for every glow (universal install), independent of push.
   */
  readonly push: BacklitPush;
  /** The error class every method throws — for `instanceof` checks. */
  readonly BacklitError: typeof BacklitError;
  /**
   * Subscribe to runtime events. The only event in v1 is `"storage.change"`,
   * fired on every `data`/`userdata`/`records` set or delete on this glow.
   * Returns an unsubscribe function.
   */
  on(event: "storage.change", handler: StorageChangeHandler): Unsubscribe;
  /**
   * Remove the Backlit floating action button this SDK injects on load.
   * Honoured only on a paid plan (a free author's call is a silent no-op);
   * never throws, idempotent, and race-safe (works even if called before the
   * button has injected). Removing Backlit branding is a paid feature.
   */
  hideUI(): void;
  /**
   * Set to `true` once the page is serving code older than the glow's current
   * live version (apps that care can prompt a reload).
   * @internal Non-contractual: the leading underscore means it is NOT part of
   * the typed v1 surface and may change without notice.
   */
  readonly _versionStale?: boolean;
}

/** The global object attached by `<script src=".../sdk/v1/sdk.js">`. */
declare const backlit: Backlit;

interface Window {
  /**
   * The Backlit runtime SDK global. Attached wherever the bundle loads, but
   * functional only on a deployed glow host — off-host every data method
   * throws `unsupported_host` and `auth.me()` resolves `null`.
   */
  readonly backlit: Backlit;
}
```

---

## Deploy & management API

The SDK above is the runtime surface app code calls in the browser. The separate REST API that creates, deploys, and manages glows is specified in OpenAPI at `https://glow.backlit.run/openapi.yaml` (served by the API itself). Agents typically deploy via the hosted MCP server at `https://mcp.backlit.run/mcp` instead of calling it directly.


---

## Guide: Push notifications end-to-end

# Push notifications end-to-end

This guide walks the whole push loop: you ship a `notify.json` in your deploy
bundle that says *which writes deserve a nudge*, the platform watches your app's
data for a matching write and rings a doorbell on every subscribed device, and
your app enrolls users and reacts to taps. It's a cross-layer story — a bundle
file, a data write, and the runtime SDK all play a part — so the reference
documents each piece on its own page and this guide stitches them together.

For the runtime API itself (what `subscribe`/`state`/`test`/`watch` return and
throw), see [`backlit.push`](https://sdk.backlit.run/sdk.md#backlitpush--notifications)
in the reference. For every field a `notify.json` rule can carry, see the
[notify.json schema](https://sdk.backlit.run/schema/notify.schema.json). This
guide links to both rather than repeating them, so it can't drift out of sync
with them.

## The mental model

Push has a **server half** and a **client half**, and they meet at a write.

- **Server half — `notify.json`.** An optional file at the root of your deploy
  bundle. It's a list of *rules*: "when a write matching this shape lands, send
  this notification to these people." The platform evaluates the rules for you
  the moment a matching write happens — your app doesn't send anything.
- **Client half — `window.backlit.push`.** Your app asks the signed-in user to
  turn notifications on, and reacts when they tap one. That's it: enrollment and
  reaction. The *sending* is entirely server-side.

The doorbell that arrives is deliberately **content-light** — it carries a
count, not your data — so the notification never leaks the payload of the write
that triggered it. Your app's service worker renders it with your glow's name
and icon; the tap opens the path you named in the rule.

A glow only pushes when its **live** deploy included a valid `notify.json`.
Deploy without one (or ship a bundle that omits it) and push is simply off — the
[`push.subscribe`](https://sdk.backlit.run/sdk.md#backlitpush--notifications)
call throws rather than enrolling anyone.

## Enable push in three moves

1. **Author a `notify.json`** and put it at the root of your bundle, alongside
   `index.html`. It rides the bundle as an optional fourth file next to the
   three required ones — see
   [Deploy bundle contract](https://sdk.backlit.run/sdk.md#deploy-bundle-contract)
   and [Packaging the deploy bundle](https://sdk.backlit.run/sdk.md#packaging-the-deploy-bundle)
   for how the bundle is assembled and
   [uploaded](https://glow.backlit.run/openapi.yaml).
2. **Enroll users** from your app with
   [`push.subscribe()`](https://sdk.backlit.run/sdk.md#backlitpush--notifications),
   gated on
   [`push.supported()`](https://sdk.backlit.run/sdk.md#pushsupported--supported-reason-requiresinstall-installed).
3. **React to taps** with
   [`push.watch()`](https://sdk.backlit.run/sdk.md#backlitpush--notifications).

The rest of this guide is a worked example of each move.

## Move 1 — author `notify.json`

A rule matches a write on the store it lands in and the verb that lands it, then
optionally narrows by a condition on the write's JSON payload, picks who to
notify, and supplies the static notification text. Each of those is a field in
the [notify.json schema](https://sdk.backlit.run/schema/notify.schema.json) —
read it there for the exact shape and constraints of `on`, `when`, `target`,
`message`, `coalesce`, and `schedule`; this guide only shows how they fit
together.

Here's a rule for a shared RSVP list: an admin should get a nudge whenever a
guest's write flips their status to `going`.

```json
{
  "version": 1,
  "rules": [
    {
      "id": "rsvp-going",
      "on": { "store": "data", "op": "set", "key": "rsvp-" },
      "when": [{ "field": "status", "op": "eq", "value": "going" }],
      "target": { "roles": ["admin"] },
      "message": {
        "title": "New RSVP",
        "body": "{count} guest just RSVP'd going.",
        "url": "/admin/rsvps"
      },
      "coalesce": { "window": "5m" }
    }
  ]
}
```

Reading it as narrative: `on` picks the store and write verb this rule watches
and (via `key`) narrows to a key prefix; `when` further gates on the write's
payload (only fire when `status` equals `going`); `target` says who receives it
(users whose glow permission is `admin`); `message` is the static text (with
`{count}` filled in at send time, and `url` the path a tap opens); and
`coalesce` batches a burst of matches into one doorbell over a window. The
schema is the authority on every one of those fields — including which
`store`/`op` combinations are legal, which stores allow an `owner` target, and
how the window is clamped — so consult it rather than trusting this paragraph.

Ship this file at your bundle root. If it's malformed, too large, or reaches for
something the platform disallows, the deploy is rejected with an error — the
[deploy API](https://glow.backlit.run/openapi.yaml) reports which — so you find
out at deploy time, not at send time.

## Move 2 — the write that trips it

Nothing about *sending* lives in your app. Push fires off an ordinary data
write — the same write your app already makes. Given the rule above, this write
is what rings the doorbell:

```js
// A guest marks themselves going. This is a normal shared-data write;
// the matching notify.json rule turns it into an admin notification.
await backlit.data.updateJSON(`rsvp-${user.uid}`, (cur) => ({
  ...cur,
  name: user.name,
  status: "going",
}));
```

The write lands in the `data` store under a `rsvp-` key with `status: "going"`
— exactly the shape the rule's `on` and `when` describe — so the platform
matches it and delivers the doorbell to every admin who subscribed. Your app
called no push API to make that happen; it just wrote its data. (For the
JSON-write helpers this uses, see the
[`data.getJSON` / `putJSON` / `putJSONIfMatch`](https://sdk.backlit.run/sdk.md#datagetjson--dataputjson--dataputjsonifmatch)
family in the reference and the read-modify-write pattern in
[Migrations](https://sdk.backlit.run/sdk.md#migrations).)

## Move 3 — enroll users and react to taps

Sending is automatic, but a user only receives a doorbell after their browser is
**enrolled**, and enrollment needs a signed-in user and a browser that can do
push. Always probe first, then offer the opt-in only where it can work:

```js
const cap = backlit.push.supported();     // synchronous; never throws
if (cap.supported) {
  enableBtn.onclick = async () => {
    try {
      await backlit.push.subscribe();      // prompts for OS permission
      await backlit.push.test();           // ring your own doorbell to confirm
    } catch (e) {
      if (e.code === "permission_denied") showTip("Notifications are blocked.");
      else throw e;
    }
  };
} else if (cap.requiresInstall) {
  showTip("Install this app to your home screen to turn on notifications.");
} else {
  enableBtn.hidden = true;                 // this browser can't do push
}

// While a tab is open, follow a tap to where the rule pointed it.
backlit.push.watch((e) => {
  if (e.type === "notification-click") location.assign(e.url);
});
```

The exact return shapes, the requirements each call imposes (a signed-in user,
push enabled for the glow), and the error `code`s it throws
(`permission_denied`, `unsupported`, `requires_install`, `unauthenticated`, …)
are all in the reference —
[`push.subscribe`](https://sdk.backlit.run/sdk.md#backlitpush--notifications),
[`push.test`](https://sdk.backlit.run/sdk.md#backlitpush--notifications),
[`push.watch`](https://sdk.backlit.run/sdk.md#backlitpush--notifications), and
the capability tags from
[`push.supported`](https://sdk.backlit.run/sdk.md#pushsupported--supported-reason-requiresinstall-installed).
Read them there; this guide won't restate them.

## Who can receive, and where

A rule's `target` speaks in the same permission roles the rest of the platform
uses (`admin` / `user` / `viewer`), and enrollment needs a **signed-in** user —
so who can actually receive a doorbell depends on the glow's auth mode. A
Private glow's users are all signed in; a Public or Accounts glow serves
anonymous visitors who receive nothing until they sign in. See
[Public, Private, and Accounts glows](https://sdk.backlit.run/sdk.md#public-private-and-accounts-glows)
for which surfaces each mode's visitors can reach, and the
[notify.json schema](https://sdk.backlit.run/schema/notify.schema.json)'s
`target` for how `roles` (and the records-only `owner`) resolve to recipients.

## No natural write? Dedicate a key

Every rule fires off a write — so when the moment you want to announce
isn't something the app already stores (a "meeting is starting" button, a
manual "ping the team"), invert the trigger: dedicate a key to the
announcement and write to it. The write *is* the send button; the rule
matches it like any other.

```json
{
  "id": "announce",
  "on": { "store": "data", "op": "set", "key": "announce" },
  "target": { "roles": ["admin", "user", "viewer"] },
  "message": {
    "title": "Team update",
    "body": "There's a new announcement.",
    "url": "/"
  }
}
```

```js
// The button's click handler IS the send.
sendBtn.onclick = () =>
  backlit.data.updateJSON("announce", () => ({
    text: input.value,
    at: Date.now(), // varies every send — load-bearing, see below
  }));
```

That `at` field is not decoration. Writing a value **identical** to what
the key already holds is a no-op — the write succeeds but nothing hears
it, so no doorbell rings (the exact rule is under
[`backlit.data`](https://sdk.backlit.run/sdk.md#backlitdata--glow-shared-keyvalue)).
Two admins sending the same "Meeting now" text minutes apart would
silently drop the second send. So a dedicated notification key must
always carry something that varies — a timestamp, a counter — or use
per-event keys under a prefix (`announce-…`, matched by the rule's
prefix `key`), which also pairs naturally with the schema's
[`coalesce`](https://sdk.backlit.run/schema/notify.schema.json) when
sends can burst.

Who may pull the trigger is just the store choice again: a shared `data`
key limits sending to signed-in non-viewers, `records` opens it to any
signed-in user, and `capture` to anyone — the
[permissions guide](https://sdk.backlit.run/guides/permissions-and-write-gating.md)
is the recipe for that decision. And check you need a dedicated key at
all: if the moment already writes — an anonymous contact form's capture,
a member's records post — hook the rule to *that* write instead, exactly
as Move 1's RSVP rule did.

## Deploy it with an agent

You don't have to package and upload by hand. An AI assistant connected to the
hosted [MCP server](https://mcp.backlit.run/mcp) can create the glow, deploy the
bundle — `notify.json` included — as a draft, and promote it live once you've
previewed it. See
[Deploy with an agent](https://sdk.backlit.run/sdk.md#deploy-with-an-agent) for
the connection details and the two out-of-band deploy transports.

---

## Guide: Permissions and write-gating

# Permissions and write-gating

Two independent settings decide who can write what in your app: the
glow's **auth mode** (a deploy/console setting — is this glow open to the
world, restricted to an allowlist, or open self-serve?) and each
signed-in user's **permission** (`admin` / `user` / `viewer`). This guide
is about designing a UI around the *combination* of the two — which is
where a static-page mental model tends to go wrong.

The reference documents each layer on its own: the
[auth-mode surface matrix](https://sdk.backlit.run/sdk.md#public-private-and-accounts-glows)
says what every surface does for each mode-and-sign-in state, and
[`backlit.auth`](https://sdk.backlit.run/sdk.md#backlitauth) plus
[`auth.me()`](https://sdk.backlit.run/sdk.md#authme-promisebacklituser--null)
carry the `permission` field. This guide links to those rather than
restating them, so it can't drift out of sync with the rules they define.

## Two dials, not one

- **Auth mode** is set on the glow, not in your app code. It decides
  whether a visitor arrives signed in, arrives anonymous, or can't arrive
  at all until they sign in. The three modes and exactly which surfaces
  each mode's visitors can reach are the
  [Public, Private, and Accounts matrix](https://sdk.backlit.run/sdk.md#public-private-and-accounts-glows)
  — read that as the authority; this guide won't reproduce its cells.
- **Permission** is a property of a *signed-in user*, surfaced as the
  `permission` field on
  [`auth.me()`](https://sdk.backlit.run/sdk.md#authme-promisebacklituser--null).
  It's what separates an editor from a read-only viewer *after* they're
  signed in.

The trap is treating "signed in" as "can write." It isn't: a signed-in
`viewer` reaches the app but still can't write shared data. Auth mode
gets the user *in*; permission decides what they can *do*.

## The server is the gate — your UI just mirrors it

Every write surface enforces its own rule server-side, and a blocked
write throws a specific `code`. You don't invent the gate; you read it
off the reference and *reflect* it in the UI so the user isn't surprised
by a rejection. The authoritative gate for each store lives with that
store:

- [`backlit.data`](https://sdk.backlit.run/sdk.md#backlitdata--glow-shared-keyvalue)
  — the glow-shared store. Its writes need a signed-in non-`viewer`.
- [`backlit.records`](https://sdk.backlit.run/sdk.md#backlitrecords--public-read-owner-write-store)
  — public-read, owner-write. Any signed-in user (a `viewer` included)
  can create and edit *their own* entries.
- [`backlit.capture`](https://sdk.backlit.run/sdk.md#backlitcapture--write-only-capture-store)
  — write-open, admin-only read. Anyone (even anonymous) can write a
  capture; only an `admin` can read them back.

The two error codes you'll design against are the `forbidden` and
`unauthenticated` rows in the
[error-code table](https://sdk.backlit.run/sdk.md#error-codes) — one
means "signed in, but not allowed," the other "no user session at all."
Consult the table for which call raises which; the point here is only
that a good UI never *lets* the user trip them.

## The recipe: pick the store by who's allowed to write

Instead of memorizing the matrix, run each thing your app writes through
two questions — and a default:

1. **Can a signed-out visitor write it?** If yes, that's a
   [`capture`](https://sdk.backlit.run/sdk.md#backlitcapture--write-only-capture-store)
   — a contact form, a telemetry ping, an anonymous submission the page
   can't read back. It's the only store whose *create* works with no
   session.
2. **Should each signed-in user own their own entries, viewers
   included?** If yes, that's
   [`records`](https://sdk.backlit.run/sdk.md#backlitrecords--public-read-owner-write-store)
   — comments, posts, profiles. A freshly self-served Accounts `viewer`
   can post to `records` but *not* to shared `data`, which makes `records`
   the natural home for user-generated content on an open glow.
3. **Otherwise it's shared state everyone sees but only privileged users
   edit** — that's
   [`data`](https://sdk.backlit.run/sdk.md#backlitdata--glow-shared-keyvalue),
   gated to a signed-in non-`viewer`.

Choosing the right store *is* the permission design. If you find yourself
wishing a `viewer` could write shared `data`, you probably wanted
`records`.

## Gate the control, not just the call

Because `permission` is readable before any write, branch your UI on it
so a user never sees a control they'd be rejected for using. The
canonical move is to disable the write affordance for a `viewer`:

```js
const me = await backlit.auth.me();

// Anonymous visitor: no user session at all.
if (me === null) {
  saveBtn.hidden = true;
  // On an Accounts glow you can offer sign-in here; see below.
} else {
  // Signed in — but a viewer can't write shared data. Reflect that.
  const canWriteShared = me.permission === "user" || me.permission === "admin";
  saveBtn.disabled = !canWriteShared;
  adminPanel.hidden = me.permission !== "admin"; // capture review, user list, …
}
```

This is a *mirror*, not the enforcement — the server still rejects a
`viewer`'s shared-data write no matter what your buttons do (see
[`auth.me()`](https://sdk.backlit.run/sdk.md#authme-promisebacklituser--null)
for why `permission` exists to branch ahead of the write). Reflecting it
in the UI is purely so the user isn't handed a button that will fail.

## Anonymous visitors and `auth.login()`

On a **Private** glow you never handle a signed-out state inside your
page — a visitor without a session is redirected to sign in before your
JavaScript runs. On a **Public** or **Accounts** glow your code *does*
run for anonymous visitors, so `auth.me()` returning `null` is a real
state to design for. What sign-in does from there differs by mode, and
the [auth-mode matrix](https://sdk.backlit.run/sdk.md#public-private-and-accounts-glows)
is the authority on it — in short, an Accounts glow lets any visitor
create an account, while a Public glow only admits allowlisted users
(e.g. an admin unlocking privileged controls).

```js
const me = await backlit.auth.me();
if (me === null) {
  // Anonymous on a Public/Accounts glow. Offer sign-in and route
  // anonymous input through capture in the meantime.
  loginBtn.onclick = () => backlit.auth.login(location.pathname);
}
```

Use [`auth.login()`](https://sdk.backlit.run/sdk.md#backlitauth)
to send the visitor to sign-in; anything they need to submit *before*
signing in belongs in
[`capture`](https://sdk.backlit.run/sdk.md#backlitcapture--write-only-capture-store),
the one store an anonymous session can write.

## A worked shape: an open glow with mixed access

Picture an Accounts glow — a community board. Anyone can read it; a
signed-out visitor can leave a "contact the organizers" note; a signed-in
member posts entries others can see but not edit; an admin curates a
shared banner and reviews the contact notes. That maps cleanly onto the
three stores and the two dials:

- The **contact note** is anonymous-writable → `capture.create`. No
  session required, and only an admin can read the notes back.
- A **member's post** is owned content a `viewer` may create →
  `records.*`. Self-served accounts land as `viewer`, and that's fine
  here because `records` doesn't gate creation on the shared-data
  permission.
- The **shared banner** everyone sees but only staff edit → `data.*`,
  with the edit control gated on `permission` being `user`/`admin`.

Each store's own reference section
([`data`](https://sdk.backlit.run/sdk.md#backlitdata--glow-shared-keyvalue),
[`records`](https://sdk.backlit.run/sdk.md#backlitrecords--public-read-owner-write-store),
[`capture`](https://sdk.backlit.run/sdk.md#backlitcapture--write-only-capture-store))
has the exact method signatures and the precise gate; this guide's job is
only to help you pick the right one and reflect its gate in the UI.

---

## Guide: Migrate a static app to Backlit

# Migrate a static app to Backlit

You have a standalone HTML app that keeps its state in `localStorage`,
`sessionStorage`, or `indexedDB`. Porting it to a glow means three
things: swap each browser-local store for the Backlit surface that
matches *who the data belongs to*, satisfy the deploy bundle contract,
and ship it as a draft you promote once it looks right. This guide is the
end-to-end sequence; the per-method facts live in the reference and this
guide links to them. (Starting from a blank page instead of porting? The
[build-a-new-app guide](https://sdk.backlit.run/guides/build-a-new-app.md)
is the from-scratch counterpart of this sequence.)

The one table you'll lean on hardest is the
[Migrations mapping](https://sdk.backlit.run/sdk.md#migrations) — browser
API on the left, Backlit surface on the right. Read it there; this guide
narrates the workflow *around* it rather than reprinting it.

## Step 1 — decide who each piece of state belongs to

Before touching code, sort every `localStorage`/`indexedDB` key into one
of three buckets. Ownership is the whole decision:

- **Global, everyone sees it** (a leaderboard, a published page, a shared
  board) → the glow-shared store,
  [storing app state](https://sdk.backlit.run/sdk.md#storing-app-state).
- **This signed-in user's own** (settings, drafts, history) → the
  per-user silo (also covered under
  [storing app state](https://sdk.backlit.run/sdk.md#storing-app-state)).
- **Owned content others read but can't edit** (posts, comments,
  profiles) → the owner-write records store.

The [Migrations mapping](https://sdk.backlit.run/sdk.md#migrations) spells
out exactly which browser API maps to which surface and when to pick each
— including where write-only submissions (contact forms, telemetry) go.
Don't restate it in your head; open it and place each key.

## Step 2 — port the reads and writes

Two shifts change how the code is shaped, both documented in the
[Migrations](https://sdk.backlit.run/sdk.md#migrations) section:

- **Storage is async.** A synchronous `getItem` becomes an awaited call,
  so the work that depended on it moves into an `async` initializer.
- **Objects go through the JSON helpers.** Instead of hand-rolling
  `JSON.stringify`/`parse`, use the `*JSON` helpers described under
  [storing app state](https://sdk.backlit.run/sdk.md#storing-app-state).

There's one correctness rule the port must respect, and it's the single
most common migration bug: a naive load → mutate → save is safe for
per-user data (you're the only writer) but a lost-update hazard on
**shared** data that more than one session can write. For any shared key,
use the read-modify-write helper from
[saving shared state safely](https://sdk.backlit.run/sdk.md#saving-shared-state-safely)
instead of a plain put. The reference's
[Migrations](https://sdk.backlit.run/sdk.md#migrations) example shows the
before/after shape; follow it.

A note on **keys**: browser storage lets you use any string, but a glow's
keyspace is restricted. If your old keys contain slashes or other
disallowed characters, rewrite them to a flat scheme — the exact rule is
in [Migrations](https://sdk.backlit.run/sdk.md#migrations); translate,
don't guess.

## Step 3 — proactively offer what a static page couldn't do

A faithful port reproduces the original behavior, but moving onto Backlit
unlocks capabilities a plain static page never had — realtime sync,
owner-scoped content, per-user silos, a private submission inbox,
permission-aware controls. If you're an AI agent doing the migration,
read what the app does and *offer* the ones that genuinely fit before you
deploy. The menu of "if the app does X, offer Y" is
[Offer advanced features that fit the app](https://sdk.backlit.run/sdk.md#offer-advanced-features-that-fit-the-app)
— suggest only what suits the app, and wire it in if the user says yes.

## Step 4 — satisfy the deploy bundle contract

A glow can't be *just* your HTML. The bundle has required root files, and
one hard prohibition that a ported static app frequently violates:

- **Required root files.** The bundle root must carry the app entry plus
  two short docs describing what the app is and how it uses the data
  namespaces. The exact filenames and what each must contain are the
  [deploy bundle contract](https://sdk.backlit.run/sdk.md#deploy-bundle-contract);
  add them if your original didn't have them.
- **No browser-local storage in the shipped code.** This is the whole
  point of Step 2 — a bundle that still reaches for browser-local
  persistence is rejected at deploy time. The deploy pipeline scans for
  it; the [deploy bundle contract](https://sdk.backlit.run/sdk.md#deploy-bundle-contract)
  and [packaging the deploy bundle](https://sdk.backlit.run/sdk.md#packaging-the-deploy-bundle)
  are the authority on what's scanned and rejected, so finish the port
  before you package.

How the bundle is assembled (the archive format, the upload request) is in
[packaging the deploy bundle](https://sdk.backlit.run/sdk.md#packaging-the-deploy-bundle),
and the upload endpoints themselves are in the
[deploy API](https://glow.backlit.run/openapi.yaml).

## Step 5 — deploy a draft, preview, then promote

Don't flip a migrated app straight to live. Ship the bundle as a
**draft** first, open the draft URL, click through the ported flows
(sign-in, a write, a reload to confirm state survives), and only then
promote the draft to live. The draft/promote endpoints are in the
[deploy API](https://glow.backlit.run/openapi.yaml).

## Let an agent do the mechanical parts

You don't have to package and upload by hand. An AI assistant connected
to the hosted [MCP server](https://mcp.backlit.run/mcp) can create the
glow, deploy the ported bundle as a draft, and promote it live once
you've previewed it — the same draft-first sequence as Step 5, driven for
you. See
[Deploy with an agent](https://sdk.backlit.run/sdk.md#deploy-with-an-agent)
in the reference for the connection details.

## Gotchas to plan for

- **Async everywhere.** Anything that read synchronously from
  `localStorage` now awaits; audit your init path for hidden synchronous
  assumptions.
- **The shared-write race.** Plain put on a shared key that two sessions
  can touch loses updates. Use the safe helper from
  [saving shared state safely](https://sdk.backlit.run/sdk.md#saving-shared-state-safely).
- **Key charset.** Slash-y or exotic keys must be flattened — see
  [Migrations](https://sdk.backlit.run/sdk.md#migrations).
- **Sign-in requirements differ by surface.** Some stores need a
  signed-in user; the [Migrations mapping](https://sdk.backlit.run/sdk.md#migrations)
  notes which, and the store's own reference section has the precise gate.
- **The bundle scan is at deploy time, not runtime.** A leftover
  browser-storage call fails the deploy, not the page — finish Step 2
  before Step 4.

---

## Guide: Build a new app on Backlit

# Build a new app on Backlit

You're starting from a blank page: an idea, no code yet. Shipping it as a
glow means writing ordinary static HTML against the `window.backlit` SDK,
wrapping it in a small deploy bundle, and promoting a previewed draft to
live. This guide is the end-to-end sequence for that first ship — the
per-method facts live in the reference, and this guide links to them
rather than restating them.

(Porting an app that already exists — one that keeps state in
`localStorage` or `indexedDB`? Start from the
[migration guide](https://sdk.backlit.run/guides/migrate-a-static-app.md)
instead; it covers the rewrite this guide gets to skip.)

## Step 1 — sketch the data model before the UI

Backlit gives you three general stores plus a write-only inbox, and the
whole design question is **who owns each piece of state**. Before writing
any markup, list what the app remembers and sort each item:

- **Everyone sees it, privileged users edit it** (a board, a roster, a
  published doc) → the glow-shared store, per
  [storing app state](https://sdk.backlit.run/sdk.md#storing-app-state).
- **It belongs to one signed-in user** (settings, drafts, history) → the
  per-user silo (same
  [storing app state](https://sdk.backlit.run/sdk.md#storing-app-state)
  table).
- **One person posts it, everyone reads it, nobody else edits it**
  (comments, entries, profiles) → the
  [records store](https://sdk.backlit.run/sdk.md#backlitrecords--public-read-owner-write-store).
- **The page captures it but must never read it back** (a contact form,
  telemetry) → the
  [capture store](https://sdk.backlit.run/sdk.md#backlitcapture--write-only-capture-store).

If who-may-write is the part you're unsure about, the
[permissions guide](https://sdk.backlit.run/guides/permissions-and-write-gating.md)
is the recipe for exactly that decision.

Then choose **keys**. The keyspace is flat — no slashes, no folders — so
compose hierarchy into the key itself (`note.{id}`, `prefs`), staying
inside the charset and length rules in
[Validation](https://sdk.backlit.run/sdk.md#validation). Prefer one key
per independent record over one giant blob: unrelated edits then never
touch the same key, which matters the moment two people write at once
(see [saving shared state safely](https://sdk.backlit.run/sdk.md#saving-shared-state-safely)).
Write the resulting table down — it becomes your `DATA.md` in Step 4.

## Step 2 — pick who can reach the app

A glow's **auth mode** decides whether visitors arrive anonymous, must
sign in, or can sign themselves up. It's a property of the glow (set at
create, changeable later via the
[management API](https://glow.backlit.run/openapi.yaml)), not of your
code — but it shapes your code: it decides whether `auth.me()` can
return `null` and which stores each visitor can use. The
[Public, Private, and Accounts matrix](https://sdk.backlit.run/sdk.md#public-private-and-accounts-glows)
is the authority; as a rule of thumb, a team tool wants Private (only
allowlisted people get in, your code never sees a signed-out state), a
community app wants Accounts (anyone can join — but a self-serve account
starts as a `viewer`, which can post to `records` and not to shared
`data`), and an open read-mostly page wants Public.

## Step 3 — write the app against the SDK

Load the SDK with one script tag and use `window.backlit.*` — there is
no build step, no bundler, no npm install. The tag, the URL channels,
and the async ground rules are in
[Loading](https://sdk.backlit.run/sdk.md#loading); note that the SDK
only talks to its backend on a deployed glow host, so the data surface
comes alive when you deploy (Step 5), not in a local preview.

Here's a complete, honest first app — a team "ship log" for a Private
glow. It reads shared state with a first-run default, gates the form on
the visitor's permission, and writes with the safe shared-write helper:

```html
<!doctype html>
<meta charset="utf-8">
<title>Ship Log</title>
<ul id="log"></ul>
<form id="add" hidden><input id="entry" placeholder="What shipped?"><button>Add</button></form>
<script src="https://sdk.backlit.run/sdk/v1/sdk.js"></script>
<script>
  (async () => {
    const me = await backlit.auth.me();
    const form = document.getElementById("add");
    // Viewers can read but not write shared data — reflect that in the UI.
    form.hidden = !me || me.permission === "viewer";

    let log = { entries: [] };
    try {
      log = (await backlit.data.getJSON("log")).value;
    } catch (e) {
      if (e.code !== "not_found") throw e; // first run: keep the default
    }
    render(log);

    form.onsubmit = async (ev) => {
      ev.preventDefault();
      const text = document.getElementById("entry").value.trim();
      if (!text) return;
      // A shared key several people write → updateJSON, never bare putJSON.
      const { value } = await backlit.data.updateJSON("log", (cur) => {
        cur ??= { entries: [] };
        cur.entries.push({ text, by: me.name || me.email, at: Date.now() });
        return cur;
      });
      render(value);
      form.reset();
    };

    function render(l) {
      document.getElementById("log").replaceChildren(...l.entries.map((e) => {
        const li = document.createElement("li");
        li.textContent = `${e.text} — ${e.by}`; // textContent: user strings are not HTML
        return li;
      }));
    }
  })();
</script>
```

The moves it demonstrates are each documented once in the reference:
branching on
[`auth.me()`](https://sdk.backlit.run/sdk.md#authme-promisebacklituser--null)
(including the `permission` field the form gates on), the first-read
`not_found` default under
[storing app state](https://sdk.backlit.run/sdk.md#storing-app-state),
and `updateJSON` — the default for any key more than one person writes —
under
[saving shared state safely](https://sdk.backlit.run/sdk.md#saving-shared-state-safely).
The error `code`s your handlers can branch on are the
[error-code table](https://sdk.backlit.run/sdk.md#error-codes).

## Step 4 — wrap it in a deploy bundle

A glow deploys as an archive with three required files at its root:
`index.html` plus two short docs, `APP.md` (what the app is, who it's
for) and `DATA.md` (which keys live in which store — the table you wrote
in Step 1). The exact contract, the reject behavior for a missing file,
and the fact that both docs are served publicly are the
[deploy bundle contract](https://sdk.backlit.run/sdk.md#deploy-bundle-contract);
the archive format, the browser-storage scan (a bundle that references
`localStorage`/`indexedDB` is rejected), and the packing command are
under
[packaging the deploy bundle](https://sdk.backlit.run/sdk.md#packaging-the-deploy-bundle).

For the ship log, the two docs are honestly this small:

```md
<!-- APP.md -->
# Ship Log

A shared changelog for the team: anyone on the allowlist can read it,
editors add a line when something ships. The team lead administers it.
```

```md
<!-- DATA.md -->
# Data layout

| Namespace | Key | Meaning |
| --- | --- | --- |
| `data` | `log` | the shared list of shipped entries (JSON, written via `updateJSON`) |
```

## Step 5 — deploy a draft, preview, then promote

Don't flip your first upload straight to live. Stage it as a **draft**,
open the preview URL, click through the real flows (sign in, a write, a
reload), and only then promote. The draft/promote endpoints are in the
[deploy API](https://glow.backlit.run/openapi.yaml) — or skip the
mechanics entirely: an AI assistant connected to the hosted
[MCP server](https://mcp.backlit.run/mcp) can create the glow, deploy
the bundle as a draft, hand you the preview URL, and promote on your
go-ahead. [Deploy with an agent](https://sdk.backlit.run/sdk.md#deploy-with-an-agent)
has the connection details and the two out-of-band transports.

## Step 6 — then outgrow the static page

Once the first version is live, the upgrades a plain static page could
never do are one step away, and the reference keeps a menu of them —
[offer advanced features that fit the app](https://sdk.backlit.run/sdk.md#offer-advanced-features-that-fit-the-app).
The two with their own guides: make shared state update live across
every open tab with the
[realtime guide](https://sdk.backlit.run/guides/realtime-collaboration.md),
and notify people when something changes even with the tab closed with
the [push guide](https://sdk.backlit.run/guides/push-end-to-end.md).
Add what fits the app; skip what doesn't.

## Gotchas to plan for

- **Every read and write is async.** There is no synchronous storage;
  structure init as an `async` function (see
  [Loading](https://sdk.backlit.run/sdk.md#loading) for why the IIFE).
- **First read of any key throws `not_found`.** Catch it and start from
  a default — the pattern under
  [storing app state](https://sdk.backlit.run/sdk.md#storing-app-state).
- **Shared writes race.** Any key two sessions can write goes through
  `updateJSON`, per
  [saving shared state safely](https://sdk.backlit.run/sdk.md#saving-shared-state-safely).
- **Keys and content types are validated.** Stay inside the charset,
  length, and content-type rules in
  [Validation](https://sdk.backlit.run/sdk.md#validation) — the SDK
  rejects violations before they leave the page.
- **No browser storage in the bundle.** The deploy scan rejects it —
  the rule is in
  [packaging the deploy bundle](https://sdk.backlit.run/sdk.md#packaging-the-deploy-bundle).
  Design on `window.backlit.*` from the first line and it never bites.
- **An Accounts sign-up is a `viewer`.** Design self-serve apps so
  user-generated content lands in `records`, not shared `data` — the
  trap is spelled out in the
  [auth-mode matrix](https://sdk.backlit.run/sdk.md#public-private-and-accounts-glows).

---

## Guide: Realtime sync and live collaboration

# Realtime sync and live collaboration

A glow's stores broadcast: every time a key is set or deleted, connected
sessions hear about it. Wire that into your app and two people looking
at the same board see each other's moves without anyone polling or
reloading. This guide is the recipe for building that — subscribing,
refetching, and (the half everyone forgets) **writing so that concurrent
edits merge instead of clobbering each other**. Live UI without safe
writes just makes the lost updates visible; the two halves ship
together.

The runtime facts each half rests on are documented once in the
reference — the event surface under
[`backlit.on`](https://sdk.backlit.run/sdk.md#backliton--realtime-subscriptions)
(event shape, which sessions receive which events, delivery guarantees,
reconnects) and the safe-write loop under
[saving shared state safely](https://sdk.backlit.run/sdk.md#saving-shared-state-safely).
This guide links to both rather than repeating them, so it can't drift
out of sync with them.

## The mental model

An event is a **hint, not the data**. What arrives names the store, the
operation, the key, and an integrity hash of the new value — never the
value itself (the exact shape is in
[`backlit.on`](https://sdk.backlit.run/sdk.md#backliton--realtime-subscriptions)).
Your app reacts by refetching what it actually cares about. That keeps
the loop simple and honest:

1. **Seed** — read or list the state you display.
2. **Subscribe** — on a matching event, refetch that key and re-render.
3. **Write safely** — anything multiple people edit goes through
   `updateJSON`, so a concurrent edit rebases instead of being lost.

Delivery is **best-effort**: an event can be missed across a reconnect
and can occasionally arrive twice — the guarantees and the recommended
compensations are spelled out in the reference's delivery-semantics
notes under
[`backlit.on`](https://sdk.backlit.run/sdk.md#backliton--realtime-subscriptions).
Design for it by treating events as cache invalidation, never as the
source of truth.

## A worked example — a live team board

Sticky notes on a shared retro board: anyone signed in posts a note,
everyone sees new notes appear live, and each note shows its author.
Notes are owned content — one person posts, others read but don't edit —
so they live in the
[records store](https://sdk.backlit.run/sdk.md#backlitrecords--public-read-owner-write-store)
under one key per note, and each event carries the note's owner.

```html
<script src="https://sdk.backlit.run/sdk/v1/sdk.js"></script>
<script>
  (async () => {
    const me = await backlit.auth.me();  // records needs a signed-in user
    const notes = new Map();             // key → { value, crc32c, owner }

    async function refresh(key) {
      try {
        const { value, crc32c, owner } = await backlit.records.getJSON(key);
        notes.set(key, { value, crc32c, owner });
      } catch (e) {
        if (e.code !== "not_found") throw e;  // deleted between event and fetch
        notes.delete(key);
      }
      render();
    }

    // Subscribe BEFORE seeding, so a write that lands mid-listing isn't
    // missed — refresh() is idempotent, so an early event is harmless.
    const off = backlit.on("storage.change", (e) => {
      if (e.store !== "records" || !e.key.startsWith("note.")) return;
      if (e.operation === "delete") { notes.delete(e.key); render(); return; }
      const have = notes.get(e.key);
      if (have && have.crc32c === e.crc32c) return;  // duplicate delivery — skip
      refresh(e.key);                                // fetch only the changed key
    });

    // Seed once from a listing; from here on, events keep the board fresh.
    let cursor = "";
    do {
      const page = await backlit.records.list("note.", { cursor });
      await Promise.all(page.items.map((it) => refresh(it.key)));
      cursor = page.next;
    } while (cursor);

    // Posting is just a write. The storage.change it triggers — in this tab
    // AND every other open session — is what updates the board, so there's
    // no manual render after the put.
    document.getElementById("post").onclick = () =>
      backlit.records.putJSON(`note.${me.uid}.${Date.now()}`, {
        text: document.getElementById("text").value,
      });

    function render() { /* draw notes; each entry's author is its owner */ }
  })();
</script>
```

Three habits in there are worth naming. **Refetch only the changed key**
— the event names it, so a busy board never re-lists everything. (The
initial seed above still fetches each key on its own; when you want the
whole starting set in one shot instead, reach for
[`records.getBatch`](https://sdk.backlit.run/sdk.md#recordsgetbatchprefix-opts--recordsgetbatchjsontprefix-opts), which returns the
page's values alongside the same cursor.)
**Dedup on the hash** — an event whose `crc32c` matches what you already
hold is a duplicate delivery or your own echo; skip the fetch (the field
semantics are in
[`backlit.on`](https://sdk.backlit.run/sdk.md#backliton--realtime-subscriptions)).
**Let your own write's event drive the UI** — a same-tab write fires
your handler too, so the posting path and the receiving path render
through one code path. And when a view unmounts, call the unsubscribe
function `on` returned (`off()` above) — connection lifecycle and
reconnect behavior are handled for you, per the reference.

## Writing so nobody's edit is lost

The board above dodges write conflicts by design: one note = one key =
one author. The moment several people edit the **same** key — a shared
counter, one list, a settings blob — a plain overwrite silently drops
whichever edit lands second. That's the lost-update bug, and the fix is
always the same helper:
[`updateJSON`](https://sdk.backlit.run/sdk.md#saving-shared-state-safely),
which loads, applies your change, and commits only if nothing moved in
between — retrying against the fresher value when it did.

Realtime raises the stakes on one specific habit: **deferring**. If you
hold a remote change back — say the user is mid-edit in a form — your
in-memory copy is now known-stale, and writing it back with a plain put
would erase the very change you deferred. Write back through
`updateJSON` (or the compare-and-swap primitives under
[`data.putJSONIfMatch`](https://sdk.backlit.run/sdk.md#datagetjson--dataputjson--dataputjsonifmatch))
and the stale copy rebases instead of clobbering. The reference calls
this pattern out explicitly in the delivery-semantics notes under
[`backlit.on`](https://sdk.backlit.run/sdk.md#backliton--realtime-subscriptions).

Also go easy on the wire: every write broadcasts to every connected
session, so a write per keystroke floods your peers (and your usage
allowance). Coalesce rapid edits and save when the value settles — the
debounce guidance lives with the write methods under
[`backlit.data`](https://sdk.backlit.run/sdk.md#backlitdata--glow-shared-keyvalue).

## Who receives what

Events are scoped like the stores they mirror: shared-`data` and
`records` events reach every connected session, `userdata` events reach
only the writing user's own sessions, and `capture` emits nothing — the
per-store rules are under
[`backlit.on`](https://sdk.backlit.run/sdk.md#backliton--realtime-subscriptions).
Remember the read side too: what a visitor can *refetch* on an event
still depends on the glow's auth mode and their sign-in state, per the
[Public, Private, and Accounts matrix](https://sdk.backlit.run/sdk.md#public-private-and-accounts-glows)
— an anonymous visitor on a Public glow can follow live shared-`data`
changes, but records reads need sign-in. Pick the store (and the glow's
auth mode) with the
[permissions guide](https://sdk.backlit.run/guides/permissions-and-write-gating.md)
if that decision isn't settled yet.

## When realtime is the wrong tool

- **A single-user app.** A notepad with one writer has nobody to sync
  with — skip the subscription entirely and just read on load.
- **Reaching people whose tab is closed.** `on` only works while a page
  is open. For "tell me when something happens even if I've left," ship
  a `notify.json` and use push — that whole loop is the
  [push guide](https://sdk.backlit.run/guides/push-end-to-end.md).
- **Correctness that can't miss an event.** Delivery is best-effort;
  if your app breaks when a change slips past it, re-list on a schedule
  or on focus and treat events as an accelerator, per the delivery
  notes under
  [`backlit.on`](https://sdk.backlit.run/sdk.md#backliton--realtime-subscriptions).