Skip to content

Render A2UI in Solid

Two packages: @exponential_app/eui (the catalog, themes, reducer and host runtime) and @exponential_app/eui-solid (the renderer). It needs Solid 1.9 with vite-plugin-solid. The surface injects its own styles, so a stylesheet setup is unnecessary.

Terminal window
npm install @exponential_app/eui @exponential_app/eui-solid solid-js

The guide project’s whole dependency list and its Vite config:

guides/solid/package.json
{
"name": "exponential-ui-guide-solid",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build"
},
"dependencies": {
"@exponential_app/eui": "^0.12.0",
"@exponential_app/eui-solid": "^0.12.0",
"solid-js": "^1.9.0"
},
"devDependencies": {
"typescript": "^5.7.2",
"vite": "^7.1.0",
"vite-plugin-solid": "^2.11.0"
}
}
guides/solid/vite.config.ts
import { defineConfig } from "vite"
import solid from "vite-plugin-solid"
// Solid's JSX needs its compiler.
export default defineConfig({ plugins: [solid()] })

createSurface reduces A2UI messages into a tree and a data model (a Solid store); <ExponentialSurface> paints it in a theme and a mode. The plugin object takes icons, onAction, onInput, openUrl and extra functions.

guides/solid/src/App.tsx
import { onCleanup, onMount } from "solid-js"
import { CORE_CATALOG_ID } from "@exponential_app/eui"
import { ExponentialSurface, createSurface } from "@exponential_app/eui-solid"
import type { A2uiMessage } from "@exponential_app/eui-solid"
import { Button } from "@exponential_app/eui-solid/primitives"
import "@exponential_app/eui-solid/styles.css"
export function App() {
// The surface state: A2UI v0.9 server messages in, the tree and data out.
const surface = createSurface({ surfaceId: `main` })
// Messages from the agent, here one JSON message per line over SSE.
onMount(() => {
surface.apply({ createSurface: { surfaceId: `main`, catalogId: CORE_CATALOG_ID } })
surface.apply({
updateComponents: {
surfaceId: `main`,
components: [
{ id: `root`, component: `Column`, children: [`title`, `body`] },
{ id: `title`, component: `Text`, text: `Hello from the agent`, variant: `title` },
{ id: `body`, component: `Text`, text: { path: `/status` } },
],
},
})
surface.apply({ updateDataModel: { surfaceId: `main`, path: `/status`, value: `Waiting for the agent…` } })
const events = new EventSource(`/a2ui`)
events.onmessage = (e) => surface.apply(JSON.parse(e.data) as A2uiMessage)
onCleanup(() => events.close())
})
// Paints it; the theme is data, the mode a prop. Actions go to your agent.
// Below it, a primitive from the same set (the stylesheet above styles it).
return (
<>
<ExponentialSurface
surface={surface}
theme="orange"
mode="dark"
host={{ onAction: (event) => void fetch(`/action`, { method: `POST`, body: JSON.stringify(event) }) }}
/>
<Button variant="outline" onClick={() => surface.apply({ updateDataModel: { surfaceId: `main`, path: `/status`, value: `Asked again` } })}>
Ask again
</Button>
</>
)
}

The DOM, the compiled stylesheet and the themes are the React renderer’s, checked case by case in headless Chromium (DOM, computed styles, frames and pixels).

One ExponentialHost per app reads messages from a transport and posts actions back; <HostSurface> paints one of its surfaces by id, with a fallback until the agent has created it.

const host = new ExponentialHost({ transport: new JsonlStreamTransport({ url: "/a2ui.jsonl", postUrl: "/action" }) })
onMount(() => host.connect()); onCleanup(() => host.close())
return <HostSurface host={host} surfaceId="main" theme="orange" fallback={<p>Waiting for the agent…</p>} />

Extensions use defineSolidExtension; the hooks have Solid names (createSurface, createHostSurface, createField). More in the Solid renderer’s README.

