Write a surface in TypeScript
Why a DSL
Section titled “Why a DSL”A surface is a flat list of JSON components: fine for a model, tedious for a person. defineSurface takes a nested, typed tree instead. Every component is a factory on ui whose props autocomplete from the catalog (ComponentProps, generated from the same catalog the renderers read), so a misspelt prop or a wrong enum is a type error, and the result is the same flat list an agent would send. The project has one dependency:
{ "name": "exponential-ui-guide-define", "private": true, "type": "module", "scripts": { "start": "node surface.ts", "typecheck": "tsc --noEmit" }, "dependencies": { "@exponential_app/eui": "^0.12.0" }, "devDependencies": { "@types/node": "^24.0.0", "typescript": "^5.7.2" }}Write a surface
Section titled “Write a surface”ui.Stack(props, children) nests; the third argument carries what is outside the props: an explicit id, style, on, visible, slots. A List renders one template per item of a data path: { template, path, key } as its children. bind(path) reads the data model (relative inside a template), call(name, args) computes, event(name) sends an action to you and run(name, args) runs a client function.
// A surface written in TypeScript (Node 24: `node surface.ts`). Every// component's props autocomplete from the catalog; a wrong prop is a type// error, a wrong value a validator issue. The result is the same flat A2UI// an agent sends, so it renders on every platform.import { bind, call, defineSurface, event, run, ui } from "@exponential_app/eui"
const entries = ui.Stack({ gap: `md` }, [ ui.Section({ title: `Entries`, icon: `ui-clock`, count: 2 }), ui.List( { divided: true }, // One Row per item of /entries/rows; `bind("label")` reads the item. { template: ui.Row({ title: bind(`label`), subtitle: bind(`detail`), icon: `ui-clock`, chevron: `right`, pressable: true }, undefined, { on: { press: event(`openEntry`, { id: bind(`id`) }) } }), path: `/entries/rows`, key: `id` } ), ui.EmptyState({ icon: `ui-clock`, title: `No hours yet`, description: `Log the hours you worked and they appear here.`, actionLabel: `Log hours` }, undefined, { visible: call(`eq`, { a: 0, b: bind(`/entries/count`) }), on: { press: run(`app.toast`, { title: `Soon` }) }, }),])
const surface = defineSurface(entries, { surfaceId: `working-hours`, data: { "/entries": { count: 2, rows: [{ id: `e1`, label: `support, admin, orgs`, detail: `2026-10-23 · 3 h` }, { id: `e2`, label: `review, onboarding`, detail: `2026-10-22 · 4.5 h` }] } },})
if (surface.issues.length) { console.error(surface.issues.map((i) => `${i.id}: ${i.message}`).join(`\n`)) process.exit(1)}console.log(surface.jsonl) // createSurface, updateComponents, updateDataModel: feed it to any hostValidate and send
Section titled “Validate and send”defineSurface returns a bad surface’s problems instead of throwing: issues from the same reducer the renderers use (a literal-only prop given a binding, an unknown icon, a missing required prop), the flat components, the messages (createSurface, updateComponents, one updateDataModel per data path) and their jsonl. Hand the messages to any host from the render guides, or embed them in an app template. Ids are assigned top-down (root, then row-1, row-2 and so on) unless you name a node.
const surface = defineSurface(tree, { surfaceId: "working-hours", data: { "/entries": rows } })if (surface.issues.length) throw new Error(surface.issues.map((i) => i.id + ": " + i.message).join("\n"))host.receive(surface.messages) // or: transport.send(surface.jsonl)Compiled in CI
Section titled “Compiled in CI”bun packages/exponential-ui/guides/check.ts define builds guides/define in an empty directory and runs it under Node.