One JSON Document, Live Data: Adding Data Bindings to oak-ui and Running It as a Vibe
oak-ui lets you describe a complete application as one JSON document: routes, layout, forms, overlays, translations. A theme turns it into real components. The same document renders as a React app with shadcn/ui on the web and as an interactive terminal app with Rise.
It had one large gap. A document could only show data that was already inside it. The data block held products, posts and prices, typed in by hand. That's fine for a marketing site. It's useless for a dashboard, where the whole point is what the server knows right now.
We needed it for Oak Prospector, a lead-generation tool whose screens (review queue, lead detail, campaign progress) are views over an API. So we added data bindings in oak-ui 1.1, taught the vibe platform to serve oak-ui documents as pages, and shipped it. This is how that went.

I – What a Document Needed to Say
The design constraint was simple: the document must stay a document. No JavaScript in it, no fetch calls, no lifecycle hooks. An editor, a validator or an LLM should be able to read it and know exactly what it will request.
So 1.1 adds three declarative pieces and two actions:
{
"schema": "oak-ui@1.1",
"state": { "filter": "new" },
"sources": {
"stats": { "get": "/api/stats" },
"leads": { "get": "/api/leads", "params": { "status": "{{ state.filter }}" }, "select": "items" }
},
"commands": {
"approve": { "method": "POST", "path": "/api/leads/{{ args.id }}/approve", "invalidates": ["stats", "leads"] }
}
}
- Sources are named reads.
paramscan use expressions,selectpicks a path out of the response. - Bindings connect an element's props to a source path. Status comes with every source:
$pending,$loaded,$error,$updatedAt. - Commands are named writes, run by the new
callaction.invalidatessays which sources are stale afterwards. refreshrefetches a source, a list of them, or"*".
An element uses them like this. It's taken from the demo document we tested against:
{
"type": "Repeat",
"props": { "as": "lead" },
"bindings": { "each": "leads" },
"children": [{
"type": "Card",
"props": { "title": "{{ lead.name }}", "description": "Score {{ lead.score }}" },
"slots": { "footer": [{
"type": "Button", "props": { "label": "Approve" },
"on": { "press": { "call": {
"command": "approve",
"with": { "id": "{{ lead.id }}" },
"then": { "toast": { "title": "Approved {{ lead.name }}", "tone": "success" } }
} } }
}] }
}]
}
Expressions can also read sources directly ("{{ sources.stats.approved }} of {{ sources.stats.total }} approved"), and conditions can read status ("if": "$sources.leads.pending" on a spinner). Props written in props still apply while a source loads, so a bound heading can show a placeholder until the data arrives.
II – The Resolution Pass
The runtime's render() is synchronous. It walks the document and returns a tree of resolved nodes for the theme to draw. Fetching doesn't fit into a synchronous walk, and I didn't want to make rendering async and push that complexity into every theme.
So rendering reads from a cache and records what it needed. When the walk finishes, the runtime starts the missing requests. When a response arrives, it triggers a re-render, and that render finds the data.
The cache key is the request, not the source name:
const key = `GET ${request.path}?${new URLSearchParams(request.query)}`;
this.touched.add(key);
this.lastKey.set(name, key);
let entry = this.entries.get(key);
const freshAfter = Math.max(this.validAfterAll, this.validAfter.get(name) ?? 0);
if (!entry || (entry.seq <= freshAfter && !this.queue.has(key))) {
entry = entry ?? { key, request, select: spec.select, loaded: false, seq: 0 };
entry.seq = ++this.seq;
this.entries.set(key, entry);
this.queue.set(key, entry);
}
That gives the first rule: each distinct request is fetched once per pass, however many elements use it. Two expressions reading sources.stats produce one request. Two differently named sources that resolve to the same path and query also share one entry.
The harder question was when a pass ends. The obvious answer, "drop the cache when everything has resolved", loops forever: the render that consumes the data would find an empty cache and fetch again. A pass has to be ended by something meaningful:
- Navigation. Every route change marks everything stale.
refresh: one source, several, or all.- A command's
invalidates, after it succeeds.
Everything else, like typing in a field, toggling a switch or opening a dialog, re-renders against the same pass. If a state change alters a request's params (state.filter goes from new to qualified), the key changes and that one request is fetched. Nothing else is.
Staleness is tracked with sequence numbers rather than by deleting entries, so the old data stays on screen while the new request is in flight. Each fetch takes the next sequence number. A refresh records "stale if fetched at or before N", per source or globally. A response that arrives after a newer fetch has started is ignored.
When nothing is in flight, entries that the last render didn't touch are dropped. Without that, a search box would grow the cache by one entry per keystroke.
III – Sources That Depend on Sources
A lead detail page needs the lead first, then its evidence:
"lead": { "get": "/api/leads/{{ route.params.id }}" },
"evidence": { "get": "/api/leads/{{ sources.lead.id }}/evidence", "select": "items" }
I didn't want an explicit dependency graph in the runtime. Evaluating evidence's path reads sources.lead, and sources in scope is a Proxy whose getter resolves the named source. So the dependency is discovered by evaluating it.
While a source is resolving its own path and params, the store notes whether any source it read has not loaded yet. If one hasn't, the dependent source doesn't fetch. It reports pending and waits. The first render fetches lead, its arrival triggers a re-render, and that render can now build evidence's path. The result is dependency waves with no scheduler.
Errors propagate the same way. If lead fails, evidence doesn't sit on pending forever. It reports Depends on "lead": No route GET /api/leads/3. The validator rejects cycles up front (a → b → a), and the runtime guards against them anyway, so a broken document can't hang the page.
IV – Paths That Can't Leave /api/
A source path is a template that gets filled with state, route params and other sources' data. That data can come from anywhere, including the API itself. So I treated every interpolated value as hostile:
export const interpolateApiPath = (template: string, scope: Scope): string => {
const path = template.replace(INTERPOLATION, (_, expr: string) => {
const value = getPath(scope, expr);
return encodeURIComponent(value === undefined || value === null ? '' : String(value));
});
const [pathname] = path.split('?');
const climbs = pathname!.split('/').some((segment) => ['.', '..'].includes(safeDecode(segment)));
if (!path.startsWith('/api/') || climbs || path.includes('//') || path.includes('\\')) {
throw new Error(`Refusing API path "${path}": it must stay under /api/`);
}
return path;
};
- The validator requires the literal template to start with
/api/, so interpolation can fill in segments but never choose the origin. - Every value is URL-encoded, so a lead id of
a/../bbecomes one segment,a%2F..%2Fb, not three. - After interpolation, any segment that decodes to
.or..is refused.encodeURIComponentdoesn't encode dots, so a route param of..would otherwise pass through untouched. URL parsers also normalise%2e%2eto.., so checking the raw text isn't enough.
My first version got this wrong in the other direction. It rejected any path containing .., which broke the harmless a%2F..%2Fb in my own test. That's one segment whose text happens to contain two dots. Checking decoded segments instead of substrings fixed both cases.
Two more rules close the remaining gaps:
- Bound values are data. A binding's value goes straight into the prop and is never run through the expression evaluator. If the API returns
"{{ state.secret }}", the page shows that string. There's a test for exactly that. - Nothing structural can be bound. Props that hold elements or actions can't take a binding. The validator refuses it, so an API can't inject UI or behaviour into a page.
V – Settling, for Tests and Servers
Asynchronous rendering is awkward to test. So the runtime has a small loop that renders until nothing more is happening:
async settle(maxPasses = 20): Promise<RenderedRoute> {
for (let pass = 1; ; pass++) {
const before = this.sources.activity;
const rendered = this.render();
if (pass >= maxPasses || (!this.sources.busy && this.sources.activity === before)) return rendered;
await this.sources.idle();
}
}
activity counts fetch starts and completions. The first version only checked busy. That missed a subtle case: when a request fails synchronously (a host with no transport), nothing is in flight after the render, but the render is already out of date. Comparing the counter caught it.
With settle(), a test reads like a description of behaviour. Here's the dependency test:
runtime.render();
await Promise.resolve();
expect(server.calls.map((c) => c.path)).toEqual(['/api/leads/7']); // evidence is blocked on lead
const settled = await runtime.settle();
expect(texts(settled.body)).toEqual(['Tasca do Zé', 'no SSL']);
The same method is the hook for server rendering later: render, settle, serialise.
VI – A Document as a Vibe
Vibe, our micro-app platform, builds each app version's page from App.jsx or index.html. We added a third option: app.oak.json.
The platform turns the document into a small HTML shell. The document is embedded as JSON, and a script tag loads the engine bundle (runtime, shadcn theme, validator, styles) from /_oak/:
<link rel="stylesheet" href="/_oak/oak-engine.css?v=15b9b30">
<div id="root"></div>
<script type="application/json" id="oak-document">{…}</script>
<script type="module" src="/_oak/oak-engine.js?v=15b9b30"></script>
Embedding JSON in HTML has one classic trap: a string containing </script> ends the script tag early. The shell escapes <, >, &, U+2028 and U+2029 as \u sequences, so no content in the document can break out. The test puts </script><script>alert(1)</script> in a Text element and checks there are exactly two closing script tags in the page.
The best part was what we didn't have to write. The shell starts with <!doctype html>, so the platform's existing template treats it as a raw-HTML vibe. It injects the vibe token, the CSRF meta tag, analytics and SEO tags, just as it does for any hand-written page. The engine reads the CSRF token from that meta tag (falling back to the cookie) and sends it on every call to the vibe's own API. The same-origin model every other vibe uses works unchanged.
The asset route is deliberately narrow. It serves flat file names only, with known extensions, and caches them as immutable because the shell's URLs carry the engine version. ..%2Felysia.ts, .env, VERSION and source maps all return 404.
We tested the whole path locally in headless Chromium. The request log for one Approve click tells the story:
GET /api/stats
GET /api/leads?status=new
POST /api/leads/tasca-do-ze/approve
GET /api/stats
GET /api/leads?status=new
Initial load: one request per distinct source, although two expressions read stats. Approve: one write, then exactly one refetch of each invalidated source. The counter went from 0 to 1 of 2 approved, and the approved card left the "new" list.