guides/solid/src/TrendLine.tsx
// An extension = a catalog (JSON: what the model may use) + one painter per
// NATIVE component. `Metric` is a macro: it expands into Card + Text +
// TrendLine in the reducer and needs no painter on any platform.
import { defineExtension } from "@exponential_app/eui"
import type { ExtensionDef } from "@exponential_app/eui"
import { defineSolidExtension } from "@exponential_app/eui-solid"
import type { ExtensionComponentProps } from "@exponential_app/eui-solid"
import catalog from "./trendline.extension.json"
// JSON imports widen literals, hence the cast; defineExtension checks it and throws listing every problem.
export const metricsCatalog = defineExtension(catalog as unknown as ExtensionDef)
// Solid props are live getters: read them inside JSX/functions, never destructure.
function TrendLine(props: ExtensionComponentProps) {
const values = () => (Array.isArray(props.props.values) ? props.props.values.map(Number) : [])
const height = () => Number(props.props.height ?? 48)
const points = () => {
const v = values()
const min = Math.min(...v), max = Math.max(...v)
const y = (n: number) => (max === min ? 50 : 100 - ((n - min) / (max - min)) * 100)
return v.map((n, i) => `${(i / Math.max(v.length - 1, 1)) * 100},${y(n)}`).join(` `)
}
return (
<div {...props.rootProps} style={{ height: `${height()}px`, width: `100%` }}>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" width="100%" height={height()} role="img" aria-label={`${values().length} values`}>
<polyline points={points()} fill="none" stroke={props.theme.modes[props.mode].color.primary} stroke-width="2" vector-effect="non-scaling-stroke" />
</svg>
</div>
)
}
// Per surface: <ExponentialSurface … extensions={[metrics]} /> and new ExponentialHost({extensions: [metricsCatalog]})
// (or registerExtension({catalog, components}) once for every surface on the page).
export const metrics = defineSolidExtension({ catalog: metricsCatalog, components: { TrendLine } })

defineSurface from @exponential_app/eui works unchanged in a Solid project: the typed tree becomes the same messages an agent sends, and createSurface applies them. The file compiles under this project’s tsconfig and runs under Node, with nothing Solid-specific in it. The DSL is the one from Write a surface in TypeScript; the second file paints it with the Solid renderer.

guides/solid/src/defined.ts
// The typed DSL from a Solid project: the same `defineSurface` as the
// TypeScript guide, compiled by this project's tsconfig (bundler resolution,
// Solid's JSX settings) with nothing changed. Every component's props
// autocomplete from the catalog; a wrong prop is a type error, a wrong value
// a validator issue. `node src/defined.ts` prints the JSONL a host consumes.
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 },
{ 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` }) },
}),
])
export const defined = 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 (defined.issues.length) throw new Error(defined.issues.map((i) => `${i.id}: ${i.message}`).join(`\n`))
console.log(defined.jsonl)
guides/solid/src/Defined.tsx
import { ExponentialSurface, createSurface } from "@exponential_app/eui-solid"
import type { A2uiMessage } from "@exponential_app/eui-solid"
import { defined } from "./defined"
// A surface written in TypeScript, painted by the Solid renderer: the
// messages defineSurface produced are the ones an agent would send.
export function Defined() {
const surface = createSurface({ surfaceId: defined.surfaceId })
for (const message of defined.messages) surface.apply(message as A2uiMessage)
return <ExponentialSurface surface={surface} theme="orange" mode="dark" host={{ onAction: (event) => console.log(event) }} />
}

@exponential_app/eui-solid/primitives is the React primitive set on Solid, module for module, with the same DOM and classes (one set of recipes, checked against React in headless Chromium). @exponential_app/eui-solid/styles.css is the same stylesheet as React’s; globalsCss(theme) from @exponential_app/eui/web themes them outside a surface. The names differ in three ways: class for className, as for asChild, and create* for the use* hooks.

import "@exponential_app/eui-solid/styles.css"
import { builtinTheme } from "@exponential_app/eui"
import { globalsCss } from "@exponential_app/eui/web"
import { Button, Dialog, DialogContent, DialogTitle, DialogTrigger, Toaster, toast } from "@exponential_app/eui-solid/primitives"
const globals = globalsCss(builtinTheme("orange")) // :root + .dark, put it in a <style>
<Dialog>
<DialogTrigger as={Button}>Open</DialogTrigger>
<DialogContent><DialogTitle>Saved views</DialogTitle></DialogContent>
</Dialog>
<Button variant="outline" class="ml-2" onClick={() => toast.success("Saved")}>Save</Button>
<Toaster />

bun packages/exponential-ui/guides/check.ts solid builds guides/solid in an empty directory from the packed packages.