backlit / JavaScript SDK reference

JavaScript SDK reference

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

The same bundle also works when no Backlit server is in reach — opened straight from file://, embedded in an AI design tool's preview iframe, or served from any non-backlit.run host. In that situation the SDK transparently mirrors every operation against browser-local IndexedDB under a fixed [email protected] identity, so an app can be prototyped end-to-end before it ships to a glow. See Local development and testing.

A plain-text copy of this reference is published at backlit.run/sdk.md so AI agents can fetch and parse the surface without HTML scraping.

Loading

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

<!doctype html>
<script src="https://cdn.backlitdev.run/sdk/v1/sdk.js"></script>
<script>
  const email = await window.backlit.auth.user();
  // ...
</script>

Two URL channels are supported:

URLStabilityUse for
https://cdn.backlitdev.run/sdk/v1/sdk.jsStableProduction glows. Pinned to the v1 surface.
https://cdn.backlitdev.run/sdk/latest/sdk.jsRollingQuick 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").

window.backlit.mode is "remote" when the SDK is talking to the live Backlit server and "local" when it's persisting to browser-local IndexedDB. The value is fixed for the lifetime of the page; branch on it to render a "running locally" banner or to disable features that only make sense once deployed. See Local development and testing for the detection rule and override hook.

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:

StoreWho can read & writeUse for
backlit.dataevery visitor of the glow (one shared set)leaderboards, shared docs, published content
backlit.userdataeach signed-in user, isolated (Private glow)per-user settings, drafts, history
backlit.recordsany signed-in user creates + reads all; only the creator/admin edits (Private glow)forum posts, comments, user submissions, galleries

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

// 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:

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 session ever writes. But when two visitors 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:

