Design proposal · Pages · September 28, 2026

Persistent storage for pages, injected on window

Today a page is a read-only broadcast: it can render, sort, and filter what was baked into it, and keep private per-browser state in localStorage, but one reader's click is invisible to everyone else. This proposal adds a small persistent JSON store to every page, exposed to page script as window.superset.storage, held in one durable object per page, quota-limited, and live for everyone reading it: pages become two-way surfaces the whole organization writes to through one link.

TL;DR: a published page can keep one small JSON slot per person per key, and read everyone's. The page opens a socket straight to its own PageHub durable object, which holds the records in SQLite and pushes changes to every viewer, so neither our API nor Postgres is in a read or a write and there is no schema change at all. Access comes from the page's R2 manifest, which the realtime Worker reads after verifying the viewer's JWT locally. One slot per person means two people writing at once touch different rows, so no CRDT. Shipped shape and its costs in section 2; the use cases that justify it first.

1 · Use cases

Since agents author pages, storage means an agent can ship a tiny working app instead of a document. The use cases fall into three buckets, and the third is the reason to build it.

Collect a signal from the org

each viewer writes their own record · the page derives a view from getAll()

Remember the reader

each viewer reads their own records back

Close the loop back to agents — the headline

humans click · the publishing agent reads structured answers back and acts

Pinned comments already flow back to the watching agent, but they are free text, one thread at a time. Storage gives that channel structure: an agent publishes a triage page listing 40 flaky tests, teammates click fix / ignore / needs owner on each row, and the agent reads the rows back and executes the decisions — no comment parsing, no form service, no separate backend. The same shape covers variant picks, feature-flag ramp approvals, "which PRs land today", and incident action-item assignment.

The page becomes a form the org fills out and an agent executes. That is what no static HTML artifact elsewhere can do, and it makes agents more capable rather than pages merely prettier — so v1's definition of done is this loop (agent-side read of a page's storage ships with viewer-side writes), not the poll.

The common thread: none of these justify standing up a real app, all of them die without shared state, and all fit inside a slot of JSON per person, kept live. The voting page used as the worked example below is just the smallest demo of the first bucket.

2 · How it works — as built: the page talks to its own hub, with the API out of the hot path

The shape

A page is served under a policy that lets its script reach exactly one host: its own storage hub. Everything else a page renders is still in the document.

