The UI is the engine
An assistant shouldn't get a second app inside the chat panel. It should drive the one people already use: see the screen, use the same controls, and draft anything with a form, which the real UI then renders and saves.
The usual AI feature builds a second app inside the chat panel. A mini table shows results. A preview shows a dashboard. A “create alert” flow half-matches the real one, and server code learns what an alert looks like. Every product change has to land twice, and the copy is always behind.
I went the other way. The assistant has no screens of its own. The app is its renderer, its hands and its eyes, and the broker for anything it can make. The server never names a UI type: on every request, it reads what the app publishes and works through it.
This post walks through how that fits together. Each piece has its own pattern page with the details.
What the app publishes
┌──────────── the app publishes, per request ─────────────┐
where it can go │ catalog: every route, its purpose, params and choices │
what's on screen │ view: the page, its filters, what its queries hold │
what can be done here │ actions: every control a mounted page registered │
what it can create │ payloads: a name, a description and a JSON Schema each │
└──────────────────────────┬───────────────────────────────┘
▼
assistant: navigate · filter · sort · run an action · generate(kind)
│
┌─────────────────────────────┴──────────────────────────────┐
▼ ▼
client tool → the real control runs payload → a card → click → the real form or page,
(validated twice, undoable with Back) primed with the values; it owns Save
Four sources, each with one owner:
- Where it can go. Every route has a view file of literal data: title, what the page is for, what it can make, starter questions. The list rides each request as a hash, and the full list goes only when the server asks.
- What’s on screen. Nothing is registered per page. The cache is the screen: every query names what it fetches, and the snapshot reads the active ones when a message is sent.
- What can be done. Pages publish their actions while mounted. One store feeds the command palette and the assistant’s tools, so the person and the AI always have the same buttons.
- What it can create. Each create form declares its payload: a schema and what to do with a filled one.
On the server, one function turns that into the model’s tools, and a tool whose preconditions don’t hold is absent, not refused. When the model gets a UI call wrong, the server catches it before it reaches the screen, and the rejection goes back to the model with the allowed values. Everything comes back on one stream.
Anything with a form can be generated
The generation manifest is just the payload declarations, collected from what the app registers:
// next to the existing alert editor: the whole integration
export const ALERT_PAYLOAD = declareDraft({
name: "alert",
title: "Alert",
description: "An alert that watches orders and fires when its query matches.",
input: z.object({
name: z.string().max(120),
query: z.string().meta({ format: "query" }), // grounded against the account's real data, then checked
severity: z.enum(["low", "medium", "high"]),
}),
label: (alert) => alert.name,
open: { to: "/alerts/new" }, // the real editor reads the draft and owns Save
});
The model has one Make tool, generate, whose kind enum is exactly the declared payloads:
const kinds = payloads.filter((shape) => !producerFor(shape.name) || producerAvailable(shape.name));
// a kind with a dedicated producer (say, a dashboard composer) uses it.
// any other kind: the model writes JSON to the declared schema, fields marked format: "query" are
// grounded against real data, and the whole thing is validated against the schema before it's sent
So “draft me an alert for orders more than 3 days late” goes like this. The model picks alert, fills the shape and grounds the query, and the person gets a card titled with the alert’s name. One click opens the real alert editor, filled in and unsaved. Making a new form generatable is one declaration, with no server change.
Opening what it made: prime the destination
The obvious way to show a generated dashboard is a preview in the chat. Then there are two dashboard renderers, and the preview never quite matches: no editing, a different grid, and a Save that has to reimplement the real one.
Instead, opening a card writes the payload into the real page’s query cache, then navigates there:
declarePayload({
name: "dashboard",
input: DashboardSpecZ,
run: async (spec, app) => {
app.queryClient.setQueryData(dashboardQuery(PREVIEW_ID).queryKey, toDashboard(spec, { unsaved: true }));
await app.go({ to: "/dashboards/$id", params: { id: PREVIEW_ID } });
},
});
The real page reads its own query, gets a cache hit and renders with its normal components. Save is its normal Save.
- Write the key the page already reads. The page doesn’t know the data came from the assistant.
- Mark it unsaved, so the page shows a draft and never patches a record that doesn’t exist yet.
- Unknown kind, no card. If a payload’s kind is unknown or its
vis newer than the client, the card renders nothing. A newer assistant never breaks an older client. - Drafts are one-shot. A form reads its draft as starting values, and the route clears it on unmount.
Changing what’s on screen: revise, don’t rewrite
Some screens show a proposal the assistant drafted, like a set of discount tiers and the rules that place each customer in one. The person says “leave out wholesale accounts”. Letting the model rewrite the proposal in its reply drops the person’s hand edits, skips the domain rules and dumps every rule into the chat. Two quick requests also overwrite each other.
So the model passes the request in the person’s words to the engine that owns the proposal. The proposal travels in the request, exactly as on screen, and never through the model’s text:
const reviseProposal = new Tool({
name: "revise-proposal",
input: z.object({ request: z.string().min(1).max(1000) }),
async run(ctx, { request }, view) {
const proposal = latest ?? view.meta.proposal; // as on screen, hand edits included
return reviser.revise({ request, proposal, ifRevision: proposal.baseRevision });
},
});
- Conditional write. The proposal carries the revision it was built on. If that’s no longer current, nothing is written: “This changed while you were looking at it. Reload and ask again.”
- Chained calls build on each other. Once one call moves the proposal forward, the next call in the same turn starts from that revision.
- Undo writes forward. Revert restores the previous version as a new revision, so history is never rewritten.
- Refusals are answers. What the domain doesn’t allow comes back as
declined, and the model relays it as given. Raw store errors become a fixed message, so a table name never reaches the person. - The reply is one line. The screen already shows the rules, so the model says what changed in a sentence.
The proposal reaches the request as a value the page publishes while mounted, usePublishMeta("proposal", proposal), and the tool only exists while it’s there.
Remembering what it did: history markers
The client holds the conversation and sends it back as text, and the server holds none. But much of what the assistant does isn’t text. It filtered the list, opened a page or ran a tool, or got stopped halfway. Without a record of that, the transcript breaks: two user messages in a row, which the model API rejects. The model also forgets it already filtered.
So each of those leaves a short bracketed marker in the assistant’s turn:
[applied filter mode=replace: status=["late"]]
[navigated to /customers/c_42 search={"tab":"orders"}]
[ran find-customer term="acme": 3 results: Acme Corp (customer, c_42); Acme Labs (customer, c_77); …]
[The user stopped this response before it finished.]
[no response]
The prompt says markers are the current state of the view, and “undo that” means proposing the reverse.
- Turns always alternate.
[no response]only fills a gap after a user message. - Payloads never enter history. The exception is a capped summary (10 items, 80-character labels) of a tool the person ran themselves, so “make a group of all of them” works.
- Actions are pinned to their page. A filter carries the route it was asked on. If the person has moved, it’s refused with “You’ve moved to another page since asking.”
- Record outcomes, not emits. A marker should say what actually happened on the page. An action the person never confirmed shouldn’t read as applied.
Why it works
The product stays one product. Whatever people can see, click or create, the assistant can see, click or draft, through the same components, permissions and save paths. New screens need no server change. The AI work moves to where it pays: understanding the request and filling the shape well.
The rest of the series: An agent loop that always answers, Generation you can check, and What the assistant knows.