// `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 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.

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 a Private glow.
localStorage (app-global state) backlit.data.* Content that every visitor should see: a wiki page, a leaderboard, a published gallery.
IndexedDB keyed records backlit.data.* / userdata.* Same split as above. Backlit's keyspace is flat — collapse object-store + key into one string.
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 the appropriate image/* content type.

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, 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:

// 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 instead of putJSON.

When you need a key gone, call backlit.data.delete(key) or backlit.userdata.delete(key) — see the delete sections 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, chat, 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
keeps per-user settings, drafts, or history in one global blob Per-user data — each signed-in user gets an isolated silo backlit.userdata.* (Private glow)
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.permission()
needs sign-in, or should be limited to specific people or domains Private auth mode — restrict to an allowlist; 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-api OpenAPI for the direct and ticket-based upload endpoints). A few rules for what to put inside.

index.html at the bundle root. Glows are served from https://{name}.backlit.run/; the file returned for / is the glow's default_file, which defaults to index.html. Either ship an index.html at the top level of the bundle or set default_file explicitly when you create the glow. 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:

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:

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"):

The deploy tools hand the bundle off out of band, directly to https://glow.backlitdev.run rather than over the MCP connection — so if your agent runs in a sandbox with a domain allow-list, add that host to its allowed/permitted domains or the upload 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.

Local development and testing

The SDK auto-detects whether it's running on a real Backlit host. When the page's hostname doesn't end in .backlit.run (or a recognised dev suffix — .localtest.me, localhost, *.localhost), the SDK enters local mode: every data.* / userdata.* / capture.* call persists to a browser-local IndexedDB database named backlit_local in the page's origin, and auth.user() returns the fixed string "[email protected]". No Backlit services need to be reachable.

This is what makes the SDK usable inside AI design tools (v0, bolt, lovable) and from raw file:// pages: drop the same <script src> tag, write the same code against window.backlit, and deploy unchanged once you're ready. Local mode is a faithful mirror of the production surface, not a stub:

Use window.backlit.mode ("remote" or "local") to branch UI:

if (backlit.mode === "local") {
  document.getElementById("env-banner").textContent =
    "Running locally — data is stored only in this browser.";
}

Forcing the mode

Set window.__BACKLIT_LOCAL to true or false before the SDK script loads to override the auto-detect. Useful when serving from a plain static server on localhost (which auto-detects as "remote" since that's also the convention for live Backlit dev setups), or when you explicitly want to test the live Backlit surface from an unusual hostname.

<script>window.__BACKLIT_LOCAL = true;</script>
<script src="https://cdn.backlitdev.run/sdk/v1/sdk.js"></script>

Inspecting and clearing local data

The data lives in your browser's DevTools under Application → IndexedDB → backlit_local, with one object store per surface (shared, userdata, private) plus a meta store holding the running byte counter. To wipe state and start clean:

indexedDB.deleteDatabase("backlit_local");

Wire that one-liner into a debug button on your own dev page if you find yourself resetting state often.

When IndexedDB is unavailable

In some browsing modes (Safari Private mode in older versions, some in-app webviews, locked-down enterprise browsers) IndexedDB throws or returns undefined. Local mode detects this at first use and every data operation rejects with BacklitError(code: "internal") whose message names the constraint. auth.user() and window.backlit.mode keep working — they don't touch IndexedDB.

Public vs. Private glows

What works for a given visitor depends on the glow's auth_mode and whether the visitor has signed in.

Surface Public glow (anyone) Private glow, signed in Private glow, signed out Local mode (any host off *.backlit.run)
backlit.auth.user() returns null returns the verified email n/a — Backlit redirects the visitor to auth.backlit.run before any JS runs returns "[email protected]"
backlit.auth.permission() returns null returns admin / user / viewer returns "admin"
backlit.data.* (reads) works works works (IndexedDB)
backlit.data.put / delete works works (blocked for viewer) works (IndexedDB)
backlit.userdata.* throws unauthenticated works (per-user silo) works under the fixed local user
backlit.records.* (reads) throws unauthenticated works (read every record) works (IndexedDB)
backlit.records.put / delete throws unauthenticated works on your own records (others' → forbidden) works (single local user owns all)
backlit.records.reassign throws unauthenticated works only for admin (else forbidden) works (single local user is admin)
backlit.capture.create / update works works works (IndexedDB)
backlit.capture.get / list / delete throw unauthenticated (no admin) work only for admin (else forbidden) work (single local user is admin)

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.

backlit.auth

auth.user()

backlit.auth.user(): Promise<string | null>

Returns the verified email of the signed-in user, or null for Public glows (which never have a user session). Never throws — network failures and missing sessions both resolve to null, since both render the same fallback UI in practice.

In local mode this method always resolves to "[email protected]", so apps written against a non-null return value can be developed and tested without a real Backlit signin flow.

const email = await backlit.auth.user();
if (email === null) {
  // Show the anonymous experience.
} else {
  document.getElementById("greeting").textContent = `Hello, ${email}`;
}

auth.permission()

backlit.auth.permission(): Promise<"admin" | "user" | "viewer" | null>

Returns the signed-in user's permission level (a lowercase enum), or null for Public glows, signed-out visitors, and sessions minted before permissions shipped. Never throws — like auth.user(), failures resolve to null.

"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 — this method exists so apps can branch ahead of a write, e.g. hiding a Save button. In local mode it always resolves to "admin".

const perm = await backlit.auth.permission();
if (perm === "viewer") {
  saveButton.disabled = true; // the server would 403 a data.put anyway
}

backlit.data — glow-shared key/value

Readable and writable by any visitor with a valid glow session — i.e. every visitor of a Public glow, and every signed-in visitor of a Private glow. There is one shared keyspace per glow, and more than one session can write the same key at the same time.

For any shared key more than one session can write, updateJSON 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 compare-and-swap primitive below — reach for that directly only for create-only locks or a custom merge.) Plain data.put 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 (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 broadcasts on writes, so app code can dedup local state against server state without an extra fetch. 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.

data.get(key)

backlit.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).

Parameters

Throws

BacklitError with code === "not_found" if the key has never been written. See Errors for the full list.

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, body, contentType)

backlit.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 is the default (or the putIfMatch primitive for non-JSON bodies).

Parameters

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), unsupported_content_type, payload_too_large, quota_exceeded. See Errors.

// 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, body, contentType, expectedCrc32c)

backlit.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 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).

The result is a discriminated union:

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

Returns

A Promise<PutIfMatchResult>.

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, forbidden for a viewer, unsupported_content_type, payload_too_large, quota_exceeded). See Errors.

// 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, which wraps this method with serialize/parse and an already-parsed witness, or data.updateJSON, 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 / data.updateJSON

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:

type PutJSONIfMatchResult<T> =
  | { matched: true;  crc32c: string }
  | { matched: false; witness: { value: T; crc32c: string } };
// 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.

data.delete(key)

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

Remove the value stored at key. The deletion is observable via backlit.on("storage.change") under {store:"data", operation:"delete"}.

Parameters

Returns

A Promise<void>.

Throws

BacklitError. Common codes: invalid_key, not_found (no value at this key — deletion is not idempotent; call get first or catch the code if you want it to be).

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

data.list(prefix?)

backlit.data.list(prefix?: string): Promise<Array<{ key: string, crc32c: 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, so apps can dedup against locally-cached state in one round trip — no per-key get needed just to discover what changed.

Parameters

Returns

A Promise<Array<{ key, crc32c }>> of matching entries, in unspecified order. The current server returns the entire result set in one response; pagination is a possible v2 evolution.

Throws

BacklitError with code === "invalid_prefix" if the prefix violates the charset or length rule.

const entries = await backlit.data.list("posts.");
// → [{ key: "posts.0001", crc32c: "+IxOLg==" }, ...]
const stale = entries.filter((e) => e.crc32c !== cache.get(e.key));

backlit.userdata — per-user data silo

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

Available on Private glows with a signed-in user only. On Public glows (and on any future signed-out Private state), every method throws BacklitError with code === "unauthenticated" and an actionable message. Call backlit.auth.user() first if you need to branch.

In local mode the methods always succeed — the fixed [email protected] user is implicitly signed in, so unauthenticated is never thrown.

userdata.get / put / delete / list / *JSON

backlit.userdata.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string }> backlit.userdata.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }> backlit.userdata.delete(key: string): Promise<void> backlit.userdata.list(prefix?: string): Promise<Array<{ key: string, crc32c: string }>> backlit.userdata.putIfMatch(key: string, body: BodyInit, contentType: string, expectedCrc32c: string): Promise<PutIfMatchResult> backlit.userdata.getJSON<T>(key: string): Promise<{ value: T, crc32c: string }> backlit.userdata.putJSON(key: string, value: unknown): Promise<{ crc32c: string }> backlit.userdata.putJSONIfMatch<T>(key: string, value: unknown, expectedCrc32c: string): Promise<PutJSONIfMatchResult<T>> backlit.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 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 (store: "userdata") that only the writing user's own sessions receive.

const email = await backlit.auth.user();
if (email === 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 on Private glows with a signed-in user only. On Public glows (and any signed-out Private state) every method throws BacklitError with code === "unauthenticated", exactly like backlit.userdata. The store is free — no subscription required.

In local mode the single fixed user owns every record, so writes always succeed and reassign works too.

records.get / put / delete / list / putIfMatch / *JSON

backlit.records.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string, owner: string }> backlit.records.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }> backlit.records.delete(key: string): Promise<void> backlit.records.list(prefix?: string): Promise<Array<{ key: string, crc32c: string, owner: string }>> backlit.records.putIfMatch(key: string, body: BodyInit, contentType: string, expectedCrc32c: string): Promise<{ matched: true, crc32c, owner } | { matched: false, witness: { value, contentType, crc32c, owner } }> backlit.records.getJSON<T>(key: string): Promise<{ value: T, crc32c: string, owner: string }> backlit.records.putJSON(key: string, value: unknown): Promise<{ crc32c: string }> backlit.records.putJSONIfMatch<T>(key: string, value: unknown, expectedCrc32c: string): Promise<PutJSONIfMatchResult<T>> backlit.records.updateJSON<T>(key: string, mutate: (current: T | undefined) => T, options?): Promise<{ value: T, crc32c: string, owner: string }>

Signatures, parameters, validation rules, and the *JSON convenience wrappers match the backlit.data.* methods of the same name, with these differences:

// 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.
for (const { key, owner } of await backlit.records.list("comment.")) {
  const { value } = await backlit.records.getJSON(key);
  render(value, owner);
}

records.reassign(key, newOwnerUid)

backlit.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"), and an unknown key throws not_found. The SDK does not pre-check admin — the server is the boundary.

// 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 Public and Private glows alike — no user session required. Reads, enumeration, and deletion (get / list / delete) are admin only — they need a signed-in Private user whose permission is admin, so ordinary app code can capture data but can never read it back even if the page is compromised.

capture.create(prefix, body, contentType)

backlit.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

Throws

BacklitError. Common codes: invalid_key, invalid_argument, unsupported_content_type, payload_too_large, quota_exceeded.

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, body, contentType)

backlit.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

Throws

BacklitError. Common codes: invalid_handle, unknown_handle (no record at that handle), plus the body / size codes from create.

// 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)

backlit.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

Throws

BacklitError. forbidden when the signed-in user is not an admin; unauthenticated on Public glows or signed-out Private visitors (no user session); unknown_handle if nothing is stored at that handle; invalid_handle for a malformed handle.

// 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?)

backlit.capture.list(prefix?: string): Promise<Array<{ handle: string, crc32c: string }>>

Admin only. Enumerate the handles stored in this glow's private store, each with its body's CRC32C. Returns handles only, never values.

Parameters

Throws

BacklitError. forbidden when the signed-in user is not an admin; unauthenticated on Public glows or signed-out Private visitors (no user session); invalid_prefix for a bad prefix.

// Only meaningful for an admin user; branch on auth.permission() first.
if ((await backlit.auth.permission()) === "admin") {
  for (const { handle } of await backlit.capture.list("contact")) {
    const { value } = await backlit.capture.get(handle);
    console.log(handle, await value.text());
  }
}

capture.delete(handle)

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

Admin only. Remove the object stored at a handle.

Parameters

Throws

BacklitError. forbidden / unauthenticated (same gating as get); unknown_handle if the handle was never created; invalid_handle for a malformed handle.

await backlit.capture.delete(handle);
In local mode the single user is always an admin, so get / list / delete work unconditionally — an app built against them behaves the same once deployed and signed into as an admin.
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).

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

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

Delivery semantics

Reconnect

In remote mode 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.

In local mode events flow via BroadcastChannel between tabs of the same origin and synchronously (on the microtask queue) within a single tab. There is no socket to reconnect.

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.

Errors

Every method (except auth.user()) rejects its returned Promise with a single error class, BacklitError. Discriminate on .code.

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.

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":   /* glow data cap hit */ break;
      default: throw e;
    }
  } else {
    throw e;
  }
}

