The UI declares every payload
The app registers a schema and a run for everything the assistant can make. The model fills the shape; the app decides what a click does. The server never names a UI type.
If the server decides what the assistant can generate, every new “AI can draft an alert” feature needs a server change, a new payload type and a bespoke viewer. The client already knows its forms. So let the client say what it accepts.
Each payload is a schema plus a run. The model fills the schema; the app owns what happens next.
export const ALERT_PAYLOAD = declareDraft({
name: "alert",
title: "Alert",
description: "A monitoring alert. Opens unsaved in the alert editor.",
input: z.object({
name: z.string().max(120).describe("What the alert watches for."),
severity: z.enum(["low", "medium", "high"]),
queries: z.array(z.object({
query: z.string().meta({ format: "query" }), // grounded against real data, not just shaped
})).min(1),
}),
label: (alert) => alert.name, // the card's title
open: { to: "/alerts/new" },
});
app registers ──▶ { name, JSON Schema } rides the request ──▶ /generate alert
│
server producer if one exists, else the model writes JSON to the schema
│
card "Error rate above 5%" ◀── payload { kind: "alert", body } ◀───┘
│ click
▼
run(): setDraft("alert", body) ──▶ navigate /alerts/new ──▶ editor reads useIncomingDraft("alert")
Two flavors
// a surface that needs priming: run does whatever it takes
declarePayload({ name, title, description, input, run: (body, app) => { ... } });
// a create form: hold the value, open the form, let the form read it
declareDraft({ name, title, description, input, open: { to: "/alerts/new" } });
// in the form
const [draft, clearDraft] = useIncomingDraft<AlertDraft>("alert");
Rules
- Forms get values, never mutations. A draft opens unsaved. The destination owns Save, so the user always confirms.
- Grounded fields are marked. A field tagged
format: "query"is generated against the real data and validated, not just written to look right. - No generate buttons on pages. The assistant is the way in; the page only declares what it accepts.
Why it works
Adding “AI can draft X” is one declareDraft next to the form that already exists. The server stays generic, and the card, the click and the save all live in the app that understands them.