---
title: "Actions and forms"
description: "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."
---

> Documentation Index
> Fetch the complete documentation index at: https://gistui.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Actions and forms

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.

```ts
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.

```gistui
$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.

```gistui
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:

```ts
{
  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

```gistui
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`.

```ts
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](/docs/guides/chat/) and [Security](/docs/guides/security/).

Source: https://gistui.com/docs/guides/actions-and-forms/index.mdx
