--- title: uicast documentation description: The expression-driven generative UI framework. pages: 27 chars: 227038 est_tokens: 56760 --- Page: https://uicast.dev/about # About uicast The expression-driven generative UI framework: open source under the MIT License, in beta. The whole documentation is also one Markdown file for LLMs: [llms-full.txt](https://uicast.dev/llms-full.txt). 1. **Define components, functions.** Your design system and your endpoints, each behind a schema: the closed set the model builds from. 2. **AI generates.** Derived values, visibility and events are short JavaScript expressions, checked before they run. 3. **Get an app, logic included.** Elements mount as they arrive. Clicks run callbacks that call your functions and write reactive state. ![A pipeline: your components and functions merge into one prompt, a model streams entries, and the entries render into a complete app built from those same components.](https://uicast.dev/uicast-hero-light.svg) ## The model as your front-end developer **uicast is a UI framework whose language is written by models, not people.** It gives a model what JSX gives a developer — components, state, events — but is built to be reliable to generate rather than easy to maintain. The model's output is data: the engine turns it into a working front-end app, with no build or deploy step per page. The demo's prompts, each with the entries the model wrote for it. The entries call these host functions: ```md # Available Functions listOrders, refundOrder, updateOrder, listProducts, placeOrder, listWarehouses, getSlots, bookSlot ## Function Details - listOrders() => { id: number /* integer */; customer: string; total: number; status: "pending" | "paid" | "shipped" | "refunded"; }[]: Every order this week, newest first. - refundOrder({ id: number /* integer */; }) => unknown: Refund one order. - updateOrder({ id: number /* integer */; status: "pending" | "paid" | "shipped" | "refunded"; }) => unknown: Change one order's status. - listProducts() => { id: number /* integer */; name: string; price: number; image: string /* format uri */; }[]: Everything the shop sells. - placeOrder({ productIds: (number /* integer */)[]; }) => { id: number /* integer */; }: Order these products, one id per item. - listWarehouses() => { city: string; lat: number; lng: number; units: number /* integer */; lowStockItems: number /* integer */; shippedToday: number /* integer */; }[]: Every warehouse, with its stock. - getSlots({ date?: string /* format date */; }) => { date: string /* format date */; label: string /* The date written out, as in Thu, Oct 2 */; slots: { time: string; free: boolean; }[]; }: Delivery slots on one day, from tomorrow on. Tomorrow's when no date is given. - bookSlot({ date: string /* format date */; time: string; }) => unknown: Book one delivery slot. ``` Prompt: Show this week's orders, with refund buttons. ```jsonl {"key":"root","component":"FlexCol","props":{"literal":{"gap":"3"}},"seed":[{"set":"scopes.root.orders","expr":"listOrders()"},{"set":"scopes.root.status","literal":"all"}],"children":["header","stats","table"]} {"key":"header","component":"FlexRow","props":{"literal":{"justify":"between","align":"center"}},"children":["heading","filter"]} {"key":"heading","component":"FlexCol","props":{"literal":{"gap":"0"}},"children":["title","subtitle"]} {"key":"title","component":"Heading","props":{"literal":{"level":"4","text":"Orders"}}} {"key":"subtitle","component":"Typography","props":{"literal":{"text":"This week","variant":"muted"}}} {"key":"filter","component":"ToggleGroup","props":{"expr":"({ value: scopes.root.status, variant: 'outline', size: 'sm', items: [{ value: 'all', label: 'All' }, { value: 'paid', label: 'Paid' }, { value: 'shipped', label: 'Shipped' }, { value: 'refunded', label: 'Refunded' }] })"},"callbacks":{"onChange":[{"set":"scopes.root.status","expr":"evt.value || currentValue"}]}} {"key":"stats","component":"Grid","props":{"literal":{"columns":"3","gap":"3"}},"children":["revenue","count","refunded"]} {"key":"revenue","component":"Stat","props":{"expr":"({ label: 'Revenue', value: '$' + scopes.root.orders.filter(o => ['paid', 'shipped'].includes(o.status)).reduce((sum, o) => sum + o.total, 0).toFixed(2) })"}} {"key":"count","component":"Stat","props":{"expr":"({ label: 'Orders', value: scopes.root.orders.length })"}} {"key":"refunded","component":"Stat","props":{"expr":"({ label: 'Refunded', value: '$' + scopes.root.orders.filter(o => o.status === 'refunded').reduce((sum, o) => sum + o.total, 0).toFixed(2) })"}} {"key":"table","component":"Table","children":["head","body"]} {"key":"head","component":"TableHeader","children":["head-row"]} {"key":"head-row","component":"TableRow","children":["h-customer","h-total","h-status","h-action"]} {"key":"h-customer","component":"TableHead","props":{"literal":{"text":"Customer"}}} {"key":"h-total","component":"TableHead","props":{"literal":{"text":"Total"}}} {"key":"h-status","component":"TableHead","props":{"literal":{"text":"Status"}}} {"key":"h-action","component":"TableHead","props":{"literal":{"text":""}}} {"key":"body","component":"TableBody","children":["row"]} {"key":"row","component":"TableRow","each":"scopes.root.orders.filter(o => scopes.root.status === 'all' || o.status === scopes.root.status)","as":"order","keyBy":"id","children":["customer","total","status","action"]} {"key":"customer","component":"TableCell","props":{"expr":"({ text: scopes.order.customer })"}} {"key":"total","component":"TableCell","props":{"expr":"({ text: '$' + scopes.order.total.toFixed(2) })"}} {"key":"status","component":"TableCell","children":["badge"]} {"key":"badge","component":"Badge","props":{"expr":"({ text: scopes.order.status, variant: { paid: 'secondary', shipped: 'default', pending: 'outline', refunded: 'destructive' }[scopes.order.status] })"}} {"key":"action","component":"TableCell","children":["refund"]} {"key":"refund","component":"Button","props":{"expr":"({ text: 'Refund', variant: 'outline', size: 'sm', disabled: !['paid', 'shipped'].includes(scopes.order.status) })"},"callbacks":{"onClick":[{"confirm":"Refund this order?","expr":"refundOrder({ id: scopes.order.id })"},{"set":"scopes.root.orders","expr":"listOrders()"}]}} ``` Prompt: Orders to ship, as a kanban I can drag. ```jsonl {"key":"root","component":"FlexCol","props":{"literal":{"gap":"3"}},"seed":[{"set":"scopes.root.orders","expr":"listOrders()"},{"set":"scopes.root.columns","expr":"['paid', 'shipped'].map(status => ({ id: status, title: { paid: 'To ship', shipped: 'Shipped' }[status], cards: scopes.root.orders.filter(o => o.status === status).map(o => ({ id: String(o.id), title: o.customer, description: '#' + o.id + ' · $' + o.total.toFixed(2) })) }))"}],"children":["header","board"]} {"key":"header","component":"FlexRow","props":{"literal":{"justify":"between","align":"center"}},"children":["heading","count"]} {"key":"heading","component":"FlexCol","props":{"literal":{"gap":"0"}},"children":["title","subtitle"]} {"key":"title","component":"Heading","props":{"literal":{"level":"4","text":"Fulfillment"}}} {"key":"subtitle","component":"Typography","props":{"literal":{"text":"Drag orders between columns","variant":"muted"}}} {"key":"count","component":"Badge","props":{"expr":"({ text: scopes.root.columns[0].cards.length + ' to ship', variant: 'secondary' })"}} {"key":"board","component":"KanbanBoard","props":{"expr":"({ columns: scopes.root.columns })"},"callbacks":{"onCardMove":[{"set":"scopes.root.columns","expr":"evt.columns"},{"expr":"updateOrder({ id: Number(evt.cardId), status: evt.toColumnId })"}]}} ``` Prompt: Map our warehouses. Click one to see its stock. ```jsonl {"key":"root","component":"FlexCol","props":{"literal":{"gap":"3"}},"seed":[{"set":"scopes.root.warehouses","expr":"listWarehouses()"},{"set":"scopes.root.selected","expr":"scopes.root.warehouses[0]"}],"children":["heading","map","details"]} {"key":"heading","component":"FlexCol","props":{"literal":{"gap":"0"}},"children":["title","subtitle"]} {"key":"title","component":"Heading","props":{"literal":{"level":"4","text":"Warehouses"}}} {"key":"subtitle","component":"Typography","props":{"expr":"({ variant: 'muted', text: scopes.root.warehouses.length + ' sites · ' + scopes.root.warehouses.reduce((sum, w) => sum + w.units, 0).toLocaleString() + ' units · click a pin' })"}} {"key":"map","component":"LocationMap","props":{"expr":"({ width: 380, height: 190, markers: scopes.root.warehouses.map(w => ({ lat: w.lat, lng: w.lng, label: w.city, active: w.city === scopes.root.selected.city })) })"},"callbacks":{"onMarkerClick":[{"set":"scopes.root.selected","expr":"scopes.root.warehouses.find(w => w.city === evt.label)"}]}} {"key":"details","component":"Card","props":{"expr":"({ title: scopes.root.selected.city })"},"children":["stock"]} {"key":"stock","component":"DescriptionList","props":{"expr":"({ columns: '3', items: [{ label: 'Units in stock', value: scopes.root.selected.units.toLocaleString() }, { label: 'Low-stock items', value: String(scopes.root.selected.lowStockItems) }, { label: 'Shipped today', value: String(scopes.root.selected.shippedToday) }] })"}} ``` Prompt: Let customers book a delivery slot. ```jsonl {"key":"root","component":"FlexCol","props":{"literal":{"gap":"3"}},"seed":[{"set":"scopes.root.day","expr":"getSlots({})"},{"set":"scopes.root.first","expr":"scopes.root.day.date"},{"set":"scopes.root.booked","literal":null}],"children":["heading","row","done"]} {"key":"heading","component":"FlexCol","props":{"literal":{"gap":"0"}},"children":["title","subtitle"]} {"key":"title","component":"Heading","props":{"literal":{"level":"4","text":"Schedule delivery"}}} {"key":"subtitle","component":"Typography","props":{"literal":{"text":"Pick a day, then a time","variant":"muted"}}} {"key":"row","component":"FlexRow","props":{"literal":{"gap":"4","wrap":true,"align":"start"}},"children":["calendar","times"]} {"key":"calendar","component":"Calendar","props":{"expr":"({ selected: scopes.root.day.date, min: scopes.root.first })"},"callbacks":{"onSelect":[{"set":"scopes.root.booked","literal":null},{"set":"scopes.root.day","expr":"getSlots({ date: evt.date })"}]}} {"key":"times","component":"Card","props":{"expr":"({ title: scopes.root.day.label, description: scopes.root.day.slots.filter(s => s.free).length + ' slots free' })"},"children":["slots"]} {"key":"slots","component":"Grid","props":{"literal":{"columns":"2","gap":"2"}},"children":["slot"]} {"key":"slot","component":"Button","each":"scopes.root.day.slots","as":"slot","keyBy":"time","props":{"expr":"({ text: scopes.slot.time, size: 'sm', variant: scopes.slot.time === scopes.root.booked ? 'default' : 'outline', disabled: !scopes.slot.free || !!scopes.root.booked })"},"callbacks":{"onClick":[{"confirm":"Book this delivery slot?","expr":"bookSlot({ date: scopes.root.day.date, time: scopes.slot.time })"},{"set":"scopes.root.booked","expr":"scopes.slot.time"},{"set":"scopes.root.day","expr":"getSlots({ date: scopes.root.day.date })"}]}} {"key":"done","component":"Alert","hidden":"!scopes.root.booked","props":{"expr":"({ status: 'success', title: 'Delivery booked', description: scopes.root.day.label + ' at ' + scopes.root.booked })"}} ``` Prompt: A shop page for our coffee, with a cart. ```jsonl {"key":"root","component":"FlexCol","props":{"literal":{"gap":"3"}},"seed":[{"set":"scopes.root.products","expr":"listProducts()"},{"set":"scopes.root.cart","literal":[]},{"set":"scopes.root.placed","literal":null}],"children":["header","done","grid"]} {"key":"header","component":"FlexRow","props":{"literal":{"justify":"between","align":"center"}},"children":["heading","checkout"]} {"key":"heading","component":"FlexCol","props":{"literal":{"gap":"0"}},"children":["title","subtitle"]} {"key":"title","component":"Heading","props":{"literal":{"level":"4","text":"Shop"}}} {"key":"subtitle","component":"Typography","props":{"expr":"({ variant: 'muted', text: scopes.root.cart.length ? scopes.root.cart.length + ' in cart' : 'Your cart is empty' })"}} {"key":"checkout","component":"Button","props":{"expr":"({ icon: 'ShoppingCart', text: 'Checkout $' + scopes.root.cart.reduce((sum, id) => sum + scopes.root.products.find(p => p.id === id).price, 0).toFixed(2), disabled: !scopes.root.cart.length })"},"callbacks":{"onClick":[{"confirm":"Place this order?","set":"scopes.root.placed","expr":"placeOrder({ productIds: scopes.root.cart })"},{"set":"scopes.root.cart","literal":[]}]}} {"key":"done","component":"Alert","hidden":"!scopes.root.placed","props":{"expr":"({ status: 'success', title: 'Order placed', description: 'Order #' + scopes.root.placed?.id + ' is confirmed.' })"}} {"key":"grid","component":"Grid","props":{"literal":{"columns":"3","gap":"4"}},"children":["product"]} {"key":"product","component":"FlexCol","each":"scopes.root.products","as":"product","keyBy":"id","props":{"literal":{"gap":"2"}},"children":["photo","text","controls"]} {"key":"photo","component":"Picture","props":{"expr":"({ src: scopes.product.image, alt: scopes.product.name, height: 'lg' })"}} {"key":"text","component":"FlexCol","props":{"literal":{"gap":"0"}},"children":["name","price"]} {"key":"name","component":"Typography","props":{"expr":"({ text: scopes.product.name, variant: 'small' })"}} {"key":"price","component":"Typography","props":{"expr":"({ text: '$' + scopes.product.price.toFixed(2), variant: 'muted' })"}} {"key":"controls","component":"FlexRow","props":{"literal":{"align":"center","gap":"3"}},"children":["remove","qty","add"]} {"key":"remove","component":"Button","hidden":"!scopes.root.cart.includes(scopes.product.id)","props":{"literal":{"icon":"Minus","size":"sm","variant":"outline","tooltip":"Remove one"}},"callbacks":{"onClick":[{"set":"scopes.root.cart","expr":"scopes.root.cart.toSpliced(scopes.root.cart.indexOf(scopes.product.id), 1)"}]}} {"key":"qty","component":"Typography","hidden":"!scopes.root.cart.includes(scopes.product.id)","props":{"expr":"({ variant: 'small', text: String(scopes.root.cart.filter(id => id === scopes.product.id).length) })"}} {"key":"add","component":"Button","props":{"expr":"scopes.root.cart.includes(scopes.product.id) ? { icon: 'Plus', size: 'sm', variant: 'outline', tooltip: 'Add one' } : { icon: 'Plus', size: 'sm', variant: 'outline', text: 'Add' }"},"callbacks":{"onClick":[{"set":"scopes.root.cart","expr":"[...scopes.root.cart, scopes.product.id]"},{"set":"scopes.root.placed","literal":null}]}} ``` Prompt: Loan calculator with a payoff chart. ```jsonl {"key":"root","component":"FlexCol","props":{"literal":{"gap":"4"}},"seed":[{"set":"scopes.root.amount","literal":300000},{"set":"scopes.root.rate","literal":5.5},{"set":"scopes.root.years","literal":25}],"children":["heading","top","chart"]} {"key":"heading","component":"FlexCol","props":{"literal":{"gap":"0"}},"children":["title","subtitle"]} {"key":"title","component":"Heading","props":{"literal":{"level":"4","text":"Mortgage calculator"}}} {"key":"subtitle","component":"Typography","props":{"literal":{"text":"Monthly payment and payoff","variant":"muted"}}} {"key":"top","component":"Grid","props":{"literal":{"columns":"2","gap":"6"}},"children":["inputs","stats"]} {"key":"inputs","component":"FlexCol","props":{"literal":{"gap":"4"}},"children":["amount","rate","years"]} {"key":"amount","component":"Field","children":["amount-label","amount-slider"]} {"key":"amount-label","component":"FieldLabel","props":{"expr":"({ text: 'Amount: $' + scopes.root.amount.toLocaleString() })"}} {"key":"amount-slider","component":"Slider","props":{"expr":"({ value: scopes.root.amount, min: 50000, max: 1000000, step: 10000 })"},"callbacks":{"onChange":[{"set":"scopes.root.amount","expr":"evt.value"}]}} {"key":"rate","component":"Field","children":["rate-label","rate-slider"]} {"key":"rate-label","component":"FieldLabel","props":{"expr":"({ text: 'Rate: ' + scopes.root.rate.toFixed(1) + '%' })"}} {"key":"rate-slider","component":"Slider","props":{"expr":"({ value: scopes.root.rate, min: 1, max: 10, step: 0.1 })"},"callbacks":{"onChange":[{"set":"scopes.root.rate","expr":"evt.value"}]}} {"key":"years","component":"Field","children":["years-label","years-slider"]} {"key":"years-label","component":"FieldLabel","props":{"expr":"({ text: 'Term: ' + scopes.root.years + ' years' })"}} {"key":"years-slider","component":"Slider","props":{"expr":"({ value: scopes.root.years, min: 5, max: 30 })"},"callbacks":{"onChange":[{"set":"scopes.root.years","expr":"evt.value"}]}} {"key":"stats","component":"FlexCol","props":{"literal":{"gap":"4"}},"children":["monthly","interest"]} {"key":"monthly","component":"Stat","props":{"expr":"({ label: 'Monthly payment', value: '$' + Math.round(scopes.root.amount * scopes.root.rate / 1200 / (1 - (1 + scopes.root.rate / 1200) ** -(scopes.root.years * 12))).toLocaleString() })"}} {"key":"interest","component":"Stat","props":{"expr":"({ label: 'Total interest', value: '$' + Math.round(scopes.root.amount * scopes.root.rate / 1200 / (1 - (1 + scopes.root.rate / 1200) ** -(scopes.root.years * 12)) * scopes.root.years * 12 - scopes.root.amount).toLocaleString() })"}} {"key":"chart","component":"LineChart","props":{"expr":"({ data: Array.from({ length: scopes.root.years + 1 }, (_, year) => ({ year, balance: Math.round(scopes.root.amount * ((1 + scopes.root.rate / 1200) ** (scopes.root.years * 12) - (1 + scopes.root.rate / 1200) ** (year * 12)) / ((1 + scopes.root.rate / 1200) ** (scopes.root.years * 12) - 1)) })), xKey: 'year', yKeys: ['balance'], height: 160, curved: true, filled: true })"}} ``` Prompt: A QR code our guests scan to join the Wi-Fi. ```jsonl {"key":"root","component":"FlexCol","props":{"literal":{"gap":"3"}},"seed":[{"set":"scopes.root.ssid","literal":"Office Guest"},{"set":"scopes.root.security","literal":"WPA"},{"set":"scopes.root.password","literal":"welcome-2026"}],"children":["heading","body"]} {"key":"heading","component":"FlexCol","props":{"literal":{"gap":"0"}},"children":["title","subtitle"]} {"key":"title","component":"Heading","props":{"literal":{"level":"4","text":"Guest Wi-Fi"}}} {"key":"subtitle","component":"Typography","props":{"literal":{"text":"A card guests scan to join","variant":"muted"}}} {"key":"body","component":"Grid","props":{"literal":{"columns":"2","gap":"6"}},"children":["form","card"]} {"key":"form","component":"FlexCol","props":{"literal":{"gap":"4"}},"children":["ssid","security","password"]} {"key":"ssid","component":"Field","children":["ssid-label","ssid-input"]} {"key":"ssid-label","component":"FieldLabel","props":{"literal":{"text":"Network name"}}} {"key":"ssid-input","component":"Input","props":{"expr":"({ value: scopes.root.ssid })"},"callbacks":{"onChange":[{"set":"scopes.root.ssid","expr":"evt.value"}]}} {"key":"security","component":"Field","children":["security-label","security-select"]} {"key":"security-label","component":"FieldLabel","props":{"literal":{"text":"Security"}}} {"key":"security-select","component":"Select","props":{"expr":"({ value: scopes.root.security, options: [{ label: 'WPA/WPA2', value: 'WPA' }, { label: 'WEP', value: 'WEP' }, { label: 'None', value: 'nopass' }] })"},"callbacks":{"onChange":[{"set":"scopes.root.security","expr":"evt.value"}]}} {"key":"password","component":"Field","hidden":"scopes.root.security === 'nopass'","children":["password-label","password-input"]} {"key":"password-label","component":"FieldLabel","props":{"literal":{"text":"Password"}}} {"key":"password-input","component":"Input","props":{"expr":"({ value: scopes.root.password })"},"callbacks":{"onChange":[{"set":"scopes.root.password","expr":"evt.value"}]}} {"key":"card","component":"Card","props":{"expr":"({ title: scopes.root.ssid, description: scopes.root.security === 'nopass' ? 'Open network' : 'Password: ' + scopes.root.password })"},"children":["qr"]} {"key":"qr","component":"QRCode","props":{"expr":"({ size: 140, value: 'WIFI:T:' + scopes.root.security + ';S:' + scopes.root.ssid + ';' + (scopes.root.security === 'nopass' ? '' : 'P:' + scopes.root.password + ';') + ';' })"}} ``` ## Expressions hold the logic What makes **uicast** different is **expressions**: computed values, visibility and event handling are JavaScript, run by a [closed-grammar interpreter](https://uicast.dev/expr). **Entries** (the LLM generates): ```jsonl {"key":"counter","component":"Counter","seed":[{"set":"scopes.root.count","literal":0}],"props":{"expr":"({ count: scopes.root.count })"},"callbacks":{"onClick":[{"set":"scopes.root.count","expr":"currentValue + 1"}]}} ``` **Definition**, `def.ts` (you provide): ```ts import z from "zod"; import { createComponentDefinition } from "@uicast/core"; export const CounterDef = createComponentDefinition({ name: "Counter", description: "A button that shows a number and increments it on each click.", props: z.object({ count: z.number().default(0).meta({ description: "The number to display" }), }), callbacks: { onClick: z.null().meta({ description: "Fires when the counter is pressed" }), }, }); ``` **Implementation**, `impl.tsx` (you provide): ```tsx import { createComponentImplementation } from "@uicast/react"; import { Button } from "@/components/ui/button"; import { CounterDef } from "./def"; export const CounterImpl = createComponentImplementation({ def: CounterDef, render: ({ count, onClick }) => ( ), }); ``` **Partial prompt** (generated from definition): ```md # Available Components Counter ## Component Details - Counter — A button that shows a number and increments it on each click. Props: - count?: number = 0 — The number to display Event handlers: - onClick() — Fires when the counter is pressed ``` **Renderer**, `renderer.tsx` (you mount it): ```tsx "use client"; import { Evaluator } from "@uicast/expr"; import { EntriesRenderer, RendererProvider } from "@uicast/react"; import { CounterImpl } from "./impl"; import counterEntries from "./entries.json"; const implementations = [CounterImpl]; const evaluator = new Evaluator(); export function Counter() { return ( ); } ``` Structured generation offers a fixed set of bindings and operators instead, so a calculation outside that set needs a code change. | Approach | Predictability | Expressiveness | Model code | | --- | --- | --- | --- | | Raw code generation | low | unlimited | any | | **uicast** | high | high | expressions | | Structured generation ([json-render](https://github.com/vercel-labs/json-render), [A2UI](https://a2ui.org)) | deterministic | limited | none | ## The expression evaluator Expressions run on an [interpreter](https://uicast.dev/expr) for a **fraction of JavaScript**: a closed grammar, an allow-list of methods and globals, a check on every property read, and a CPU and allocation budget. By default nothing reaches `eval` or `new Function`, so a **uicast** app runs under a strict Content-Security-Policy, without `unsafe-eval`. The interpreter secures the language, not what you plug into it: host functions are capabilities you grant, and scope data is only as safe as what you put there. ```js // scopes.root.orders === [{ total: 2 }, { total: 3 }] // ✅ → 5 scopes.root.orders.reduce((a, o) => a + o.total, 0) // ✅ a host function you registered, callable in seed and callbacks listOrders() // ❌ throws — "fetch" is not available in expressions fetch("/api/orders") // ❌ throws — so do "window", "localStorage", "eval", … document.cookie ``` ## The prompt is generated from your code **uicast** prints the model's list of components and functions from your component definitions and function schemas. Change a prop or a function, and the prompt changes with it. ```md # Available Components Alert, Badge, BarChart, Button, Calendar, Card, Checkbox, DescriptionList, EmptyState, Field, FieldLabel, FlexCol, FlexRow, Form, Grid, Heading, Input, KanbanBoard, LineChart, LocationMap, Modal, NumberInput, Pagination, Picture, QRCode, Select, Slider, Stat, Switch, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, ToggleGroup, Typography ## Component Details - Button — A clickable button. Props: - text?: string — The button label - variant?: "default" | "destructive" | "outline" | … Event handlers: - onClick(evt: MouseEvent) … one entry per component … # Available Functions listOrders, refundOrder, updateOrder, listProducts, placeOrder, listWarehouses, getSlots, bookSlot ## Function Details - listOrders() => { id: number /* integer */; customer: string; total: number; status: "pending" | "paid" | "shipped" | "refunded"; }[]: Every order this week, newest first. - refundOrder({ id: number /* integer */; }) => unknown: Refund one order. … one entry per function … ``` > Show this week's orders, with refund buttons. It is the first prompt in the demo at the top. Both lists are closed: the model picks components from your **catalog** and calls only your **functions**, each a [`standard-tool`](https://standard-tool.js.org/). The descriptions are the ones you wrote on props and schema fields. See [Assembling the prompt](https://uicast.dev/prompt). ## Events carry declared payloads An event can carry data. The square below sends the pointer position with `onMove`; the entry writes it to state, and the props read it back. Inside a callback, **`evt` is the payload**, shaped by the [definition](https://uicast.dev/def): here `evt.x` and `evt.y` are numbers. It is not React's `SyntheticEvent`. **Entries** (the LLM generates): ```jsonl {"key":"pad","component":"TrackPad","seed":[{"set":"scopes.root.pointer","literal":{}}],"props":{"expr":"({ x: scopes.root.pointer.x, y: scopes.root.pointer.y })"},"callbacks":{"onMove":[{"set":"scopes.root.pointer","expr":"({ x: evt.x, y: evt.y })"}],"onLeave":[{"set":"scopes.root.pointer","literal":{}}]}} ``` **Definition**, `def.ts` (you provide): ```ts import z from "zod"; import { createComponentDefinition } from "@uicast/core"; export const TrackPadDef = createComponentDefinition({ name: "TrackPad", description: "A square surface that reports the pointer position while hovered or pressed.", props: z.object({ x: z.number().optional().meta({ description: "Pointer x, in px from the left edge" }), y: z.number().optional().meta({ description: "Pointer y, in px from the top edge" }), }), callbacks: { onMove: z .object({ x: z.number().meta({ description: "Pointer x inside the square" }), y: z.number().meta({ description: "Pointer y inside the square" }), }) .meta({ description: "Fires on every pointer move over the square" }), onLeave: z.null().meta({ description: "Fires when the pointer leaves" }), }, }); ``` **Implementation**, `impl.tsx` (you provide): ```tsx import { createComponentImplementation } from "@uicast/react"; import type { PointerEvent } from "react"; import { Card, CardContent } from "@/components/ui/card"; import { TrackPadDef } from "./def"; export const TrackPadImpl = createComponentImplementation({ def: TrackPadDef, render: ({ x, y, onMove, onLeave }) => { const move = (e: PointerEvent) => { const rect = e.currentTarget.getBoundingClientRect(); onMove({ x: Math.round(e.clientX - rect.left), y: Math.round(e.clientY - rect.top), }); }; return ( onLeave()} > {x === undefined || y === undefined ? ( Hover or tap here ) : ( <> x: {x} · y: {y} )} ); }, }); ``` **Partial prompt** (generated from definition): ```md # Available Components TrackPad ## Component Details - TrackPad — A square surface that reports the pointer position while hovered or pressed. Props: - x?: number — Pointer x, in px from the left edge - y?: number — Pointer y, in px from the top edge Event handlers: - onMove(evt) — Fires on every pointer move over the square - x: number — Pointer x inside the square - y: number — Pointer y inside the square - onLeave() — Fires when the pointer leaves ``` **Renderer**, `renderer.tsx` (you mount it): ```tsx "use client"; import { Evaluator } from "@uicast/expr"; import { EntriesRenderer, RendererProvider } from "@uicast/react"; import { TrackPadImpl } from "./impl"; import trackerEntries from "./entries.json"; const implementations = [TrackPadImpl]; const evaluator = new Evaluator(); export function Tracker() { return ( ); } ``` ## Expressions call host functions Expressions can call **host functions**: the functions you register, each a [`standard-tool`](https://standard-tool.js.org/) with a name, a description, schemas and `execute`. They are how a document reaches your backend. `getWeather` below is async (about 500 ms). Picking a city runs two steps: the first stores the city, the second calls `getWeather({ city: scopes.root.city })`. The second reads what the first wrote, so they run in order, and the result is written to state when it arrives. **Entries** (the LLM generates): ```jsonl {"key":"weather","component":"WeatherCard","seed":[{"set":"scopes.root.city","literal":"Amsterdam"},{"set":"scopes.root.weather","expr":"getWeather({ city: scopes.root.city })"}],"props":{"expr":"({ city: scopes.root.city, tempC: scopes.root.weather?.tempC, condition: scopes.root.weather?.condition })"},"callbacks":{"onCity":[{"set":"scopes.root.city","expr":"evt.city"},{"set":"scopes.root.weather","expr":"getWeather({ city: scopes.root.city })"}]}} ``` **Definition**, `def.ts` (you provide): ```ts import z from "zod"; import { createComponentDefinition } from "@uicast/core"; export const WeatherCardDef = createComponentDefinition({ name: "WeatherCard", description: "Shows the weather for a selectable city.", props: z.object({ city: z.string().meta({ description: "The selected city" }), tempC: z.number().optional().meta({ description: "Temperature, °C" }), condition: z.string().optional().meta({ description: "Sky condition" }), }), callbacks: { onCity: z .object({ city: z.string().meta({ description: "The newly picked city" }) }) .meta({ description: "Fires when the user picks a city" }), }, }); ``` **Implementation**, `impl.tsx` (you provide): ```tsx import { createComponentImplementation } from "@uicast/react"; import { Card, CardContent } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { WeatherCardDef } from "./def"; const CITIES = ["Amsterdam", "Tokyo", "Oslo"]; export const WeatherCardImpl = createComponentImplementation({ def: WeatherCardDef, render: ({ city, tempC, condition, onCity }) => ( {tempC === undefined ? (
Loading…
) : ( <>
{tempC}°C
{condition}
)}
), }); ``` **Host function**, `tools.ts` (you provide): ```ts import z from "zod"; import { standardTool } from "standard-tool"; const LATENCY_MS = 500; const TABLE: Record = { Amsterdam: { tempC: 14, condition: "Drizzle" }, Tokyo: { tempC: 24, condition: "Clear" }, Oslo: { tempC: 8, condition: "Cloudy" }, }; export const getWeather = standardTool({ name: "getWeather", description: "Current weather for a city.", inputSchema: z.object({ city: z.string().meta({ description: "City name" }), }), outputSchema: z.object({ tempC: z.number().meta({ description: "Temperature, °C" }), condition: z.string().meta({ description: "One-word sky condition" }), }), async execute({ city }) { await new Promise((resolve) => setTimeout(resolve, LATENCY_MS)); return TABLE[city] ?? { tempC: 20, condition: "Unknown" }; }, }); ``` **Partial prompt** (generated from definition): ```md # Available Components WeatherCard ## Component Details - WeatherCard — Shows the weather for a selectable city. Props: - city: string — The selected city - tempC?: number — Temperature, °C - condition?: string — Sky condition Event handlers: - onCity(evt) — Fires when the user picks a city - city: string — The newly picked city # Available Functions getWeather ## Function Details - getWeather({ city: string /* City name */; }) => { tempC: number /* Temperature, °C */; condition: string /* One-word sky condition */; }: Current weather for a city. ``` **Renderer**, `renderer.tsx` (you mount it): ```tsx "use client"; import { Evaluator } from "@uicast/expr"; import { EntriesRenderer, RendererProvider } from "@uicast/react"; import { WeatherCardImpl } from "./impl"; import { getWeather } from "./tools"; import weatherEntries from "./entries.json"; const implementations = [WeatherCardImpl]; const evaluator = new Evaluator({ functions: [getWeather] }); export function Weather() { return ( ); } ``` ## Chat replies carry live UI The [Streamdown](https://uicast.dev/streamdown) plugin renders each `uicast` fenced block in a Markdown reply as a live [``](https://uicast.dev/react/renderer). Other fences stay highlighted code: ````md Here are this week's orders: ```uicast {"key":"orders","component":"Table", … } ``` Want me to refund any of them? ```` The chat runs under one [``](https://uicast.dev/react/renderer), so all blocks share the `root` scope. Each block is still a complete document: lift one out and it renders on its own, for example as a saved page. ## Next steps - [Getting started](https://uicast.dev/getting-started) — install and render your first document. - [Concepts](https://uicast.dev/concepts) — the model, expressions and scopes. - [Component definition](https://uicast.dev/def) and [Component implementation](https://uicast.dev/react) — build your own components. - [Streaming](https://uicast.dev/streaming) — stream entries from a route and render them as they arrive. --- Page: https://uicast.dev/getting-started # Getting started Type a prompt, get a working UI built from components you register and data you expose. This guide uses the shadcn reference catalog, so there are no components to write first. The examples use Next.js; any React app with a server route works. ## With an agent **uicast** ships an [Agent Skill](https://agentskills.io): one [`SKILL.md`](https://github.com/finom/uicast/tree/main/skills/uicast) for the developer side — definitions and implementations, host functions, prompt assembly, mounting, page and chat streaming, server rendering, errors and security. The entry format the model emits is not in it; that comes from the [assembled prompt](https://uicast.dev/prompt) at run time. The [skills CLI](https://skills.sh) detects the agents you use and installs the skill for each: ```sh npx skills add finom/uicast ``` It supports Claude Code, Cursor, Codex, Gemini CLI, OpenCode, Copilot and most other clients. To install by hand, copy the [`skills/uicast`](https://github.com/finom/uicast/tree/main/skills/uicast) folder to where your client looks for skills; the [client showcase](https://agentskills.io/clients) links each client's setup instructions. Then ask your agent: ```md Add uicast to this app. Wrap our existing API endpoints as host functions, register the shadcn reference catalog, and add a page where a prompt generates a UI over that data. ``` For a chat that answers with generated UIs: ```md Add a chat to this app that answers with uicast UIs, built from the shadcn reference catalog and our existing API endpoints. ``` ## From the starter app The app this guide builds, ready to run: ```sh npx create-next-app@latest my-app --example https://github.com/finom/uicast/tree/main/examples/starter cd my-app echo "AI_GATEWAY_API_KEY=your-key" > .env.local npm run dev ``` ## 1. Install ```sh npm install @uicast/expr @uicast/core @uicast/react @uicast/shadcn-catalog standard-tool ai @tanstack/react-query zod ``` - **`@uicast/shadcn-catalog`** — a reference catalog of 106 shadcn/ui components, each with the definition the model reads. - **`@uicast/core`** — the engine and the [prompt builders](https://uicast.dev/prompt). - **`@uicast/react`** — the renderer. - **`@uicast/expr`** — the [expression evaluator](https://uicast.dev/expr). - **`standard-tool`**, **`ai`**, **`@tanstack/react-query`** — host functions, the model call, the streaming query. The catalog ships its own stylesheet, so there is no Tailwind config to write: ```css filename="src/app/globals.css" /* Optional — preflight and the shadcn palette, if you never ran `shadcn init`. */ @import "@uicast/shadcn-catalog/theme.css"; @import "@uicast/shadcn-catalog/catalog.css"; ``` The catalog reads your theme's CSS variables (`background-color: var(--card)`), so your theme restyles it too, status colors included: `--success`, `--warning`, `--info` and `--destructive`. ## 2. Expose your data **Host functions** give the model your backend: one [`standard-tool`](https://standard-tool.js.org/) per function it may call. The route reads their schemas, and the page runs them. `execute` runs in the **browser**, so fetch over HTTP: a database client here would ship your credentials to the client bundle. ```ts filename="src/tools/list-orders.ts" import z from "zod"; import { standardTool } from "standard-tool"; export const listOrders = standardTool({ name: "listOrders", description: "Orders, newest first.", inputSchema: z.object({ page: z.number().int().min(1).default(1).meta({ description: "1-based page number" }), status: z .enum(["all", "open", "refunded"]) .default("all") .meta({ description: "Filter by order status" }), }), outputSchema: z.object({ orders: z.array( z.object({ id: z.number().int().meta({ description: "Order id" }), customer: z.string().meta({ description: "Who placed it" }), total: z.number().meta({ description: "Order total, in dollars" }), }), ), pageCount: z.number().int().meta({ description: "Total number of pages" }), }), execute: async ({ page, status }) => (await fetch(`/api/orders?page=${page}&status=${status}`)).json(), }); ``` ```ts filename="src/tools/index.ts" import { listOrders } from "./list-orders"; export const tools = [listOrders]; ``` `inputSchema` lets the model paginate and filter, and is checked before `execute` runs. `outputSchema` tells the model the shape of the result. Every `description` reaches the model word for word. See [Host functions](https://uicast.dev/expr/functions). `/api/orders` stands for your own API. The [starter app](https://uicast.dev/getting-started#from-the-starter-app) ships a stand-in route with 23 orders in memory, `src/app/api/orders/route.ts`. ## 3. Generate on the server The prompt is built from the same catalog and functions the page uses. The route passes the model's text through as it arrives, and the page reads each line as soon as it is complete: ```ts filename="src/app/api/generate/route.ts" import { createTextStreamResponse, streamText } from "ai"; import { getCommonInstructionsPartialPrompt, getComponentsPartialPrompt, getExpressionsPartialPrompt, getFunctionsPartialPrompt, getScopePartialPrompt, } from "@uicast/core/prompt"; import { defs } from "@uicast/shadcn-catalog/all/defs"; import { tools } from "@/tools"; const system = [ getCommonInstructionsPartialPrompt(), getScopePartialPrompt({ kind: "page" }), getComponentsPartialPrompt({ definitions: defs }), getFunctionsPartialPrompt({ functions: tools }), getExpressionsPartialPrompt(), ].join("\n\n"); export async function POST(req: Request) { const { prompt } = await req.json(); // A bare model id resolves through the Vercel AI Gateway — set AI_GATEWAY_API_KEY. const result = streamText({ model: "anthropic/claude-opus-5.5", instructions: system, prompt }); return createTextStreamResponse({ stream: result.textStream, headers: { "content-type": "application/jsonl; charset=utf-8" }, }); } ``` Keep this order; [Assembling the prompt](https://uicast.dev/prompt#order) explains it. ## 4. Render on the page React Query's `streamedQuery` turns an `AsyncIterable` into an array that grows as values arrive. `streamJsonLines` returns one; the renderer takes the array: ```tsx filename="src/app/page.tsx" "use client"; import { QueryClient, QueryClientProvider, experimental_streamedQuery as streamedQuery, useQuery, } from "@tanstack/react-query"; import { type ComponentEntry, streamJsonLines } from "@uicast/core"; import { Evaluator } from "@uicast/expr"; import { EntriesRenderer, RendererProvider } from "@uicast/react"; import { ConfirmModal } from "@uicast/shadcn-catalog"; import { impls } from "@uicast/shadcn-catalog/all/impls"; import { Button } from "@uicast/shadcn-catalog/ui/button"; import { Input } from "@uicast/shadcn-catalog/ui/input"; import { Skeleton } from "@uicast/shadcn-catalog/ui/skeleton"; import { useState } from "react"; import { tools } from "@/tools"; // Once, at module scope: the host functions bind on it, and it holds the parse cache. const evaluator = new Evaluator({ functions: tools }); const fallbackComponents = { confirm: ConfirmModal, defaultSkeleton: () => , }; function Generated({ prompt }: { prompt: string }) { const { data: entries = [] } = useQuery({ queryKey: ["generate", prompt], enabled: prompt !== "", // One model call per prompt: no refetch on window focus, no retry. staleTime: Infinity, retry: false, queryFn: streamedQuery({ streamFn: async () => { const res = await fetch("/api/generate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt }), }); if (!res.ok || !res.body) throw new Error(`Generation failed (${res.status})`); return streamJsonLines(res.body); }, }), }); return ( ); } export default function Home() { const [client] = useState(() => new QueryClient()); const [draft, setDraft] = useState(""); const [prompt, setPrompt] = useState(""); return (
{ e.preventDefault(); setPrompt(draft); }}> setDraft(e.target.value)} />
); } ``` > Show the latest orders, with a status filter. `key={prompt}` gives each prompt a new `RendererProvider`. A provider kept across prompts keeps the first page's state, and a seed never overwrites state, so the next page would show the old data. The page fills in from the top, parents before children. A child that has not arrived shows its parent's [skeleton](https://uicast.dev/react#skeleton), or `defaultSkeleton`. Finished parts are never rebuilt, and a [`seed`](https://uicast.dev/entry#seed) that already fetched does not fetch again. ## Where to go next - [Concepts](https://uicast.dev/concepts) — the element model, expressions and scopes. - [Component definition](https://uicast.dev/def) and [Component implementation](https://uicast.dev/react) — your own components instead of the catalog. - [Host functions](https://uicast.dev/expr/functions) — changing data, confirmation, refetching. - [Streaming](https://uicast.dev/streaming) — saving entries as they stream. --- Page: https://uicast.dev/concepts # Concepts A complete **uicast** document with six entries. It loads products, lists them with a quantity input, edits one in a dialog, and shows a total that stays up to date. Every concept on this page is in it. **Entries** (the LLM generates): ```jsonl {"key":"card","component":"Card","busy":"scopes.root.busy","seed":[{"set":"scopes.root.busy","literal":false},{"set":"scopes.root.products","expr":"listProducts()"}],"children":["title","refresh","rows","total","editor"]} {"key":"title","component":"Heading","props":{"expr":"({ text: scopes.root.products.length + ' products' })"}} {"key":"refresh","component":"Button","props":{"literal":{"label":"Refresh"}},"callbacks":{"onClick":[{"set":"scopes.root.busy","literal":true},{"set":"scopes.root.products","expr":"listProducts()"},{"set":"scopes.root.busy","literal":false}]}} {"key":"rows","component":"ProductRow","each":"scopes.root.products","as":"product","keyBy":"id","props":{"expr":"({ name: scopes.product.name, price: scopes.product.price, qty: scopes.product.qty })"},"callbacks":{"onQtyChange":[{"set":"scopes.product.qty","expr":"evt.value"},{"expr":"updateProduct({ id: scopes.product.id, qty: evt.value })"}],"onEdit":[{"set":"scopes.root.draftName","expr":"scopes.product.name"},{"set":"scopes.root.draftPrice","expr":"scopes.product.price"},{"set":"scopes.root.editingId","expr":"scopes.product.id"}]}} {"key":"total","component":"Heading","props":{"expr":"({ text: '$' + scopes.root.products.reduce((sum, p) => sum + p.price * p.qty, 0) + ' total' })"}} {"key":"editor","component":"EditDialog","hidden":"!scopes.root.editingId","seed":[{"set":"scopes.root.draftName","literal":""},{"set":"scopes.root.draftPrice","literal":0}],"props":{"expr":"({ name: scopes.root.draftName, price: scopes.root.draftPrice })"},"callbacks":{"onNameChange":[{"set":"scopes.root.draftName","expr":"evt.value"}],"onPriceChange":[{"set":"scopes.root.draftPrice","expr":"evt.value"}],"onSave":[{"confirm":"Apply these changes to the product?","set":"scopes.root.products","expr":"scopes.root.products.map(p => p.id === scopes.root.editingId ? { ...p, name: scopes.root.draftName, price: scopes.root.draftPrice } : p)"},{"expr":"updateProduct({ id: scopes.root.editingId, name: scopes.root.draftName, price: scopes.root.draftPrice })"},{"set":"scopes.root.editingId","literal":null}],"onCancel":[{"set":"scopes.root.editingId","literal":null}]}} ``` **Definitions**, `def.ts` (you provide): ```ts import z from "zod"; import { createComponentDefinition } from "@uicast/core"; // card/def.ts export const CardDef = createComponentDefinition({ name: "Card", description: "A bordered container.", }); // heading/def.ts export const HeadingDef = createComponentDefinition({ name: "Heading", description: "A section heading.", props: z.object({ text: z.string().meta({ description: "The heading text" }), }), }); // button/def.ts export const ButtonDef = createComponentDefinition({ name: "Button", description: "A click target.", props: z.object({ label: z.string().meta({ description: "Button text" }), }), callbacks: { onClick: z.null().meta({ description: "Fires when pressed" }), }, }); // product-row/def.ts export const ProductRowDef = createComponentDefinition({ name: "ProductRow", description: "One product: its name, unit price, and an editable quantity.", props: z.object({ name: z.string().meta({ description: "Product name" }), price: z.number().meta({ description: "Unit price, in dollars" }), qty: z.number().meta({ description: "Quantity in the cart" }), }), callbacks: { onQtyChange: z.object({ value: z.number().meta({ description: "The new quantity" }), }), onEdit: z.null().meta({ description: "Fires when the edit button is pressed" }), }, }); // edit-dialog/def.ts export const EditDialogDef = createComponentDefinition({ name: "EditDialog", description: "A modal editing a product's name and unit price.", props: z.object({ name: z.string().meta({ description: "The product name being edited" }), price: z.number().meta({ description: "The unit price being edited" }), }), callbacks: { onNameChange: z.object({ value: z.string().meta({ description: "The new name" }), }), onPriceChange: z.object({ value: z.number().meta({ description: "The new price" }), }), onSave: z.null().meta({ description: "Fires on Save" }), onCancel: z.null().meta({ description: "Fires on Cancel" }), }, }); ``` **Implementations**, `impl.tsx` (you provide): ```tsx import { createComponentImplementation } from "@uicast/react"; import { Button as UIButton } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { ButtonDef, CardDef, EditDialogDef, HeadingDef, ProductRowDef } from "./def"; // card/impl.tsx export const CardImpl = createComponentImplementation({ def: CardDef, render: ({ children }, { busy }) => ( {children} ), }); // heading/impl.tsx export const HeadingImpl = createComponentImplementation({ def: HeadingDef, render: ({ text }) =>
{text}
, }); // button/impl.tsx export const ButtonImpl = createComponentImplementation({ def: ButtonDef, render: ({ label, onClick }) => ( onClick()}> {label} ), }); // product-row/impl.tsx export const ProductRowImpl = createComponentImplementation({ def: ProductRowDef, render: ({ name, price, qty, onQtyChange, onEdit }) => (
{name} onEdit()}> ✎ ${price} × onQtyChange({ value: e.target.valueAsNumber || 0 })} className="w-14 rounded border bg-transparent px-1 py-0.5 text-right" />
), }); // edit-dialog/impl.tsx export const EditDialogImpl = createComponentImplementation({ def: EditDialogDef, render: ({ name, price, onNameChange, onPriceChange, onSave, onCancel }) => (
Edit product
onNameChange({ value: e.target.value })} className="rounded border bg-transparent px-2 py-1 text-sm" />
$ onPriceChange({ value: e.target.valueAsNumber || 0 })} className="flex-1 rounded border bg-transparent px-2 py-1" />
onCancel()}> Cancel onSave()}> Save
), }); ``` **Host function**, `tools.ts` (you provide): ```ts import z from "zod"; import { standardTool } from "standard-tool"; const LATENCY_MS = 350; const PRODUCTS = [ { id: 7, name: "Filter Papers", price: 8, qty: 5 }, { id: 8, name: "Espresso Beans", price: 15, qty: 1 }, { id: 9, name: "Pour-Over Kettle", price: 23, qty: 4 }, { id: 10, name: "Ceramic Mug", price: 14, qty: 2 }, ]; // Edits are per-id overrides in localStorage, so they survive a reload. const KEY = "uicast-docs-products"; type Override = { name?: string; price?: number; qty?: number }; function readOverrides(): Record { try { return JSON.parse(localStorage.getItem(KEY) ?? "{}"); } catch { return {}; } } export const listProducts = standardTool({ name: "listProducts", description: "The three most recently stocked products.", outputSchema: z.array( z.object({ id: z.number().int().meta({ description: "Product id" }), name: z.string().meta({ description: "Product name" }), price: z.number().meta({ description: "Unit price, in dollars" }), qty: z.number().meta({ description: "Quantity in the cart" }), }), ), async execute() { await new Promise((resolve) => setTimeout(resolve, LATENCY_MS)); // The list rotates on every call, so a refetch shows it change. PRODUCTS.push(...PRODUCTS.splice(0, 1)); const overrides = readOverrides(); return PRODUCTS.slice(0, 3).map((product) => ({ ...product, ...overrides[product.id] })); }, }); export const updateProduct = standardTool({ name: "updateProduct", description: "Update one product's name, unit price, or quantity.", inputSchema: z.object({ id: z.number().int().meta({ description: "Product id" }), name: z.string().optional().meta({ description: "New product name" }), price: z.number().optional().meta({ description: "New unit price" }), qty: z.number().optional().meta({ description: "New quantity" }), }), async execute({ id, name, price, qty }) { const overrides = readOverrides(); overrides[id] = { ...overrides[id], ...(name !== undefined && { name }), ...(price !== undefined && { price }), ...(qty !== undefined && { qty }), }; try { localStorage.setItem(KEY, JSON.stringify(overrides)); } catch { // Storage unavailable (private mode) — the edit still lives in state. } }, }); ``` **Partial prompt** (generated from definition): ```md # Available Components Card, Heading, Button, ProductRow, EditDialog ## Component Details - Card — A bordered container. - Heading — A section heading. Props: - text: string — The heading text - Button — A click target. Props: - label: string — Button text Event handlers: - onClick() — Fires when pressed - ProductRow — One product: its name, unit price, and an editable quantity. Props: - name: string — Product name - price: number — Unit price, in dollars - qty: number — Quantity in the cart Event handlers: - onQtyChange(evt) - value: number — The new quantity - onEdit() — Fires when the edit button is pressed - EditDialog — A modal editing a product's name and unit price. Props: - name: string — The product name being edited - price: number — The unit price being edited Event handlers: - onNameChange(evt) - value: string — The new name - onPriceChange(evt) - value: number — The new price - onSave() — Fires on Save - onCancel() — Fires on Cancel # Available Functions listProducts, updateProduct ## Function Details - listProducts() => { id: number /* Product id; integer */; name: string /* Product name */; price: number /* Unit price, in dollars */; qty: number /* Quantity in the cart */; }[]: The three most recently stocked products. - updateProduct({ id: number /* Product id; integer */; name?: string /* New product name */; price?: number /* New unit price */; qty?: number /* New quantity */; }) => unknown: Update one product's name, unit price, or quantity. ``` **Renderer**, `renderer.tsx` (you mount it): ```tsx "use client"; import type { ComponentEntry } from "@uicast/core"; import { Evaluator } from "@uicast/expr"; import { type ConfirmComponentProps, EntriesRenderer, RendererProvider } from "@uicast/react"; import { Button } from "@/components/ui/button"; import { ButtonImpl, CardImpl, EditDialogImpl, HeadingImpl, ProductRowImpl } from "./impl"; import { listProducts, updateProduct } from "./tools"; const implementations = [CardImpl, HeadingImpl, ButtonImpl, ProductRowImpl, EditDialogImpl]; const evaluator = new Evaluator({ functions: [listProducts, updateProduct] }); // Without it the engine falls back to window.confirm. const fallbackComponents = { confirm: ({ open, message, onConfirm, onCancel }: ConfirmComponentProps) => open ? (
{message}
) : null, }; export function Products({ entries }: { entries: ComponentEntry[] }) { return ( ); } ``` ## The tree is built from keys Nothing is nested in the file. `card` lists `["title","refresh","rows","total","editor"]`, so those five are its children: ``` card ─┬─ title ├─ refresh ├─ rows (repeats: one element per product) ├─ total └─ editor (hidden until a row's ✎ is pressed) ``` `card` is the **root**: no line lists it as a child. A parent must arrive before its children. Until a child's line arrives, the parent's [skeleton](https://uicast.dev/react#skeleton) fills its place. ## One line, many elements: lists The `rows` line has `each`, which makes it a **list**: it repeats once per item in the array. ```jsonc { "each": "scopes.root.products", "as": "product", "keyBy": "id" } ``` - **`each`** — the array. It runs in the *parent's* scopes, so it cannot read the item. - **`as`** — names the item's scopes. `scopes.product` is the product itself; `scopes.$product` is its row, with `index` (0, 1, 2 …) and `id`. - **`keyBy`** — the field the row's `id` comes from. Three products in state, three elements from one entry: | `scopes.product` | `scopes.$product.index` | rendered | |---|---|---| | `{ id: 8, name: "Espresso Beans", price: 15, qty: 1 }` | `0` | Espresso Beans · $15 × 1 | | `{ id: 9, name: "Pour-Over Kettle", price: 23, qty: 4 }` | `1` | Pour-Over Kettle · $23 × 4 | | `{ id: 10, name: "Ceramic Mug", price: 14, qty: 2 }` | `2` | Ceramic Mug · $14 × 2 | ## State lives in scopes State is kept in named scopes. This document has three: - **`scopes.root`** — one per app, shared by every element. It holds `products`, set by the [`seed`](https://uicast.dev/entry#seed) on line 1. - **`scopes.product`** — one per row, named by the list's `as`: the product itself. - **`scopes.$product`** — one per row too: its `index`, `id` and own state. Scopes do not nest: `scopes.product` is not inside `scopes.root`. But an element can read every scope around it. In a list inside a list, it reads all of them: ```jsonc { "key": "orders", "component": "Card", "each": "scopes.root.orders", "as": "order", "children": ["lines"] } { "key": "lines", "component": "Typography", "each": "scopes.order.lines", "as": "line", "props": { "expr": "({ text: scopes.order.id + ' / ' + scopes.line.sku })" } } ``` So every `as` must be a new name: a list that reuses the name of a scope around it fails. See [State & Scopes](https://uicast.dev/entry/state). ### Editing a row updates the total The `$135 total` sits outside the list and reads the array: ```jsonc { "key": "total", "component": "Heading", "props": { "expr": "({ text: '$' + scopes.root.products.reduce((sum, p) => sum + p.price * p.qty, 0) + ' total' })" } } ``` Each row's quantity input writes to the product: ```jsonc { "onQtyChange": [{ "set": "scopes.product.qty", "expr": "evt.value" }] } ``` The write puts an edited copy of the product **into `scopes.root.products`**, so the total updates. The dialog sits outside the list, so its Save button has no `scopes.product` to write. It writes `scopes.root.products.map(...)` itself, which is what the row's write does behind the scenes. Both edits then call `updateProduct(...)`, a [host function](https://uicast.dev/expr/functions) that saves the change. Save's first step has a [`confirm`](https://uicast.dev/entry/value-sources#callbackvaluesourceassignment): if the user declines, that step and every step after it are skipped. The host's [`fallbackComponents.confirm`](https://uicast.dev/react/renderer#fallbackcomponents--the-engines-fallback-ui) draws that dialog for every document. ## Reading state subscribes to it The `rows` list reads `scopes.root.products` in its `each`. Nothing declares this. The engine finds the read in the expression and subscribes the element to that field. ```jsonc { "key": "rows", "each": "scopes.root.products", "as": "product", "keyBy": "id" } // └── auto-subscribes to scopes.root.products ``` **Refresh** writes `scopes.root.products`, which wakes every reader: the rows rebuild and the `N products` count updates. The callback never names the elements it wakes. See [Reactivity](https://uicast.dev/entry/reactivity). ## Expressions compute; `set` changes `props.expr`, `hidden`, `busy`, `each`, and the `expr` of every `seed` or `callbacks` step hold **one expression** in a [subset of JavaScript](https://uicast.dev/expr). Two rules. **An expression only computes.** Only a step's `set` writes state: ```jsonc { "expr": "scopes.root.count + 1" } // ✅ computes { "set": "scopes.root.count", "expr": "currentValue + 1" } // ✅ computes, then writes { "expr": "scopes.root.count = 1" } // ❌ rejected: assignment ``` **The same state gives the same result.** `props`, `hidden`, `busy` and `each` run again only when a scope field they read changes. The language has no clock and no random numbers, so nothing else changes what they show. The time comes from a host function, taken once in a step and stored ([Recipes](https://uicast.dev/expr/recipes)): ```jsonc { "set": "scopes.root.savedAt", "expr": "now()" } // ✅ taken on save; props read scopes.root.savedAt ``` ## Nothing runs on its own An event runs a callback, the page updates, and then it waits. Expressions only read state. Callbacks write state when an event fires, seeds write it once at mount, and nothing else writes. So one event does a limited amount of work, and the page never loops on its own. ## What makes a document valid Most render failures are one of these. **Exactly one root** — the element whose `key` is in no `children`: ```jsonl {"key":"a","component":"Card","children":["b"]} ✅ root {"key":"b","component":"Typography"} ✅ referenced once {"key":"c","component":"Typography"} ⚠️ second root — nobody references c ``` Several roots still render, side by side, but the prompt asks for exactly one. **A list inside a list needs a new `as`.** A nested list that reuses its parent's `as` fails as `invalid-list`: ```jsonl {"key":"orders","each":"scopes.root.orders","as":"row", ...} {"key":"lines","each":"scopes.row.lines","as":"row", ...} ❌ inside it, scopes.row already exists ``` Lists side by side may share a name: an `invoices` list and a `quotes` list can each hold a list with `"as": "line"`. **`props`, `hidden`, `busy` and `each` cannot call host functions.** The call fails in the element's error slot. Host functions run in `seed` and `callbacks`, where the engine waits for the result, so there is no `await`: `listProducts()`, never `await listProducts()`. **Sending a key again updates the element.** It is not an error: the new line replaces the old one. [Edit turns](https://uicast.dev/prompt) work this way: ```jsonl {"key":"title","component":"Heading","props":{"expr":"({ text: 'Products' })"}} {"key":"title","component":"Heading","props":{"expr":"({ text: 'All products' })"}} ``` ## Vocabulary Words with a specific meaning in **uicast**. **props** is narrower than usual: it leaves out event handlers, which definitions and entries list separately as `callbacks`. | Term | Meaning | | --- | --- | | **uicast document** | One complete UI: a flat list of entries that form a tree. The **root** is the entry no other entry lists in `children`; nothing else marks it. Streamed as JSONLines, one entry per line. | | **entry** | One line of the stream: the JSON for one node. What mounts from it is its **element**. See [Entry fields](https://uicast.dev/entry). | | **list** | An entry that repeats once per array item, marked by [`each`](https://uicast.dev/entry). `as` names two scopes per **item**: `scopes.` is the item itself, when it is an object, and `scopes.$` its row, with `index`, `id`, `value` (a string or number item) and the row's own state. | | **scope** | Named state that elements read and subscribe to: `scopes.root` for the whole document, plus two per list item, the item and its row. Scopes stack but never nest. See [State & Scopes](https://uicast.dev/entry/state). | | **expression** | One expression in a subset of JavaScript, run against the scopes. It is the only part of a document that computes. Run by [`@uicast/expr`](https://uicast.dev/expr): a closed grammar, every read checked at run time, no `new Function`. | | **value source** | How a value is produced: `{ "literal": … }` verbatim, or `{ "expr": … }` evaluated. See [Value Sources](https://uicast.dev/entry/value-sources). | | **step** | One item of a `seed` or a callback: a value source, optionally with a `set` path to write it to. A step that reads what another writes runs after it. See [Entry fields](https://uicast.dev/entry#callbacks). | | **definition** / **implementation** | The two halves of a component: the def is what the model reads (`name`, `description`, and `StandardSchemaV1 & StandardJSONSchemaV1` schemas), the impl is the React code that draws it. A registered set of pairs is a **catalog**. See [def](https://uicast.dev/def) and [impl](https://uicast.dev/react). | | **host function** | A function the host gives expressions to call, sync or async; the engine waits for the result either way. Each is a [`standard-tool`](https://standard-tool.js.org/) — name, description, optional `StandardSchemaV1 & StandardJSONSchemaV1` schemas, `execute`. See [Host functions](https://uicast.dev/expr/functions). | --- Page: https://uicast.dev/expr # The expression evaluator A model writes every `expr`, and a saved document replays in other people's browsers, so an expression is untrusted input. `@uicast/expr` defines the language expressions are written in, a subset of JavaScript, and runs it. The subset is **JavaScript expressions over JSON data**: the standard methods of strings, numbers and arrays and a short list of globals, without statements, `new`, mutation, regular expressions or function values. ## How an expression runs `Evaluator` parses an expression once with acorn and runs it itself, checking every property read and every call under a CPU and allocation [budget](https://uicast.dev/expr#budgets). No source reaches the JavaScript engine, so a strict Content-Security-Policy without `unsafe-eval` works. ```tsx import { Evaluator } from "@uicast/expr"; const evaluator = new Evaluator({ functions: tools }); // the React binding ``` The checks happen at run time, because a name can be built while the expression runs: ```js "abc".substr(1) // ❌ refused — `substr` is not in the language "abc"["sub" + "str"](1) // ❌ the same call — the name exists only at run time ``` Under `Evaluator` the call itself is checked, so both lines are refused. Three rules do most of the work: - **Own properties only.** Nothing inherited is readable, so `({}).constructor` is `undefined`. No deny-list is needed. - **Only data is readable.** Objects, arrays, primitives, a short list of built-ins. A class instance, a DOM node or a function fails on the first read, so a real `MouseEvent` in scope cannot leak `evt.target.ownerDocument`. - **Methods are called, never passed around.** `rows.map` as a value is an error. ## Where expressions appear Every site, in one product search (`scopes.root.products` is seeded higher up): ```jsonc { "key": "search", "component": "SearchInput", "seed": [{ "set": "scopes.root.query", "literal": "" }], "props": { "expr": "({ value: scopes.root.query })" }, "callbacks": { "onChange": [{ "set": "scopes.root.query", "expr": "evt.value" }] } } { "key": "empty", "component": "Typography", "hidden": "scopes.root.products.length > 0", "props": { "literal": { "text": "No products" } } } { "key": "rows", "component": "Typography", "as": "row", "keyBy": "id", "each": "scopes.root.products.filter(p => p.name.includes(scopes.root.query))", "props": { "expr": "({ text: scopes.row.name })" } } ``` `props` is a [value source](https://uicast.dev/entry/value-sources); `hidden`, `busy` and `each` are bare strings. Inside `callbacks`, an expression also sees `evt`. A `set` step also sees [`currentValue`](https://uicast.dev/entry/value-sources#valuesourceassignment), the value at that path now. Only `seed` and `callbacks` await a result, so only they may call host [`functions`](https://uicast.dev/expr/functions). A host call in `props`, `hidden`, `busy` or `each` is rejected into the error slot. A scope path read in `props`, `hidden`, `busy` or `each` subscribes the element to it. The paths are found in the expression text; nothing is declared. See [Reactivity & Dependencies](https://uicast.dev/entry/reactivity). ## The language The grammar is a closed allow-list of AST node types; anything else fails validation. | Kind | Allowed | | --- | --- | | Values | numbers, strings, booleans, `null`, template literals | | Access | `a.b`, `a[b]`, `a?.b`, `a?.[b]` (optional *receiver*, not `a?.()`) | | Calls | methods, host functions | | Operators | arithmetic, comparison, `===`/`==`, `&&`, `\|\|`, `??`, `!`, `-`, `+`, `typeof`, `? :` | | Literals | arrays and objects, with spread and computed keys | | Functions | arrow functions with an **expression body**, only as a method's callback or called where written; parameters may destructure | Left out on purpose: - **Statements and declarations.** No `const`/`let`/`var`, loops, `try`, blocks or `function`. A runaway loop cannot be written, and with nothing named, nothing can recurse. - **Assignment, `++`, `delete`, mutating methods.** Expressions are **pure by construction**: only a step's `set` changes state. `.toSorted()` and `.toReversed()` replace `.sort()` and `.reverse()`. - **Regular expressions.** Catastrophic backtracking runs outside the budget. Text search goes through `.includes()`, `.startsWith()`, `.split()` and `.replaceAll()`. - **Functions as values.** An arrow is only a method's callback, as in `rows.map(r => r.name)`, or called where it is written, which names a value: `(r => r ? r.name : "")` applied to `rows.find(…)`. It is never stored or returned, and has no name to call itself by. A global that takes one argument can replace it: `rows.filter(Boolean)`. - **`await`.** The runtime awaits a host call, so the call must be the result: the whole expression, a branch of `?:`, or the right side of `??`, `||`, `&&`. `getUser({ id }).name` and `ids.map(id => getUser({ id }))` are refused before any call runs. - **`Intl`.** The locale methods still take a locale and options: `price.toLocaleString(undefined, { style: "currency", currency: "USD" })`. - **`new`, so `Date` values, `Set` and `Map`.** Every value is plain data: anything JSON can hold, plus `undefined`, `NaN` and `Infinity`. A date is an ISO string or a timestamp from `Date.parse(text)`; format it in a component (see [`DateTime`](https://github.com/finom/uicast/tree/main/packages/shadcn-catalog/src/uicast-catalog/date-time)). An object does a `Map`'s job, and `Object.keys(Object.groupBy(rows, r => r.tag))` lists distinct tags. - **`forEach`, iterators.** Callbacks have no effects, and functions are not values. - **`in`, bitwise operators, `x?.()`, `instanceof`, tagged templates, `this`.** None shapes a display value. - **Methods a UI has no use for.** `Math.fround()`, `Math.f16round()`, `Math.clz32()`, `Math.imul()`, `Math.sumPrecise()`, `.isWellFormed()`, `.toWellFormed()`, an array's `.toLocaleString()`, and a `JSON.stringify` replacer. A callback body is one expression too, never a block: ```js // ✅ a ternary inside the callback scopes.root.rows.map(r => r.status === "paid" ? "green" : "red") // ❌ rejected — a block body is a statement list, and there are no statements scopes.root.rows.map(r => { switch (r.status) { case "paid": return "green" } }) ``` Expressions are also **stateless**: no `Math.random`, no `Date.now`, and nowhere to keep a counter. Without host functions, the same scopes give the same result. A random value or the time comes from a host function, and the prompt tells the model to call it in a step and read the result back ([Recipes](https://uicast.dev/expr/recipes)): ```jsonc // ❌ refused: the language has no clock { "props": { "expr": "({ text: 'Draft ' + Date.now() })" } } // ✅ taken once by a host function, read back from the path { "seed": [{ "set": "scopes.root.draftId", "expr": "now()" }], "props": { "expr": "({ text: 'Draft ' + scopes.root.draftId })" } } ``` `getExpressionsPartialPrompt()` gives the model these rules and the globals, filled from the evaluator's own tables. ## Budgets Every expression stops: evaluation throws when the budget runs out. Only built-in methods iterate, and `Evaluator` implements each one, so every iteration is charged: - a **step counter** (1,000,000 by default): each node is a step, and a built-in adds a step per item it walks or builds (a sort, about n·log n) and per 16 characters it reads or writes; - a **wall-clock deadline** (100 ms), on a clock the expression cannot read or move, checked every 2048 steps and after each locale formatter is built; - **allocation caps**, so `"x".repeat(1e9)` is refused, not attempted; - an **AST depth limit**, so nested source cannot overflow the compiler; - a **source-length limit** (`maxSourceLength`, 1000 characters by default), checked before parsing. The prompt states it to the model. Steps count work roughly; the clock bounds time. One built-in call cannot be stopped halfway, so it is charged its worst case before it runs: a text search, the text's length times the pattern's. `normalize()` and `localeCompare()` refuse text with more than 30 combining marks in a row, where the engine's time grows with the square of the run. ## When an expression is rejected A rejection is a classified [`EntryError`](https://uicast.dev/error-recovery#classification-entryerror-and-onerror), a **document** fault shown in the element's error slot: | expression | reason | | --- | --- | | `if (scopes.root.n) 1` | `expression-syntax` — a statement, not an expression | | `scopes.root.count = 1` | `guardrail-violation` — assignment is not in the language | | `scopes.root.rows["so" + "rt"]()` | `guardrail-violation` — checked at the call, computed or not | | `fetchProducts()` | `unknown-reference` — a name nobody handed in | ## Performance The numbers below come from a fast computer, and from Chrome with its CPU slowed 6× (a mid-range phone) and 12× (a slow phone). A page with a 50-row table (7 cells a row, the [reference catalog](https://uicast.dev/react/reference-catalog)) rendered by React, with its expressions run by `Evaluator` or compiled with `new Function`. Time from the event to the updated DOM: | event | evaluator | fast computer | mid-range phone | slow phone | | --- | --- | --- | --- | --- | | first render | `Evaluator` | 15 ms | 37 ms | 78 ms | | | `new Function` | 14 ms | 34 ms | 71 ms | | next page | `Evaluator` | 15 ms | 36 ms | 76 ms | | | `new Function` | 15 ms | 34 ms | 70 ms | | edit a row | `Evaluator` | 1.5 ms | 4.6 ms | 8.8 ms | | | `new Function` | 1.3 ms | 3.9 ms | 8.4 ms | Most of each event is React and the DOM, the same under either evaluator. Per expression, on the fast computer: | expression | in a document | `Evaluator` | `new Function` | | --- | --- | --- | --- | | `scopes.root.count > 0` | a `hidden` condition | ~45 ns | ~4 ns | | `scopes.root.user.name` | any scope read | ~75 ns | ~6 ns | | template, two interpolations | a text prop | ~160 ns | ~28 ns | | object literal from scope | every `props.expr` | ~180 ns | ~9 ns | | `filter`, `map`, `reduce` over 1000 rows | a derived list | 20–39 µs | 0.7–2 µs | | re-evaluate a whole 1000-row table, 5 expressions per row | the realistic worst case | ~0.65 ms | ~0.09 ms | Two prompt rules keep the load small. Neither is an `Evaluator` limit: - `maxListItems` on `getCommonInstructionsPartialPrompt` (default 100): the model caps each list at that many items and pages past it. - The model leaves real computation to host functions. It asks a function for the page, sort and filters it offers, and reads the totals it returns instead of summing rows. Give your functions those inputs and outputs. Expression time of whole events (every expression one event re-evaluates), in Chrome: | event | fast computer | mid-range phone | slow phone | | --- | --- | --- | --- | | 50-row table, 7 cells per row | 0.3 ms | 1.3 ms | 4.5 ms | | 100-row table, currency and date formatting per row | 0.4 ms | 2.5 ms | 5.1 ms | | dashboard over 1,000 orders: 3 KPIs, a chart, a 30-day series | 1.6 ms | 11 ms | 21 ms | | search keystroke over 1,000 orders | 1.1 ms | 7.1 ms | 16 ms | | the same over 10,000 orders | 9.8 ms | 60 ms | 123 ms | | kanban board, 300 cards | 0.2 ms | 3.5 ms | 3.5 ms | | join 1,000 orders to 200 customers with `find()` per order | 5.2 ms | 33 ms | 68 ms | A frame is 16 ms. Two kinds of work belong in a host function: the whole table in the browser, and a search per row. ## What it does not secure Expressions cannot reach `window`, `fetch` or the rest of the page. They see only the data and functions you give them. Two things are still up to you: | | Risk | What you do | | --- | --- | --- | | **Functions** | A document can call every function you give it. If you give it `deleteProduct`, it can delete products. | Give only the functions it needs. Check permissions on the server. | | **URLs** | A document can put your data into an image URL. The browser sends the data when it loads the image. | Declare URL props with `z.string().meta({ format: "uri-reference" })`. Then [`urlPolicy`](https://uicast.dev/react/renderer#urlpolicy--the-urls-a-document-may-load) blocks other sites. | The [Security model](https://uicast.dev/security) page has more, including the [rules for components](https://uicast.dev/react#props-the-document-controls). --- Page: https://uicast.dev/expr/functions # Host functions A document reaches no API on its own. **Host functions** are functions you supply, called by name from [expressions](https://uicast.dev/expr). Each is a [`standard-tool`](https://standard-tool.js.org/) (`StandardToolV0`). `execute` runs where the renderer runs (the browser, with the React binding), so reach your data over HTTP: a database client would ship its credentials to the client. ## Anatomy of a host function Build one with `standardTool({...})`: ```tsx import { standardTool } from "standard-tool"; import { z } from "zod"; const suggestPalette = standardTool({ name: "suggestPalette", description: "Given a base hex color, return a small harmonious palette of hex strings.", inputSchema: z.object({ hex: z.string() }), outputSchema: z.array(z.string()), async execute({ hex }) { return harmonize(hex); }, }); ``` - **`name`**: what the model calls, a valid JS identifier. - **`description`**: what it does; the model reads it to decide when to call. - **`title`** *(optional)*: a human label printed before the description, unless it is the same text. The model can reuse it for a button or heading. - **`inputSchema`** *(optional)*: the single argument, validated on each call and printed as the signature in the prompt. Omit it for no argument. - **`outputSchema`** *(optional)*: the same for the return value. Omitted means *undeclared*, not *returns nothing*: see [Undeclared returns](https://uicast.dev/expr/functions#undeclared-returns). - **`execute(input)`**: your logic, sync or async. See [Errors](https://uicast.dev/expr/functions#errors). Schemas are `StandardSchemaV1 & StandardJSONSchemaV1`: Zod 4.2+ and ArkType qualify, and Valibot through `toStandardJsonSchema()` from `@valibot/to-json-schema`. ### Errors A failed call is classified. Arguments your input schema rejects are `invalid-arguments`, a document fault the [recovery loop](https://uicast.dev/error-recovery) can feed back to the model. Anything else is a `host-function` failure, the host's. Both reach [`onError`](https://uicast.dev/react/renderer); a failed [`seed`](https://uicast.dev/entry#seed) also shows the [`error` slot](https://uicast.dev/react/renderer). In a [`callback`](https://uicast.dev/entry#callbacks), later steps do not run, and earlier writes stay. ## Wiring into the renderer Bind your tools on the evaluator and pass it to [``](https://uicast.dev/react/renderer#evaluator--the-expression-evaluator): ```tsx import { Evaluator } from "@uicast/expr"; import { EntriesRenderer, RendererProvider } from "@uicast/react"; import { impls } from "@uicast/shadcn-catalog/all/impls"; import type { StandardToolV0 } from "standard-tool"; const tools: StandardToolV0[] = [ listProducts, createProduct, deleteProduct, ]; const evaluator = new Evaluator({ functions: tools }); ; ``` Create the evaluator **once**, as a module constant. It holds the parse cache, and a new instance per render re-renders the whole tree. ## How the model calls them Each tool is in scope under its `name` and takes **one argument**: what `inputSchema` declares, or none without a schema. An object schema means an object: ```js deleteProduct({ id: scopes.row.id }) // ✅ inputSchema: z.object({ id: … }) deleteProduct(scopes.row.id) // ❌ same tool, wrong shape ``` A scalar schema works too: with `inputSchema: z.string()` the call is `deleteProduct(scopes.row.id)`. An object gives each field a name and a description the model can read. `listProducts()` passes `undefined`, which a bare `z.object` rejects even when every field is optional. Declare the input as `schema.optional()` to allow the zero-argument call. A tool named `scopes`, `evt` or `currentValue` would shadow the engine's own bindings, so prompt assembly refuses those names, and duplicates: ```ts standardTool({ name: "scopes", /* … */ }); // ❌ Host function name "scopes" is reserved standardTool({ name: "evt", /* … */ }); // ❌ reserved standardTool({ name: "currentValue", /* … */ }); // ❌ reserved standardTool({ name: "listOrders", /* … */ }); // ✅ anything else is fine ``` ## Where they run: `seed` and `callbacks` `standardTool` wraps `execute` in an `async` function, so a call always returns a Promise. It can only be called where the engine awaits it: - **[`seed`](https://uicast.dev/entry#seed)**: load data on mount. The element shows its [skeleton](https://uicast.dev/react#skeleton) until every async seed resolves: ```jsonl { "key": "table", "component": "Table", "seed": [{ "set": "scopes.root.rows", "expr": "listProducts()" }] } ``` - **[`callbacks`](https://uicast.dev/entry#callbacks)**: change something on an event. The result is written to the step's `set` path: ```jsonl { "key": "suggest", "component": "Button", "callbacks": { "onClick": [ { "set": "scopes.root.swatches", "expr": "suggestPalette({ hex: scopes.root.base })" } ] } } ``` `props`, `hidden`, `busy` and `each` run synchronously on every render, so a host call there goes to the element's error slot. **Derived values in `props`, effects in `seed` and `callbacks`.** ### Mutate, then re-fetch A mutation returns only what it changed, so call it, then re-read in the next step: ```jsonl { "key": "del", "component": "Button", "callbacks": { "onClick": [ { "confirm": "Delete this product?", "expr": "deleteProduct({ id: scopes.row.id })" }, { "set": "scopes.root.rows", "expr": "listProducts()" } ] } } ``` Step one has no `set`: it runs for its effect. A host-function step is a barrier, so the re-fetch waits for the delete, and `confirm` gates both. Ordering in full: [Reactivity](https://uicast.dev/entry/reactivity#effectful-sites-ordering). ### Many items at once A host call cannot sit inside an arrow function, such as the one `.map(...)` takes, so one step cannot call a function once per item. For an action on many items, give the model a function that takes the list: ```js updateProducts({ items: scopes.root.products.map(p => ({ id: p.id, qty: p.qty + 1 })) }) // ✅ one call scopes.root.products.map(p => updateProduct({ id: p.id, qty: p.qty + 1 })) // ❌ a call per item ``` ### Windows and aggregates A function that can return a lot should take a window, declared in its schema: ```ts standardTool({ name: "listProducts", description: "A page of products, by name by default.", inputSchema: z.object({ limit: z.number().int().min(1).max(200).default(50).meta({ description: "Rows to return." }), offset: z.number().int().min(0).default(0).meta({ description: "Rows to skip." }), q: z.string().optional().meta({ description: "Matches the name, case-insensitive." }), }).optional(), outputSchema: z.object({ items: z.array(product), total: z.number().int().meta({ description: "Rows matching the filters, all pages." }), }), execute: (input) => fetchProducts(input ?? {}), }); ``` The prompt prints each field's bounds and defaults, and tells the model to fetch only the slice it renders, to refetch when the page or a filter changes, and never to filter or sort a fetched slice in an expression. Return the total with the rows: a pager needs `Math.ceil(total / limit)`. Put other totals in a function of their own, such as `getSalesSummary({ days })`. ## Advertising to the model `getFunctionsPartialPrompt` from `@uicast/core/prompt` tells the model about your functions: ```ts import { getFunctionsPartialPrompt } from "@uicast/core/prompt"; getFunctionsPartialPrompt({ functions: [ standardTool({ name: "listProducts", description: "Return every product, newest first.", outputSchema: z.array(z.object({ id: z.number().int(), name: z.string() })), execute: async () => (await fetch("/api/products")).json(), }), standardTool({ name: "deleteProduct", description: "Delete a product by id.", inputSchema: z.object({ id: z.number().int().meta({ description: "Product id." }) }), execute: ({ id }) => fetch(`/api/products/${id}`, { method: "DELETE" }), }), ], }); ``` renders exactly: ```md # Available Functions listProducts, deleteProduct ## Function Details - listProducts() => { id: number /* integer */; name: string; }[]: Return every product, newest first. - deleteProduct({ id: number /* Product id. integer */; }) => unknown: Delete a product by id. ``` A `title` prints before the description: `name(params) => output: title — description`. A field's comment carries its `.meta({ description })`, then what the type alone does not say: `integer`, bounds, lengths, a `format`, a `default`, as in `limit?: number /* integer, ≥ 1, ≤ 200, default 50 */`. Never declare a schema for "nothing": `z.void()` has no JSON Schema form and throws. ## Undeclared returns `=> unknown` means **nobody published this shape**. The prompt tells the model it may call the function for its effect but must not read fields off the result: ```jsonc // what the prompt printed for the two: // deleteProduct({ id: number }) => unknown // listProducts() => { id: number; name: string }[] // ✅ delete for the effect, then refetch through the declared one [ { "expr": "deleteProduct({ id: scopes.row.id })" }, { "set": "scopes.root.products", "expr": "listProducts()" } ] // ❌ store the `unknown` result and read fields off it { "set": "scopes.root.result", "expr": "deleteProduct({ id: scopes.row.id })" } // … later: scopes.root.result.remaining — nothing ever said `remaining` exists ``` A step with **no `set`** is legal only in `callbacks`, so a function returning `unknown` has no place in a `seed`. `outputSchema` matters more here than in ordinary tool calling: the model writes `scopes.root.products[0].name` into an expression that runs later, on data it never sees. ## Shared types A schema with an id prints once, as a type, and is referenced by name everywhere else. In Zod, that is `.meta({ id })`: ```ts const Person = z .object({ id: z.string(), name: z.string().optional() }) .meta({ id: "Person" }); ``` ```md ## Function Details - getOwners() => { owner?: Person; reviewer?: Person; }: Owners. - listTree() => { root?: Node; }: Whole tree. ## Shared Types - Person: { id: string; name?: string } - Node: { name?: string; children?: Node[] } — A folder or file. ``` Without `.meta({ id })`, `Person` is inlined at both sites. A **recursive** type needs a name: inlined, its self-reference stops at the cycle guard and prints `unknown[]`, which says the tree is one level deep. [Components](https://uicast.dev/def) hoist their own types into a separate `## Shared Types` block; nothing crosses between the two. ## Where to go next - [Renderer](https://uicast.dev/react/renderer) — the evaluator and the rest of the mount setup. - [Entry fields](https://uicast.dev/entry) — `seed` and `callbacks`, where functions are called. - [The expression evaluator](https://uicast.dev/expr) — the language the calls are written in. --- Page: https://uicast.dev/expr/recipes # Recipes ## Predictability An expression has no clock and no random numbers. Without host functions, the same scopes give the same result: in the browser and on the server, today and next year. Two results still depend on the machine: - The locale methods (`toLocaleString`, `toLocaleUpperCase`, `toLocaleLowerCase`, `localeCompare`) follow its default locale when the expression passes none. - `Date.parse` reads a date-time without an offset, such as `"2026-09-30T10:00"`, in its time zone. A random value and the time come from host functions. The prompt tells the model to call one in a step and store the result, so the value is taken once, and the elements that read it re-run only when it changes. ## Random values ```ts import { standardTool } from "standard-tool"; import z from "zod"; export const random = standardTool({ name: "random", description: "A random number from 0 up to, not including, 1.", outputSchema: z.number(), execute: () => Math.random(), }); ``` Bind it like any other [host function](https://uicast.dev/expr/functions), in the evaluator and in the prompt: ```ts const evaluator = new Evaluator({ functions: [random, ...tools] }); const functionsPrompt = getFunctionsPartialPrompt({ functions: [random, ...tools] }); ``` A tip picked once, when the element mounts: ```jsonc { "key": "tip", "component": "Typography", "seed": [{ "set": "scopes.root.pick", "expr": "random()" }], "props": { "expr": "({ text: scopes.root.tips[Math.floor(scopes.root.pick * scopes.root.tips.length)] })" } } ``` ## The time ```ts export const now = standardTool({ name: "now", description: "The current time: milliseconds since 1970-01-01 UTC.", outputSchema: z.number(), execute: () => Date.now(), }); ``` An order's age in days, from the time taken when the element mounts: ```jsonc { "key": "age", "component": "Badge", "seed": [{ "set": "scopes.root.now", "expr": "now()" }], "props": { "expr": "({ text: Math.round((scopes.root.now - Date.parse(scopes.root.order.createdAt)) / 86400000) + 'd' })" } } ``` The stored time does not move. A display that counts ("3 minutes ago") belongs in a component that updates itself, such as the reference catalog's [`DateTime`](https://github.com/finom/uicast/tree/main/packages/shadcn-catalog/src/uicast-catalog/date-time) with `format: "relative"`. --- Page: https://uicast.dev/expr/custom-expr # Custom evaluator ## What an evaluator does Every expression in a document runs on the evaluator you pass to the renderer: ```tsx import { Evaluator } from "@uicast/expr"; const evaluator = new Evaluator({ functions: tools }); ; ``` For each expression it: 1. **checks** the source against [the language](https://uicast.dev/expr#the-language), once per source; 2. **reports** the `scopes` paths it reads and the host functions it calls, so **uicast** knows when to re-evaluate it and what to await; 3. **runs** it with `scopes`, `evt`, `currentValue`, the host functions and the language's globals; 4. **checks the result**: only plain data leaves. `Evaluator` runs step 3 in an interpreter that checks every read and call under a [budget](https://uicast.dev/expr#budgets), so the page needs no `unsafe-eval`. A subclass can hand step 3 to the JavaScript engine instead. ## A faster evaluator ```ts import { Evaluator } from "@uicast/expr"; export class FunctionEvaluator extends Evaluator { protected override toFunction(names: readonly string[], body: string) { return new Function(...names, body); } } const evaluator = new FunctionEvaluator({ functions: tools }); ``` **uicast** does not ship this class: copy it. `toFunction` runs once per expression and gets the arguments `new Function` takes. Steps 1, 2 and 4 stay; the engine runs step 3. Same expressions, on a fast computer: | expression | in a document | `Evaluator` | `FunctionEvaluator` | | --- | --- | --- | --- | | `scopes.root.count > 0` | a `hidden` condition | ~45 ns | ~9 ns | | `scopes.root.user.name` | any scope read | ~75 ns | ~17 ns | | template, two interpolations | a text prop | ~160 ns | ~37 ns | | object literal from scope | every `props.expr` | ~180 ns | ~53 ns | | `filter`, `map`, `reduce` over 1000 rows | a derived list | 20–39 µs | 0.7–2 µs | | a whole 1000-row table, 5 expressions per row | the realistic worst case | ~0.65 ms | ~0.23 ms | What it costs: - **No budget.** Nothing meters the engine; the `budget` option does nothing. `"x".repeat(1e9)` runs. - **No check at run time.** A name built at run time reaches whatever it names. `"abc"["sub" + "str"](1)` returns `"bc"`, and `[({}).constructor.constructor][0]("…")()` reaches `Function`, which runs any code. - **`unsafe-eval`.** The page's Content-Security-Policy must allow it. ### The model is the first line of defence Step 1 still refuses what it can see: statements, assignment, regular expressions, unknown names, methods outside the allow-list. Through a name built at run time, an expression can do anything the page can: call `fetch`, read cookies, change the DOM. So you bet on the author. A production model that follows [the prompt contract](https://uicast.dev/prompt) in your own pipeline is a fair bet. A stranger's prompt is not, and neither is text the model reads on the way: a user's message, a fetched page, a tool result someone else can write. Any of them can carry instructions. The prompt tells the model that text inside data is data, not instructions. That makes bad output rare. Under `Evaluator`, the interpreter stops it whether the model meant it or not. Use `FunctionEvaluator` only when nothing untrusted reaches the prompt: your own pipeline, an admin tool, your tests. --- Page: https://uicast.dev/def # Component definition A catalog component is a **pair**: `def.ts`, a framework-agnostic definition the model reads, and [`impl.tsx`](https://uicast.dev/react), the React code that renders it. The definition is all the model knows about a component: that it exists, its props and its events. An [entry](https://uicast.dev/entry) points into it. ```ts interface ComponentDefinition { name: string; // catalog name the model emits — PascalCase, unique description: string; // prose the model reads to decide when to use it props?: StandardSchemaV1 & StandardJSONSchemaV1; // the props object; leave it out when there are none callbacks?: Record; // event → payload hidden?: boolean; // kept out of the model's prompt, still renderable } ``` `props` and each callback are schemas that implement both [Standard Schema](https://standardschema.dev/schema) and [Standard JSON Schema](https://standardschema.dev/json-schema): Zod 4.2+, ArkType, or Valibot through `toStandardJsonSchema()` from `@valibot/to-json-schema` (the examples use Zod). The prompt prints the JSON Schema as a TypeScript-like signature; the implementation gets the inferred type. ```ts filename="src/uicast-catalog/badge/def.ts" import z from "zod"; import { createComponentDefinition } from "@uicast/core"; export const BadgeDef = createComponentDefinition({ name: "Badge", description: "A small status indicator badge for labels, counts, or tags. Use it for " + "status (active, pending), categories, counts, or any short label.", props: z.strictObject({ text: z.string().optional().meta({ description: "The badge text content" }), variant: z .enum(["neutral", "info", "warning", "danger"]) .default("neutral") .meta({ description: "Visual style: neutral, info, warning, danger" }), }), }); ``` ## `name` What the model writes in an entry's [`component`](https://uicast.dev/entry#component) field. PascalCase and unique in the catalog: two defs sharing a name throw `Duplicate component name: "Badge"`. ## `description` The model reads it to decide when to use the component. It is printed word for word on the `- Badge — …` line below. ## `props` — the contract with the model The model sees whatever the schema holds. Omit `props` for a component that takes none. `getComponentsPartialPrompt` turns the def above into: ```md - Badge — A small status indicator badge for labels, counts, or tags. Use it for status (active, pending), categories, counts, or any short label. Props: - text?: string — The badge text content - variant?: "neutral" | "info" | "warning" | "danger" = "neutral" — Visual style: neutral, info, warning, danger ``` Describe every field with `.meta`, or it prints as a bare type. `.default("neutral")` makes `variant` optional and prints as `= "neutral"`. Props are parsed through the schema before [`render`](https://uicast.dev/react) sees them, so an omitted `variant` arrives as `"neutral"`. An entry's [`props`](https://uicast.dev/entry#props) must produce that shape: ```jsonc { "key": "order-status", "component": "Badge", "props": { "expr": "({ text: scopes.root.order.status, variant: scopes.root.order.status === 'paid' ? 'neutral' : 'danger' })" } } ``` Use `z.strictObject`, so a prop the model invented fails the element into its error slot instead of being dropped silently. **Never a `children` prop.** It is the entry field that lists child keys, so declaring it throws on creation. Name the prop for what it holds (`text`, `title`, `label`); nested elements reach the [implementation](https://uicast.dev/react) as React `children`. ## `callbacks` — event payloads An optional map from event name to payload schema: the shape of `evt` in that callback's [steps](https://uicast.dev/entry#callbacks). ```ts createComponentDefinition({ // … callbacks: { onChange: z.strictObject({ value: z.string().meta({ description: "The current value" }), }), onKeyDown: keyboardEventSchema, }, }); ``` With `keyboardEventSchema` as a [shared event](https://uicast.dev/def#shared-events), the prompt prints: ```md Event handlers: - onChange(evt) - value: string — The current value - onKeyDown(evt: KeyboardEvent) ``` The model can then write: ```jsonc { "key": "search", "component": "SearchInput", "props": { "expr": "({ value: scopes.root.q })" }, "callbacks": { "onChange": [{ "set": "scopes.root.q", "expr": "evt.value" }] } } ``` A callback with no payload is `z.null()`, printed as a call with no argument: ```md Event handlers: - onPress() — Fires when pressed ``` The implementation calls it as `onPress()`. ### Shared events A payload many components fire, like `onClick`'s, would print under each one. An `id`, as on any shared type, prints it once: ```ts // src/uicast-catalog/events/keyboard.ts export const keyboardEventSchema = z .object({ key: z.string().meta({ description: 'The key value (e.g. "Enter", "a")' }), repeat: z.boolean().meta({ description: "Whether the key is auto-repeating" }), }) .meta({ id: "KeyboardEvent", description: "Fires on a key press" }); // in every def that fires it createComponentDefinition({ // … callbacks: { onKeyDown: keyboardEventSchema }, }); ``` `getComponentsPartialPrompt` finds the `id` on its own, and each handler becomes one line, `onKeyDown(evt: KeyboardEvent)`: ```md ## Common Events - KeyboardEvent — Fires on a key press - key: string — The key value (e.g. "Enter", "a") - repeat: boolean — Whether the key is auto-repeating ``` A payload without an `id` prints inline under each handler. Two different payloads with the same `id` throw at prompt assembly. The implementation still passes that exact payload, not the React event: ```tsx createComponentImplementation({ // … render: ({ onKeyDown }) => ( onKeyDown({ key: e.key, repeat: e.repeat })} /> ), }); ``` ## `hidden` — keep a def out of the prompt `hidden: true` keeps a def out of the prompt; an entry that names it still renders. ## Registration Register each component twice: the def for the prompt, the impl for the renderer. ```tsx import { getComponentsPartialPrompt } from "@uicast/core/prompt"; import { EntriesRenderer, RendererProvider } from "@uicast/react"; import { defs } from "@uicast/shadcn-catalog/layout/defs"; import { impls } from "@uicast/shadcn-catalog/layout/impls"; import { BadgeDef } from "./badge/def"; import { BadgeImpl } from "./badge/impl"; const definitions = [...defs, BadgeDef]; const implementations = [...impls, BadgeImpl]; getComponentsPartialPrompt({ definitions }); ; ``` With only the def, the model emits a `Badge` that renders the [`error` slot](https://uicast.dev/error-recovery) with `reason: "unknown-component"`. With only the impl, the model never learns the component exists. --- Page: https://uicast.dev/react # Component implementation A component is a **pair**: a [definition](https://uicast.dev/def) the model reads and an implementation React runs. The implementation decides what renders: a component from your design system, or plain markup. ```tsx filename="src/uicast-catalog/badge/impl.tsx" import { createComponentImplementation } from "@uicast/react"; import { Badge } from "./badge"; import { BadgeDef } from "./def"; export const BadgeImpl = createComponentImplementation({ def: BadgeDef, render: ({ text, children, variant }) => ( {children ?? text} ), }); ``` `createComponentImplementation` returns the implementation to pass to [``](https://uicast.dev/react/renderer). An optional third field, [`skeleton`](https://uicast.dev/react#skeleton), draws the component while it waits for data or children. ## The render function `render` gets two arguments. The first is one object, typed from the def, with every prop, every callback and `children` side by side (`{ text, variant, onClick, children }`): - **Each prop**, already resolved: the engine evaluates the entry's [`props`](https://uicast.dev/entry#props) and parses the result through the def's schema, so every `.default()` is applied. ```jsonc { "key": "total", "component": "Typography", "props": { "expr": "({ text: 'Open: ' + scopes.root.open })" } } // render receives { text: "Open: 3" } // …and is called again with "Open: 4" the moment something writes scopes.root.open ``` - **Each callback**, a `(payload) => Promise` under its key in the def's `callbacks`, present even if the entry wired none. Calling one parses the payload through its schema and runs the entry's [callback steps](https://uicast.dev/entry#callbacks) with it as `evt`. - **`children`**, the rendered child elements, present only when the entry has a `children` array. It is never a prop ([a def declaring one throws](https://uicast.dev/def#props--the-contract-with-the-model)). The second is the **render context**: - **`entry`**, the entry being rendered, unevaluated. - **`busy`**, the entry's [`busy`](https://uicast.dev/entry#busy) expression, evaluated. What to show while it is `true` is up to the implementation, such as a busy state. - **`scopes`**, the scopes the entry's expressions read: `scopes.root` and, inside a list, the item's two scopes. It is for debugging; state changes go through callbacks. ```tsx filename="src/uicast-catalog/search-input/impl.tsx" export const SearchInputImpl = createComponentImplementation({ def: SearchInputDef, render: ({ value, onChange, onKeyDown }) => ( onChange({ value: e.target.value })} onKeyDown={(e) => onKeyDown({ key: e.key, repeat: e.repeat })} /> ), }); ``` Build each payload from the React event by hand, with exactly the fields its [schema](https://uicast.dev/def#callbacks--event-payloads) declares. ## Skeleton An optional `skeleton` draws a component while something is missing. It gets four props: `reason`, `entry`, `knownProps` and `children`. `reason` says what is missing: - `"streaming"`: a child entry has not arrived yet. The **parent's** skeleton fills the slot, with the parent's `entry`, since the child's component is not known yet. It gets no `children`. - `"seeding"`: the element's own `seed` is waiting on an async host function. The element and everything under it draw as skeletons, as [`DocumentSkeleton`](https://uicast.dev/react/ssr#document-skeleton) draws them: each skeleton gets `children` (its children's skeletons, or `null`) and draws its own tag, a list draws three items, and an element under it with `hidden` is left out. While [`init`](https://uicast.dev/react/renderer#init--preload-state-for-the-group) loads, the whole document draws this way. `knownProps` holds the props that need no evaluation, a literal or none, checked by the definition with its defaults filled in, as `render`'s are. It is absent when the props come from an expression, and in a child's slot. ```tsx filename="src/uicast-catalog/card/impl.tsx" export const CardImpl = createComponentImplementation({ def: CardDef, render: ({ title, children }) => {children}, // With `children`, draw the Card itself; without, fill a slot inside a real one. skeleton: ({ knownProps, children }) => children === undefined ? : {children}, }); ``` ```jsonc // while getSales() is in flight, the panel draws as CardImpl's skeleton, // titled "Sales" from its literal props, with BarChartImpl's inside { "key": "panel", "component": "Card", "props": { "literal": { "title": "Sales" } }, "seed": [{ "set": "scopes.root.sales", "expr": "getSales()" }], "children": ["chart"] } // once the seed settles, the Card renders; a "chart" line // that hasn't streamed in yet leaves a slot, filled by CardImpl's skeleton // without children, since the gap is the parent's { "key": "chart", "component": "BarChart", "props": { "expr": "({ data: scopes.root.sales, xKey: 'month', yKeys: ['total'] })" } } ``` With no `skeleton`, an element with children draws theirs, and anything else draws [`fallbackComponents.defaultSkeleton`](https://uicast.dev/react/renderer), or nothing. ## Registration Pass every implementation to ``; the [definitions](https://uicast.dev/def#registration) go to the prompt. Adding a component means updating both. ## Props the document controls A model wrote every prop your implementation gets. The [evaluator](https://uicast.dev/expr) checks expressions, not what your JSX does with their results. Follow three rules: - **Put URLs only in URL props.** Declare the prop as a URL in the [definition](https://uicast.dev/def) (`z.string().meta({ format: "uri-reference" })`), and [`urlPolicy`](https://uicast.dev/react/renderer#urlpolicy--the-urls-a-document-may-load) checks it before `render` runs. A plain string prop in `src`, `href` or `poster` is checked by nothing. - **Never put document text where it can run.** `dangerouslySetInnerHTML`, `