Threading the AI in the UI for a personal app

winding1 pts0 comments

Millwright: the three layers of a malleable UI — millfolio ← Blog The local AI budget post was about how to get value out of your local GPU. This one is about seeing more from your data — how millfolio renders<br>model-generated analytics without ever letting a model touch markup, styles, or<br>the DOM. The feature is called Millwright , and it’s built as three nested<br>layers, each one a plain data contract the client validates before rendering.

Layer 1: the widget — typed results, not markup

A widget’s content is the output of a small program — the same sandboxed<br>programs that answer one-off questions in Ask. A program never returns HTML or<br>markdown. It returns a result spec: a versioned JSON envelope of typed blocks —

{ "v": 1,<br>"text": "Your top 5 merchants over the last 3 months.",<br>"data": [{ "kind": "table",<br>"headers": ["Merchant", "Spent"],<br>"rows": [[{ "type": "text", "value": "WHOLE FOODS" },<br>{ "type": "money", "raw": 612.4, "text": "$612.40" }]] }] }<br>Every value is typed — money crosses the boundary as {raw, text} so the<br>chart axis uses the number and the label uses the exact formatted string; the<br>client never parses "$612.40" back out of a display string. Besides tables<br>there are KPIs, time/category series, share-of-whole pies, and an offline<br>proportional-symbol map. The renderer picks the visualization from the data’s<br>shape; the program only says what the data is.

This is the trusted-chrome invariant: programs produce data, the chrome<br>manages interactions. When a table column is tagged as a merchant<br>or tag, the chrome — not the program — turns cells into deep links into your<br>Vault records. A generated program can’t inject a link any more than it can<br>inject a script tag, because there is nowhere in the contract to put one.

Layer 2: the Board — a semantic, versioned spec

The Board itself is another plain document: an ordered list of widgets, each<br>with an id, a title, a size hint, and a pointer to the program that computes<br>it. Editing is where it gets interesting. Every change — resize a tile, edit a<br>program, remove a widget — produces a new content-addressed version of the<br>spec (a 16-hex FNV-1a of its bytes), appended to a version log. The “current”<br>board is just a pointer into that log, so undo is a pointer move, and an<br>edit that breaks something can’t destroy the earlier version of the board.

Before any candidate spec is accepted — whether it came from the inline ✎<br>editor or from the model — it passes a validator: widget ids must be<br>path-safe and unique, referenced programs must exist, remote URLs are<br>rejected outright, and structural limits are enforced. The model proposes;<br>the validator disposes.

Layer 3: pages — additive navigation

A group of widgets can be promoted into a page — it gets its own top-level<br>nav button next to Ask and Vault, and dissolving the page returns its widgets<br>to the Board. Pages are the same spec document (a pages[] section), the same<br>versioning, the same validator — with one extra rule: navigation changes are<br>additive-only . Generated edits can add a page; they can never rename or<br>remove the built-in tabs. The parts of the UI you rely on to inspect what<br>the model did are not themselves editable by the model.

Why layers instead of letting the model write UI

The obvious alternative would be to have the model emit HTML/JSX and sandbox it. The downside of that approach is that you can’t diff it, you can’t validate<br>it structurally, you can’t revert it by moving a pointer, and every render is<br>a security decision. Three data layers give the opposite trade: every model<br>contribution is a document you can inspect, version, validate, and refuse —<br>and the pixels are always drawn by code that shipped with the app.

The same layering is what makes the public demo<br>safe to expose: the demo board is the identical machinery over a synthetic<br>vault, with edits kept in your browser’s localStorage — same hashing, same<br>validator, same chrome.

Try it: demo.millfolio.app — edit a widget with<br>✎, break it, and revert.

← Back to the blog

model data program board widget spec

Related Articles