For a while my AI assistant was a JSON printer with extra steps.
The user described what they wanted. The model streamed a blob of text. I stripped markdown fences, regex’d out {...}, called JSON.parse, and wrote the result into app state. If they wanted one small change, we did the whole thing again.
It looked like generation because tokens were moving. It was not a form editor. It was a parser with a prompt taped to it.
I replaced that with tools. The model no longer is the form. It calls add_field, update_field, set_conditional_logic. Those run against the live editor while the turn is still streaming. You can point at a field and say “this one.”
This is how that actually works in the code.
The old contract
The generation skill was explicit:
Generate **pure JSON only** - no markdown code blocks, no explanations.Then a pile of schema docs, field types, grid rules, and “MUST NOT” lists that grew every time the model got a property wrong. Display components kept arriving with
Output must be valid JSON that can be directly parsed by a form builder.validation: []. We added CRITICAL WARNING sections. It still happened, because the interface was “one perfect object.”
Theme generation still uses this pattern. Stream Claude’s text, then:
let jsonText = content.text.trim()
if (jsonText.startsWith("```")) {
const codeBlockMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)\s*```/)
if (codeBlockMatch) jsonText = codeBlockMatch[1].trim()
}
const jsonMatch = jsonText.match(/\{[\s\S]*\}/)
const result = jsonMatch ? JSON.parse(jsonMatch[0]) : JSON.parse(jsonText)That block is a museum exhibit. Truncation, a trailing comma, or an extra sentence and you paid for a full generation and threw it away. “Make this required” reprinted every step, field, gridLayout, and displayConfig. There was no this. Chat was two text bubbles. The canvas sat still until the parse succeeded.
The new contract
The system prompt now starts with the opposite instruction:
You are NOT a JSON dump machine, talk like a helpful product assistant. Never paste large JSON blobs into chat. The canvas shows the form.
The route does not parse an assistant message into a form. It streams a tool-using model and lets the client apply each call:
const result = streamText({
model: google("gemini-2.5-flash"),
system,
messages: await convertToModelMessages(recent),
tools,
stopWhen: isStepCount(12),
abortSignal: request.signal,
})
return result.toUIMessageStreamResponse({
originalMessages: recent,
sendReasoning: true,
})stopWhen: isStepCount(12) is a fuse. Tool loops can wander. I do not want a 40-call turn because the model decided to narrate every checkbox.
abortSignal: request.signal means Stop in the UI actually stops generation.
Tools without execute
This was the important bit.
Vercel AI SDK tools usually have an execute that runs on the server. Mine mostly do not. The declarations are schemas plus a description. The comment in the tools file is the whole design:
/**
* Mutation tools and inspect_form have no `execute` — the client runs them
* against the live Zustand store so mid-turn mutations are visible to inspect.
*/A field update looks like this:
update_field: tool({
description:
"Update properties on an existing field. Prefer selected field when user says 'this field'. Partial gridLayout/displayConfig are deep-merged (won't wipe other keys).",
inputSchema: z.object({
stepId: z.string().optional(),
fieldId: z.string().optional().describe("Defaults to selected field"),
updates: fieldInputSchema.partial().omit({ id: true }),
}),
}),Same idea for add_field, add_step, delete_field, move_field, set_conditional_logic, create_form, and so on. create_form still exists for empty canvases and rebuilds. The prompt tells the model not to stuff a 40-field job application into one payload. First pass for structure, then smaller tools so the canvas actually moves.
If these ran on the server, every mutation would be a round trip through a snapshot from request start. inspect_form later in the same turn would not see the field you just added. The editor is the source of truth. The model proposes actions. The store applies them.
Applying tools on the client
useChat auto-continues when the assistant message finishes with tool calls. onToolCall is where the form changes:
useChat({
transport,
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
onToolCall: ({ toolCall }) => {
if (toolCall.dynamic) return
const { toolName, toolCallId, input } = toolCall
if (appliedToolCallsRef.current.has(toolCallId)) return
appliedToolCallsRef.current.add(toolCallId)
if (!applyEnabledRef.current || !openRef.current) {
addToolOutput({
tool: toolName,
toolCallId,
state: "output-error",
errorText: "Cancelled — assistant was stopped or closed",
})
return
}
if (toolName === "inspect_form") {
addToolOutput({
tool: toolName,
toolCallId,
output: executeInspectForm(input),
})
return
}
const result = tryApplyToolCall(toolName, input, { isPro: isProRef.current })
if (!result) return
if (result.ok) {
addToolOutput({
tool: toolName,
toolCallId,
output: { ok: true, summary: result.summary },
})
} else {
addToolOutput({
tool: toolName,
toolCallId,
state: "output-error",
errorText: result.error ?? result.summary,
})
}
},
})A few details that mattered in practice:
Idempotency. Streams replay. appliedToolCallsRef is a Set of toolCallIds so we do not add the same email field twice.
Stop is a first-class result. If the user hits Stop or closes the panel, we still addToolOutput. The model loop needs an answer. We just do not mutate. Product-side we refund that credit when the turn did not finish.
The model gets a sentence, not the form. { ok: true, summary: 'Updated field email' } is the tool result. The old design echoed the whole document back as “the response.” That was both UX and token waste.
Errors are data. Plan limits, missing ids, “cannot delete the only step” go back as output-error. The model can shrink the form or ask to upgrade instead of inventing a workaround.
“This field” is real context
Every send attaches editor state, including the current selection:
prepareSendMessagesRequest: async ({ messages, id, body }) => ({
body: {
...body,
id,
messages,
editorContext: getEditorContext(),
},
})That context is a compact snapshot plus:
Selected field: "Email" (email) in step "Contact"or a selected step, or nothing. update_field / delete_field / add_field fall back to whatever is selected:
case "update_field": {
const fieldId =
(input.fieldId as string | undefined) ?? selectedFieldId ?? undefined
if (!fieldId) return { ok: false, summary: "No field specified", error: "missing fieldId" }
const loc = findFieldLocation(config, fieldId, input.stepId as string | undefined)
if (!loc) return { ok: false, summary: "Field not found", error: fieldId }
const updates = deepMergeFieldUpdates(
loc.field,
(input.updates as Partial<FormField>) ?? {},
)
store.updateField(loc.stepId, fieldId, updates)
return { ok: true, summary: `Updated field ${fieldId}` }
}Partial updates deep-merge gridLayout and displayConfig. The model can send { required: true } without wiping the hero’s animatedBackground. That is the opposite of regenerating the document.
inspect_form is scoped to form | step | field, and it reads the live store, not the snapshot from POST. Same-turn sequence actually works: add_field then inspect_form then set_conditional_logic on the new id.
What streaming means here
People hear “streaming” and think tokens in a bubble. The useful stream is the tool parts.
The SDK emits input-streaming while arguments are still arriving, then input-available when we can run the tool. The status line is derived from that:
if (part.state === "input-streaming") {
return name === "create_form"
? "Building form structure…"
: `Preparing ${toolDisplayName(name)}…`
}
return `Running ${toolDisplayName(name)}…`So you see “Adding field…” and the canvas updates when that tool succeeds, then “Setting conditional logic…”, then a short final sentence. Intermediate chatter is folded onto the last tool row so the bubble does not flicker. Advice-only turns (no tools) stay as normal text. No fake “Build plan” chrome.
That is also why complex creates are split on purpose. One giant create_form is the old JSON dump wearing a tool name. Several smaller calls is how you watch a form assemble, and how you keep the good parts if a later call fails.
The unglamorous reliability work
Tools did not remove schema pain. They moved it to Zod, which is the right place.
Gemini likes "6" for colSpan and "false" for booleans. z.boolean() treats the string "false" as true. So:
const num = () => z.coerce.number()
const bool = () =>
z.preprocess((val) => {
if (typeof val === "boolean") return val
if (typeof val === "string") {
const s = val.trim().toLowerCase()
if (["true", "1", "yes"].includes(s)) return true
if (["false", "0", "no", ""].includes(s)) return false
}
if (typeof val === "number") return val !== 0
return val
}, z.boolean())It also rejects z.any() and arrays without items. The field schema is explicit keys, not z.record(z.any()). Annoying. Cheaper than another 6KB of “CRITICAL WARNING” in a skill file.
Layout is not the model’s job either. If it omits x/y, a packer fills a 12-column grid left to right from colSpan. Duplicate ids get rewritten. Display fields get defaults merged in. The model can be sloppy about coordinates; the canvas still has to be a valid react-grid-layout.
Context used to “save tokens” by chopping messages to 50 characters, which saved money by destroying the conversation. Now we keep the last 12 messages and summarize the rest. Compact snapshots instead of the full editor config. Still not free on huge forms. Better than reprinting the form as the assistant message.
Why the bill went down
Output tokens were the form. A multi-step intake with heroes and conditionals is a lot of JSON. update_field with { required: true } is not. After the first draft, most turns are tweaks. That is the main win.
Parse failures were a full write-off. Invalid JSON, truncated max_tokens, fences, wrong keys on display components pay once, throw away, pay again. A bad add_field does not delete the form. The model gets an error and continues.
User retries dropped. People were not failing JSON. They were failing intent. “Almost, but make email required” is one small tool instead of another full dump that reshuffles labels you already fixed.
The model is Flash, not a Sonnet JSON printer. This workload is many small structured calls, not long prose. Theme gen is still Claude streaming a JSON object. I know. I have not migrated that yet.
Tool results are tiny. The model does not receive the applied form back. It receives Created form "Event RSVP" with 2 step(s).
The honest tradeoff: a tool loop is extra round trips. Each call goes model → client → model. For “build a contact form,” one create_form can cost about the same as the old dump. The savings show up in the edit loop, in not reprinting, and in not throwing away truncated JSON. The step cap exists so the loop cannot become the new way to light money on fire.
What I would tell myself six months ago
If the artifact is a document the user will keep editing, do not make the model emit the document.
Make the model emit actions. Apply them to real state. Stream the actions, not a preview of the JSON. Point the prompt at a selection. Merge patches. Fail per tool. Let inspect see the live store.
The parser was the product for a while. It was also the bug. Tools did not make the assistant smarter. They made it stop lying about what it was doing. The canvas is the form. The chat is just the log of how we got there.