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()
- Votes and polls — "which design variant?", "which name do we ship?". A design-shotgun comparison board that collects the verdict itself instead of comments trickling in.
- RSVPs and signups — offsite attendance, on-call swaps, workshop slots.
- Claiming — a bug-bash or migration checklist where each row has a claim button; a claim is your record on that row's key, so double-claims can't happen.
- Estimation poker — hidden per-person estimates, revealed when everyone's record is in.
- Retro boards — each person's cards are their own records; grouping is derived on read.
- Lightweight approvals — a release-readiness page where each stakeholder stamps ✅/❌ with a note; the page is the sign-off record, versioned like everything else.
- Onboarding checklists — each new hire checks off their own steps; the author sees who's stuck where.
Remember the reader
each viewer reads their own records back
-
Cross-device continuity — the filter you set on
desktop is the filter your phone shows.
localStorageonly remembers one browser. - Read-tracking on recurring pages — a daily digest that collapses what you've already seen.
- Preferences that survive republishing — storage keys to the page, not the version, so your column sort outlives the agent's nightly republish.
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.