Notion platform
Two-way data sync
Try it, without a Notion token
Everything below runs against a demo workspace that implements the same adapter interface as the Notion API, including its rate limits and its Free-plan block cap. Two renderers are shown side by side because that is the honest picture of the design: the left frame is the hosted embed, the right frame is the downloaded artifact running the way Notion runs an HTML block, in a sandbox with an opaque origin and no network at all.
Connect a workspace
Nothing connected yet. The demo workspace needs no credentials and implements the same adapter interface as the Notion API, including rate limits and the Free-plan block limit.
Or paste a Notion token (PAT or internal connection)
The token is validated by reading its own identity, sealed with AES-256-GCM and stored server-side. It is never placed in a widget URL: the widget gets a scoped instance key instead.
Bind a component
Nothing matches “”. The catalog has 0 widgets.
grocery-list
grocery-listAnswer the open questions on your own workspace
Four things cannot be settled from documentation, only from a real Notion page (research §8). The probe file measures the sandbox it is running in and draws the answer:
- Does an HTML block allow
localStorage, or is the origin opaque? (Q1) - Does the embed frame permit popups and clipboard writes? (Q2)
- Does
PATCH /v1/blocks/{id}swap an embed's file in place? (Q3, answered by the refresh button above on a real connection) - Does the mobile app render an HTML block where it refuses embeds? (Q4, open the page on a phone)
Ten components, bound for real
Ten components are wired, not illustrated: the playground above binds any of them in one click, and each one carries a schema that has been driven through the whole loop by npm run check:components (138 assertions, one component at a time: bind, write, find the row, edit the row in Notion, find the value in what a device renders, trash the row, find the tombstone). They were picked the way a test suite is picked, so between them they cover every state shape the other 182 widgets use and every property type the mapping layer can write.
| Component | What it proves | Columns |
|---|---|---|
| Habit Heatmap try it records | State is {habit: {date: level}}, so inference cannot read it. Declared, and the key is the habit and the day: two ticks in one cell are one row, which is what the grid already means. | Habit* · Date* · Level |
| Expense Tracker try it records | An array of flat records, declared rather than inferred: Amount is a number column (sums and rollups work), Category is a select with the widget's own options, Date is a date. | Description* · Date* · Amount · Category |
| Chore Checklist try it records | Done is a checkbox, not the string "true". The key is the chore and the day, so the same chore on two days is two rows and ticking it in Notion ticks it on every device. | Task* · Day* · Assignee · Done |
| Action Item Tracker try it records | State is one of three values the widget knows about, so it becomes a select with exactly those options. The key is the task and its due date. | Task* · Due* · Owner · State |
| Bookmark Library try it records | URL is a real url property, so the cell is clickable in Notion instead of being text that looks like a link. Tag is a select, opened up by the first write if it is a new one. | Name* · URL* · Tag |
| Daily Journal try it records | The long-text case: the widget allows 3,000 characters, Notion holds 2,000 per rich text object, so a long entry is written as several objects in one cell and read back whole. | Date* · Title · Body |
| Grocery List try it kv-blob inferred | State-blob mode, and honestly so: the widget keys its lists by a hash of the label, so a record schema would produce a List column full of values no person can read. One row holds the document, with a revision and an updated stamp. | Arena key · State · Rev · Updated |
| Invoice Tracker try it records | One of the 84 widgets generated from a SPEC, so its columns come from the widget's own field list: Amount is a number, Issued and Due are dates, State is a select over Draft, Sent, Paid, Overdue, Void. | Invoice alias · Client alias · Amount (USD) · Issued · Due · Note · State · Arena key* |
| Equipment Inventory try it records | SPEC family with two selects: the provisioned table offers the same options the widget's dropdown does, because the vocabulary travels with the binding instead of being invented at the first write. | Equipment alias · Location alias · Category · Condition · Quantity · Notes · Status · Arena key* |
| Incident Log try it records | The checklist case: five fixed steps per incident, stored in Notion as a multi_select of the steps that are done. The labels are fixed by the widget, so nothing is lost and the column is still queryable. | Summary · System alias · Severity · Detected · Owner alias · Impact · State · Checks · Arena key* |
The star marks a key field. A row is identified by its key columns and nothing else, which is why those columns matter more than they look: a widget that mints its own id (the SPEC family, so the three on the bottom row) gives every record an identity, so two invoices that happen to look identical are two rows. A widget that does not gives its records a key built from their own fields, so two identical expenses on the same day are one row. Each component says which of the two it is, above and in the playground, instead of leaving a user to find out by losing a row.
The ten are curated, not the limit. The palette above lists all 192 widgets, and 147 of them can be bound right now: 10 carry a hand-written first document, 81 are generated from a field list the widget itself carries, and 56 were measured by running the widget headlessly and asking it what it persists —45 persist nothing a table could hold, and the palette says so on the card instead of offering a button that would create an empty table.
Two commands keep that number honest, and both need npm run dev:
npm run check:coverage— binds every widget that can produce a first document (147 of them), provisions its table, uploads the document, reads it back and compares, then pushes the same document again to prove the second push adds no row. 585 assertions, and the 45 widgets it cannot bind are listed by name rather than skipped in silence.npm run check:components— the ten above, driven the long way: write from the widget, find the row, edit the row in Notion, find the value in what a device renders, trash the row, find the tombstone. 138 assertions.
Of the 166 widgets that persist state, ten do not use the notion-arena.<slug>.v1 key they look like they should, and the bind reads the key out of the file rather than assuming it, because adopting the wrong key is invisible until a device renders empty.
The model, in four sentences
- Notion is the source of truth. Arena stores bindings, a sealed instance key, and a cache. Delete Arena and the data is still a database in your workspace, readable by any Notion client.
- An instance is one component, in one configuration, bound to one data source. It has an id and a capability key, and both live in the URL, so identity travels with the link and never depends on cookies or browser storage.
- A renderer is anything that draws that instance: the hosted embed, the HTML block, a standalone page, an integrator's site. Every renderer is the same file with a different transport.
- Widgets keep their own state shape and their own code. The mapping layer, not the widget, decides whether that shape becomes typed rows (
records) or one JSON cell (kv-blob).
Two drawings carry this section better than prose does: figure 01 is the problem, three devices and three separate copies with no channel between them, and figure 02 is the shape that replaces it. Both are in the gallery, with the rest of them.
localStorage to fall back on. Any design that pretends otherwise ships a component that silently loses data on Safari and on every browser that partitions storage. The offline transport is therefore not a degraded mode bolted on at the end: it is the reason the live pull exists at all.Two channels, one instance
The same instance is reachable two ways. They differ in transport and in what the browser is allowed to do, not in what they show.
| Embed block | HTML block | |
|---|---|---|
| Transport | live: fetch on open, SSE for pushes, 20 s poll as fallback | outbox: reads the baked snapshot, queues ops locally |
| Identity | instance id and key in the embed URL | same key, baked into the file at publish time |
| Storage | first-party to Arena, so the cache and the outbox work | opaque origin: <code>localStorage</code> throws, memory only |
| Writes | pushed to the workspace adapter immediately | queued, then handed off by sync code or refreshed server-side |
| Refresh | new revision arrives in place, no reload | server re-renders and PATCHes the block's file in place |
The sync code
An offline renderer cannot reach Arena, but a person can carry text between two machines, so that is the bridge. The bridge exposes a sync code: instance id, base revision, and the queued operations, encoded and shown as a short string. Paste it into any reachable renderer on the same instance and the ops are pushed from there, with the same idempotency keys they were born with, so replaying the same code twice cannot double-write.
ARC1.eyJ2IjoxLCJpIjoiaW5zXzAxaDkiLCJiYXNlIjo0MSwib3BzIjpbeyJpZCI6Im9wXzdmMiIsImtleSI6IndhdGVyIiwicGF0aCI6WyIyMDI2LTEwLTEyIl0sInZhbHVlIjoyLCJhdCI6MTc2MDMyMTA0Mn1dfQThe server-side refresh is the other half: the publish endpoint re-renders the instance with the current revision and swaps the block's file through a single PATCH /v1/blocks/{id} with a fresh file upload, so an offline reader picks up changes without an extra block appearing on the page every time.
Binding a component to your data
Binding answers two questions: which data source, and how does the widget's state become rows. The mapping layer infers the second by default, from the sample document in the widget's own registry entry, and a declared schema overrides it where inference guesses wrong.
| Mode | State shape | In Notion | Example |
|---|---|---|---|
| records | array of flat records | one row per record, one property per field, inferred types | expense tracker → Date / Amount / Category / Description |
| records (declared) | nested but rectangular | one row per leaf, with the keys promoted to properties | habit heatmap → Habit / Date / Level |
| kv-blob | nested, arbitrary | one row per instance, whole document as JSON in a rich_text cell | grocery list → Component / Key / State / Rev / Updated |
A record's identity is a business key, not a row id: the widget says "water" and "2026-10-12" and the engine finds the row that matches. Renaming a component or re-binding it to a different database therefore does not orphan data, and a person editing the table by hand cannot break the link by touching a hidden column.
Conflicts
Two writers can touch one field within the same minute, and the workspace is the only place that knows what actually happened, so the rule is per-field last-writer-wins ordered by last_edited_time, with our own monotonic counter breaking ties. Nothing is silently dropped: the losing value is written to an append-only conflict log and shown as a notice in the widget. Event-shaped data is the exception, where the right answer is keep-both rather than last-writer-wins.
Connections and credentials
Two ways in, because they fail in different places. The public connection (OAuth) is the default for anything a user gives to other people: it can create databases anywhere the user installs it, and it survives nothing on our side, which is the point. A pasted token (an internal connection or a personal access token) is the escape hatch for local work, air-gapped installs and self-hosting.
| Public connection (OAuth) | Pasted token (PAT / internal) | |
|---|---|---|
| Identity | Installation in a workspace, per-user grants | A bot identity inside one workspace |
| Can create at workspace level | Yes, which is what a binding needs | Yes |
| Lifetime | Until the user revokes it | Expiry chosen at creation, 7 days to 1 year |
| List all users | Yes, when granted | No |
| Used here for | The default path, and the marketplace listing | Local development, offline and enterprise setups |
A user's Notion token is never placed in a widget URL. It is validated on the way in by reading its own identity, sealed with AES-256-GCM, and stored server-side. What reaches the browser is a capability key scoped to one instance, revocable, rotatable, and useless against anything else in the workspace.
What the platform will refuse
Three limits shape the design, and each one is handled rather than retried.
- The Free-plan block cap. A multi-member workspace on Free has a lifetime block allowance, and deleting blocks does not give it back. The API says so precisely: a 403
restricted_resourcecarryingadditional_data.block_limit. Retrying will never succeed, so the engine stops and explains. Personal access tokens and any-workspace OAuth installations are exempt. - Rate limits per connection. 180 requests per minute on Free and Plus, 600 on Business and above, shared across the whole workspace, with
Retry-Afterthat can exceed a minute. Every adapter call goes through one token bucket, and only 429 and 529 are retried, because those are the only two that mean "later". - Record size. A page that gets too large is rejected outright, so a blob-mode instance keeps a size ceiling and warns well before it, instead of an unexplained 400 on the next save.
The endpoints
| Route | Method | What it does |
|---|---|---|
| /api/sync/connections | GET, POST | List connections; connect the demo workspace or store a validated token |
| /api/sync/datasources | GET | Candidate data sources to bind to, with their property names and types |
| /api/sync/instances | GET, POST | List by component; create an instance, returning the sync key exactly once |
| /api/sync/instances/{id} | GET, PATCH, DELETE | Summary, change log and events; re-bind or rename; rotate or revoke the key |
| /api/sync/instances/{id}/sync | POST, GET | The endpoint both renderers read and write through |
| /api/sync/instances/{id}/stream | GET | Server-sent revisions and events for the live renderer |
| /api/sync/instances/{id}/bundle | GET, HEAD | The offline artifact, and the same artifact as a download |
| /api/sync/instances/{id}/publish | POST | Install or refresh the HTML block in the user's workspace |
| /api/sync/notion/table | GET | The bound table read back out of Notion, for verification |
| /api/webhooks/notion | POST, GET | Signature-verified webhook fanout into the change log |
| /api/dev/notion | POST | Development-only simulator for edits made by a person in Notion |
/embed/{slug} stays what it always was, and gains the bound form: ?i=<instance>&k=<key> renders that instance live, no key renders the query-string configuration as before.
Still open, and how to close it
Four questions cannot be answered from documentation, because they depend on which Notion client is rendering the page. The probe file measures the environment it is running in, so it answers them from inside a real workspace: upload it as an HTML block, or open the URL in an embed block, and read the table.
| Question | How to answer it |
|---|---|
| Does an HTML block allow localStorage, or is the origin opaque? | The probe reports its own origin and a storage round trip |
| Does the embed frame permit popups and clipboard writes? | The probe offers both, since real widgets use them for export |
| Does PATCH on a block swap an embed's file in place? | The publish button above counts refreshes |
| Does mobile render an HTML block where it refuses embeds? | Open the same page on a phone |
The whole loop, one write end to end, is figure 03; the identity that makes the same instance render the same rows on a second device is figure 06. All six are collected at the sync in figures, each with a PNG to take away.
The deeper research, including the direct citations for every platform limit quoted above and the survey of what two-way-sync vendors do and do not do, is in docs/DATA_SYNC_RESEARCH.md. The phased build order is in docs/SYNC_PLAN.md.