Error codes

codeSourceMeaning
not_foundserverdata.get / userdata.get on a key that has never been written.
unauthenticatedserver / SDKuserdata.* called without a user session (Public glow, expired session).
cross_glow_tokenserverYour session cookie is bound to a different glow. Rare; indicates cookie reuse.
forbiddenserverA 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. (capture.create/update and records create are unaffected.)
invalid_keybothKey violates the charset ([A-Za-z0-9.\-]) or length (1–256 bytes) rule.
invalid_prefixbothPrefix violates the charset or length rule (length 0 is allowed).
invalid_handlebothcapture.update handle is not a 36-char lower-hex 8-4-4-4-12 GUID.
unknown_handleservercapture.update against a well-formed GUID that has no record.
invalid_bodybothThe body failed a content check: server-side for a declared Content-Type mismatch, or SDK-side when getJSON reads a key whose stored bytes are not valid JSON.
invalid_argumentSDKClient-side rejection: wrong body type, missing contentType, or a putJSON value with no JSON form (undefined / function / circular / BigInt).
unsupported_content_typebothContent-Type isn't on the allowlist (or is one of the always-denied executable types).
payload_too_largeserverBody exceeds the server's per-object cap (default 10 MiB).
quota_exceededserverThe write would push you over your account's storage allowance. The just-written object has already been rolled back.
method_not_allowedserverA non-SDK caller used an unsupported HTTP method. Should never fire from the SDK.
invalid_hostserverThe request reached Backlit with no recognisable glow Host. Should never fire from the SDK.
glow_not_foundserverThe glow has been deleted (or never existed) since the page loaded.
glow_darkserverThe glow has been switched to Dark since the page loaded.
region_mismatchserverThe request reached a Backlit region different from the glow's region.
networkSDKfetch itself rejected: offline, CORS, aborted, etc. .backlitCause holds the underlying error.
conflictSDKupdateJSON exhausted its compare-and-swap retries (maxAttempts, default 6) under write contention.
internalbothServer returned 5xx (or a non-JSON 4xx), or the response body wasn't parseable.

A few patterns worth pulling out:

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 (updateJSON gave up after its retries), 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

Used by every data.* / userdata.* / records.* method that takes a key — get, put, putIfMatch, delete, getJSON, putJSON, putJSONIfMatch, updateJSON.

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

Prefixes

Used by data.list, userdata.list, records.list.

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

Handles

Used by capture.update / get / delete. A handle is the keyed string "{prefix}-{guid}" returned by create:

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:

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