Skip to content

Actions and forms

Everything a person does in a generated UI reaches your app through one callback. The action types, the form submit payload, and what to send back to the model.

Updated View as Markdown

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.

Preview

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 no do:, 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.

Preview

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:

  • required
  • type:email|url|number|tel on Input; type:email|url on TagInput checks every tag
  • minLength, maxLength, min, max, pattern:"…"
  • match:"password": must equal another field
  • protocols:["https"] for URLs (default http and https)
  • error:"…": the message for an invalid value
  • CheckboxGroup(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" or Button(…, type:draft) submits with empty fields allowed. What is filled in is still checked. The action has partial: 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close