A generated UI talks to your app through one callback. A button press, a follow-up suggestion, a form submit and a runtime error all arrive there as a typed object.
function handle(a) {
if (a.type === "send") sendToModel(a.message); // a Button or a FollowUps item
else if (a.type === "submit" && !a.partial) save(a.formId, a.values); // a valid form
}| Framework | How to pass it |
|---|---|
| React, Solid, vanilla | onAction={handle} |
| Vue | @action="handle" |
| Svelte | onaction={handle} |
Action types
type |
When | Main fields |
|---|---|---|
send |
A Button without do:, a FollowUps item, or @send("…") |
message |
submit |
A form was submitted and is valid (or saved as a draft) | values, schema, formId, message, partial |
open |
@open(url) ran. The link is also opened, unless openLinks is false |
url |
emit |
@emit("event", payload) ran: a custom event for your app |
event, payload |
select |
A chart point or range was selected | the selection |
change |
A bound value changed | the value |
error |
A tool was missing or failed, or a URL was blocked | code: tool-not-found, tool-failed, blocked-url |
Buttons
Try it: press the buttons and open your browser console. The preview logs each action.
Loading preview…
$n = 0
root = Stack(Buttons(Button("Summarise this"), Button("Add one", do:[@set($n, $n + 1)], v:secondary), Button("Export", do:[@emit("export", {format:"csv"})], v:ghost)), "Count: **" + $n + "**", FollowUps(["Compare with last year", "Show the top five"]))Button("Summarise this")has nodo:, so it sends its label:{ type: "send", message: "Summarise this" }.do:[@set($n, $n + 1)]changes state in the browser. Nothing reaches your app.do:[@emit("export", {format:"csv"})]reaches your app as{ type: "emit", event: "export", payload: { format: "csv" } }.
Steps in a do: list run in order and stop at the first failure.
| Step | What it does |
|---|---|
@set($x, value) |
Sets a state variable |
@reset($x, …) |
Resets state variables to their defaults |
@run(name) |
Runs a mutation, or re-fetches a query |
@send("text") |
Sends a message: a send action |
@open(url) |
Opens a link (http, https, mailto and tel only) |
@emit("event", payload) |
A custom event for your app |
Forms
A form validates itself from the rules written on its fields, then submits once, with everything you need to store or forward it.
Loading preview…
root = Form("invite", TagInput("emails", "Email addresses", type:email, required), Select("role", "Role", ["Viewer", "Editor", "Admin"], value:"Editor"), Slider("seats", "Seats", min:1, max:20, value:5), Checkbox("terms", "I accept the terms", required), submit:"Send invites", id:"invite-v1")A valid submit emits this:
{
type: "submit",
form: "invite",
nodeId: "invite/1",
formId: "invite-v1", // Form(…, id:"invite-v1"), or "form_" + a fingerprint of the fields
submissionId: "6f1c…", // UUID v4, new for every submit: use it as an idempotency key
submittedAt: "2026-09-30T10:12:00.000Z",
partial: false, // true for drafts
values: { emails: ["ada@x.com"], role: "Editor", seats: 5, terms: true }, // typed; empty optionals left out
fields: [{ name, label, component, type, input, required, multiple, options, default, hint, step, rules }],
schema: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties, required, additionalProperties: false },
message: "Submitted \"invite\":\n- Email addresses: ada@x.com\n- Role: Editor\n- Seats: 5",
}What each part is for
| Field | Use it to |
|---|---|
values |
Store or process the answer. Numbers are numbers, lists are lists. |
schema |
Validate values on your server with any JSON Schema validator (Ajv, z.fromJSONSchema), or generate types. It carries format, enum, minimum, maxLength, pattern and the rest of the field rules. |
formId |
Route the submit. Give known forms an id:. An AI-generated form gets a fingerprint, so the same fields always give the same id. |
submissionId |
Deduplicate retries. |
message |
Send to the model as the person’s reply, in a chat. |
partial |
Tell a draft (true) from a final submit. |
Never trust values because the browser validated them. Validate again on the server with schema.
Validation rules
Rules live on the fields:
requiredtype:email|url|number|telonInput;type:email|urlonTagInputchecks every tagminLength,maxLength,min,max,pattern:"…"match:"password": must equal another fieldprotocols:["https"]for URLs (default http and https)error:"…": the message for an invalid valueCheckboxGroup(min:, max:)counts choices
Form(…, validate:submit|blur|change) decides when to check. submit is the default, and re-checks a field as it is fixed. On submit, the first invalid field gets focus.
Step forms, drafts, success
root = Form("signup", Step("Account", Input("email", "Email", type:email, required)), Step("Profile", Input("name", "Full name", required)), submit:"Create", draft:"Save draft", success:"Welcome aboard.")- Steps: Continue validates the current step first. The action has
step: { index, count, title }. - Drafts:
draft:"Save draft"orButton(…, type:draft)submits with empty fields allowed. What is filled in is still checked. The action haspartial: true. - Success:
success:"…"replaces the form with a thank-you panel.
Back to the model
In a conversation, both send and submit become the next user turn: send a.message.
function handle(a) {
if (a.type === "send" || (a.type === "submit" && !a.partial)) chat.send(a.message, { origin: "ui" });
}@gistui/chat does this for you, and marks the message as coming from the UI so the model cannot mistake text chosen by a generated program for text the person typed. See Chat and Security.