VII – The Fonts the CSP Wouldn't Load
The first browser run rendered, but the console filled with errors:
Refused to load the font 'data:font/woff2;base64,d09GMgABAAAAAAfs…' because it violates
the following Content Security Policy directive: "font-src 'self' https://fonts.gstatic.com …"
The bundler had inlined every font into the stylesheet as a data: URI, and the platform's CSP only allows fonts from 'self' and a few CDNs. Loosening the CSP was the wrong fix; it protects every vibe on the platform.
Setting the bundler's loader option to emit fonts as files didn't help. The CSS pipeline inlined them regardless. So the build now post-processes its own output: every url(data:font/…;base64,…) becomes a content-hashed file next to the stylesheet, and the url() is rewritten to point at it. Eleven fonts were extracted, and the stylesheet dropped from 465 KB to 253 KB. Browsers now download only the fonts a page actually uses.
VIII – Wearing the Brand
The shadcn theme ships eight accent colours. For our own tools we wanted the oakoliver.com look: void slate #050a0f surfaces, champagne gold #d4af37, emerald for positive states, Cinzel for headings and Space Grotesk for text. So theme.accent: "oak" is now a brand, not just a colour. It sets surfaces, borders, charts and fonts, in light and dark.
The colours worked on the first try. The fonts didn't: the heading still rendered in Geist. The kits set their font variables on [data-kit="nova"], which has the same specificity as [data-accent='oak']. Since the kit rules came later in the stylesheet, they won. The fix was one selector:
/* Kits set fonts on [data-kit]; the brand wins on either kit. */
[data-kit][data-accent='oak'] {
--oak-font-sans: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif;
--oak-font-heading: "Cinzel", ui-serif, Georgia, serif;
}
The fonts are bundled from @fontsource, not fetched from Google, so they go through the same file extraction and pass the same CSP.
IX – Production
Once merged, the engine deployed about 60 seconds after the push. The production bundle is byte-identical to the one we built locally, and every traversal attempt against /_oak/ returns 404.
The first real app.oak.json in production wasn't ours. A parallel session working on the platform turned the image-generator vibe's page into an oak-ui document within the hour: a create form, mode and resolution pickers, a live queue. It was also the first to find a bug.
The platform keeps each vibe's active files in a current/ folder, and the sync that updates system vibes on deploy copied new files into it without removing old ones. image-generator's current/ held both the new app.oak.json and the previous App.jsx, and the page loader tries JSX first. Production kept serving the old page. That session fixed the sync to remove page sources the new version no longer has, while leaving runtime data such as the vibe's key-value database alone. Thirty seconds after that deploy, production served the oak shell.
In headless Chromium against production:
- The page rendered with Cinzel headings, fonts loaded under the CSP with
200, and there were no script errors. - Bindings called the vibe's own API:
GET /api/options 200, andGET /api/jobs 401for an anonymous visitor. The queue correctly showed its empty state rather than an error.