host (has the viewer's JWT)
  │  POST /v2/page/<id>/storage/ticket
  ▼
realtime Worker ── verifies the JWT against the API's JWKS
  │             ── reads the page's manifest from R2 (no database)
  │             ── signs {pageId, userId, name, image, author, writable, nonce, exp 60s}
  ▼
host posts the one ticket into the frame
  │
  ▼
page opens its own socket ──▶ PageHub (one per page)
                              ├─ spends the nonce (SQLite, not memory)
                              ├─ checks Origin is this page's frame origin
                              ├─ pins identity to the connection
                              ├─ upserts a visits row, replies hello
                              └─ pushes records to every connection on change

Neither our API nor Postgres is in a read or a write. The API keeps the manifest write it already did, one nudge after it, and the cross-page account purge.

Where access comes from

The manifest, which every page already has in R2 and which the usercontent Worker already treats as its whole authorization source. It gains two fields, organizationId and createdByUserId; taken down is already "no manifest". The rule is a pure function with tests: an organization page opens to a member, a just_me page only to its author, and a manifest with no organization authorizes nobody.

That last case is what lets this ship in one merge. A manifest written before these fields existed answers { fallback: true } rather than refusing, and the host serves that page over the bridge it already had. Deploy order stops being load-bearing, and the backfill upgrades pages to the direct path as it runs instead of gating the release. The bridge is deleted in a follow-up once every manifest carries the fields.

When access changes, writePageManifest nudges the hub, which re-reads and closes the connections that no longer pass with a terminal revoked. No manifest means deleted or taken down, and the records go with it. A slow timed re-read covers a lost nudge.

The page-facing API

const store = window.superset?.storage;
if (await store?.ready) {
  store.viewer;    // { userId, name, image }
  store.author;    // true for the page's author
  store.writable;  // whether this viewer may write

  await store.set("lunch-vote", "Ramen");   // my slot under this key
  const mine = await store.get("lunch-vote");
  const all = await store.getAll("lunch-vote");
  store.subscribe("lunch-vote", (records) => render(records));
}

One slot per person per key: you write yours, you read everyone's. Two people writing at once touch different rows, so there is nothing to merge and no CRDT. Codes are stable and branched on rather than parsed from prose: quota_exceeded, rate_limited, unauthenticated, invalid, unavailable, and revoked, which is terminal. A page must still render from zero records, because there is no host in the thumbnail renderer or a file:// preview.

Data model and limits

-- inside PageHub(pageId), in Cloudflare
records (key, user_id, value, size_bytes, updated_at, PRIMARY KEY (key, user_id))
visits  (user_id PRIMARY KEY, name, image, first_seen_at, last_seen_at)
spent_nonces (nonce PRIMARY KEY, spent_at)

The records hold opaque ids; name and image come from the person's visits row at read time, so a rename lands the next time they open the page and the hub holds nothing personal. 64 KiB a value, 500 keys a person, 4 MiB a page, plus a per-connection call budget because page script can now reach its own hub on CPU we are billed for. Because the hub is one object per page it is also the only writer, so the counts it measures cannot be stale by the time it writes.

What this cost, stated plainly

  • The page CSP opens one hole, to our realtime origin only. The skill's "no network" line becomes "a page can reach its own hub".
  • A credential crosses into untrusted page script. It is single-use, spent at upgrade, scoped to one page and expires in a minute; the host's JWT never enters the frame. The nonce is in SQLite because a hibernation inside that minute would otherwise make it replayable.
  • Identity must come from the signed token. An earlier revision took the display name from the request body, which let any member attribute their records to a teammate.
  • No table means no cross-page query. Account purge sweeps the pages in the organizations the person belonged to, which leaves one gap: someone who leaves an organization and later deletes their account keeps their records on that organization's pages.
  • The JWT's organization list is hourly, so a removed member keeps access until it expires, as with the org nudge socket today.

Still open

  • The runtime gives up on its handshake after two seconds. On the web the iframe is in the server-rendered HTML and starts loading before the bundle hydrates, so a slow load can leave storage permanently unavailable for that view. Raising the deadline stalls a legitimately host-less viewer for just as long, so the fix is an explicit negative hello rather than a bigger number.
  • Whether to keep the bridge fallback at all, or delete it and make the backfill a hard precondition of the Worker deploy.
  • Presence rides the same socket and is its own change, with the cloud presence stack lifted to packages/ui.

Diagrams and the full non-negotiables list are on the agreed walkthrough: Page storage on the hub.

3 · Prior art: Claude's artifact db — claude.ai already ships this shape; what we take from it

Published claude.ai artifacts get a window.claude API injected by the hosting shell, and await claude.use("db") hands page script a persistent, realtime, Firestore-style document store shared by the artifact's viewers — documents and collections, get/set/ update/delete, filtered queries, and onSnapshot subscriptions with latency compensation. Claude the agent can read and write the same rows from outside the page, so "publish a form, humans fill it, agent reads structured answers and acts" is a shipped loop there today.

The architectures converge

Their design and this proposal make the same three moves, independently: the page is sandboxed with no network of its own; a host-injected runtime bridges calls out (claude.use() there, window.superset.storage here); and identity comes from the signed-in shell, never from the page or its URL. Their realtime contract is also ours: delivery "rides a realtime stream when available and falls back to periodic refresh" — push with a polling floor. That an independent team landed on the same shape is the strongest available evidence the shape is right.

Voting is their canonical example too

Their access rules are declarative, fixed at publish, keyed on sharing level, and their own documentation uses exactly the per-viewer-record shape this proposal hardcodes:

db: { rules: [
  { path: "votes", read: "view", write: "owner" },   // everyone reads
  { path: "votes/{self}", write: "interact" },       // each writes only their own
]}

Our v1 is that rule as the only mode. If we ever generalize, their rules syntax is the blessed path — no need to invent one.

Rules of theirs worth adopting from day one

  • "Can't see" equals "doesn't exist". They have deliberately no permission-denied and no not-found on reads: a document a viewer cannot see behaves identically to one that was never written. Kills a whole class of probing.
  • Named-quota errors. Every cap rejects with a stable code and a message naming which cap (quota_exceeded, resource_exhausted) so pages can tell viewers what happened instead of failing silently.
  • No transactions, and say so. They ship last-writer-wins plus one cooperative TTL-lease primitive, and shape the data model (per-viewer paths, derive-on-read) so conflicts don't arise. Same call here.
  • Private-from-the-owner subtrees. Their per-viewer paths are hidden even from the artifact's owner, which enables sealed votes and planning poker (write privately, copy to a shared path when you reveal). Worth keeping in mind for our v2.

What we deliberately don't copy in v1

Queries, subscriptions-with-64-cap, leases, nested collections, and the rules engine are three years of surface area serving use cases we don't have yet. Our flat per-viewer-record API is the ~10% that serves every launch use case, with their contract as the map for wherever we grow next. Competitively this also frames the stakes: Superset's pitch for pages over artifacts is "the org's surface — listed, versioned, comments wired to agents." Interactivity is now a dimension where artifacts are ahead; this proposal closes it.