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.
[
{
"key": "card",
"component": "Card",
"loading": "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 }]
}
}
]How it's built— definition, implementation, prompt & wiring
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" }),
},
});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 }, { loading }) => (
<Card className={loading ? "w-72 animate-pulse opacity-60" : "w-72"} aria-busy={loading || undefined}>
<CardContent className="grid gap-2">{children}</CardContent>
</Card>
),
});
// heading/impl.tsx
export const HeadingImpl = createComponentImplementation({
def: HeadingDef,
render: ({ text }) => <div className="font-semibold">{text}</div>,
});
// button/impl.tsx
export const ButtonImpl = createComponentImplementation({
def: ButtonDef,
render: ({ label, onClick }) => (
<UIButton variant="outline" size="sm" className="justify-self-start" onClick={() => onClick()}>
{label}
</UIButton>
),
});
// product-row/impl.tsx
export const ProductRowImpl = createComponentImplementation({
def: ProductRowDef,
render: ({ name, price, qty, onQtyChange, onEdit }) => (
<div className="flex items-center gap-2 border-t pt-1 text-sm">
<span className="truncate">{name}</span>
<UIButton variant="ghost" size="sm" className="mr-auto h-6 px-1.5" onClick={() => onEdit()}>
✎
</UIButton>
<span className="w-12 text-right text-muted-foreground">${price} ×</span>
<input
type="number"
min={0}
value={qty}
onChange={(e) => onQtyChange({ value: e.target.valueAsNumber || 0 })}
className="w-14 rounded border bg-transparent px-1 py-0.5 text-right"
/>
</div>
),
});
// edit-dialog/impl.tsx
export const EditDialogImpl = createComponentImplementation({
def: EditDialogDef,
render: ({ name, price, onNameChange, onPriceChange, onSave, onCancel }) => (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/40">
<div className="grid w-64 gap-2 rounded-lg border bg-background p-4 shadow-lg">
<div className="font-semibold">Edit product</div>
<input
value={name}
onChange={(e) => onNameChange({ value: e.target.value })}
className="rounded border bg-transparent px-2 py-1 text-sm"
/>
<div className="flex items-center gap-1 text-sm">
<span className="text-muted-foreground">$</span>
<input
type="number"
min={0}
value={price}
onChange={(e) => onPriceChange({ value: e.target.valueAsNumber || 0 })}
className="flex-1 rounded border bg-transparent px-2 py-1"
/>
</div>
<div className="flex justify-end gap-2">
<UIButton variant="outline" size="sm" onClick={() => onCancel()}>
Cancel
</UIButton>
<UIButton size="sm" onClick={() => onSave()}>
Save
</UIButton>
</div>
</div>
</div>
),
});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<string, Override> {
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.
}
},
});# 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.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 ? (
<div className="fixed inset-0 z-[60] grid place-items-center bg-black/40">
<div className="grid w-64 gap-3 rounded-lg border bg-background p-4 shadow-lg">
<div className="text-sm">{message}</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button size="sm" onClick={onConfirm}>
Confirm
</Button>
</div>
</div>
</div>
) : null,
};
export function Products({ entries }: { entries: ComponentEntry[] }) {
return (
<RendererProvider implementations={implementations} evaluator={evaluator} fallbackComponents={fallbackComponents}>
<EntriesRenderer entries={entries} />
</RendererProvider>
);
}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 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.
{ "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.productis the product itself;scopes.$productis its row, withindex(0, 1, 2 …) andid.keyBy— the field the row’sidcomes 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 holdsproducts, set by theseedon line 1.scopes.product— one per row, named by the list’sas: the product itself.scopes.$product— one per row too: itsindex,idand 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:
{ "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.
Editing a row updates the total
The $135 total sits outside the list and reads the array:
{ "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:
{ "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
that saves the change. Save’s first step has a
confirm: if the user
declines, that step and every step after it are skipped. The host’s
fallbackComponents.confirm
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.
{ "key": "rows", "each": "scopes.root.products", "as": "product", "keyBy": "id" }
// └── auto-subscribes to scopes.root.productsRefresh 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.
Expressions compute; set changes
props.expr, hidden, loading, each, and the expr of every seed or callbacks
step hold one expression in a subset of JavaScript. Two rules.
An expression only computes. Only a step’s set writes state:
{ "expr": "scopes.root.count + 1" } // ✅ computes
{ "set": "scopes.root.count", "expr": "currentValue + 1" } // ✅ computes, then writes
{ "expr": "scopes.root.count = 1" } // ❌ rejected: assignmentThe same state must give the same result. props, hidden, loading and
each run again only when a scope field they read changes. The time is not a
scope field: props showing Date.now() keep the time of their last run, and
jump when an unrelated field changes. So the prompt has the model take the time
once, in a step, and store it:
{ "props": { "expr": "({ text: 'Saved at ' + Date.now() })" } } // ⚠️ runs, but jumps whenever the props re-run
{ "set": "scopes.root.savedAt", "expr": "Date.now()" } // ✅ taken on save; props read scopes.root.savedAtNothing 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:
{"key":"a","component":"Card","children":["b"]} ✅ root
{"key":"b","component":"Typography"} ✅ referenced once
{"key":"c","component":"Typography"} ⚠️ second root — nobody references cSeveral 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:
{"key":"orders","each":"scopes.root.orders","as":"row", ...}
{"key":"lines","each":"scopes.row.lines","as":"row", ...} ❌ inside it, scopes.row already existsLists side by side may share a name: an invoices list and a quotes list can
each hold a list with "as": "line".
props, hidden, loading 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 work this way:
{"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. |
| list | An entry that repeats once per array item, marked by each. as names two scopes per item: scopes.<as> is the item itself, and scopes.$<as> its row, with index, id 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. |
| 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: 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. |
| 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. |
| 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 and impl. |
| 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 — name, description, optional StandardSchemaV1 & StandardJSONSchemaV1 schemas, execute. See Host functions. |