X – The Honest Accounting
| Before | After | |
|---|---|---|
| Live data in a document | none (static data block only) |
sources, bindings, commands, call, refresh |
| Requests for N elements reading one source | n/a | 1 per distinct request per pass |
| Dependent sources | n/a | resolved in waves; errors propagate; cycles rejected |
| Runtime tests | 11 | 23 (bindings, safety, commands, errors, transport) |
| Schema tests | 9 | 14 |
| Packages passing | 13 of 13 | 13 of 13 |
| Page sources vibe understands | App.jsx, index.html |
plus app.oak.json |
| Engine CSS | 465 KB, fonts inlined, blocked by CSP | 253 KB, fonts as files |
| oak-ui vibes in production | 0 | 1 (image-generator) |
What it doesn't do yet:
- The bundle is big. 1.7 MB of JavaScript before compression, because it carries both kits, every icon and the markdown renderer. One kit per document and icon tree-shaking would cut most of it.
- Rendering is client-only.
settle()makes server rendering straightforward, but it isn't wired into the platform, so the first paint waits for the bundle. - Validation runs in the browser. The server only checks that the document is JSON; a document with a schema error shows the error on the page. Publishing should validate too.
The part I like most is the shape of the result. The document still contains no code. It lists what it reads, what it writes and which writes make which reads stale. That's enough for a runtime to fetch the minimum, for a validator to reject the unsafe, and for an agent to generate a working dashboard without knowing React exists.
– Antonio