
The hard part of building an AI form builder isn't the AI
Tell an LLM "build me a contact form." You'd expect the hardest problem to be language understanding. It isn't.
The LLM is fine. It reads the intent, picks reasonable fields, even names them sensibly. The hard part is everything that happens after the model decides what it wants to do. Storing it. Validating it. Letting a human edit it in the browser while the model is still mid-conversation about it. None of that is AI work. It's the boring, unglamorous middle of the stack, and it's where almost all of the engineering at PropelForm has gone.
This post is about why.
The naive plan, and why it falls apart fast
When we started, the obvious design was: ask the model for a JSON blob describing the form, parse it, save it. A round trip per change. Easy. The model is good at JSON now, right?
Two things break that design within a week.
The first is schema evolution. A form definition isn't a flat list of fields. It's fields with validation, layout
that arranges fields into pages and sections, rules that fire actions based on conditions, settings, theme. Twenty-eight
field types. A conditional logic engine with and, or, not, and switch. The schema keeps growing because the
product keeps growing. Every time we add something, the prompt has to relearn the entire shape or it starts emitting
malformed output. You spend more time tuning the prompt than building the feature.
The second is validation. A JSON Schema can catch structural problems: wrong type, missing required property, illegal enum value. It cannot catch the things that actually go wrong in a form:
- A rule references a field that doesn't exist.
- A
set_valueaction tries to assign a string to a numeric field. - A layout row says it has three field IDs and two column widths.
- A
switchcondition is nested inside anand, which is illegal because of how the rules engine evaluates.
The model will produce all of these. JSON Schema will happily wave them through. And then your form editor crashes on first render.
Tool calls beat structured output
The shift that made things click: stop asking the model to emit a form. Let it call functions on a form.
PropelForm's agent exposes four tools:
create_form— creates the initial definition.update_form— applies one or more patch operations to the current definition. Add a field, remove a field, change a rule, update the layout.get_current_form— reads the current state.validate_form— runs the full validation pipeline and returns errors.
The model never sees the entire schema at once. It edits incrementally. Each edit goes through the same validation the
UI uses. Errors come back to the model as part of the conversation: "your patch failed because the rule references field
email which doesn't exist anymore." The model fixes it on the next turn. If it can't, it tells the user what's wrong
instead of producing broken output.
This is a much better fit for how LLMs actually work. They're great at small, scoped reasoning steps. They're bad at " emit a 4,000-token JSON document that satisfies sixteen cross-cutting invariants on the first try."
It's also a better fit for the product. The user is going to edit the form in the UI anyway. The agent calling the same tools the UI calls means there's exactly one code path that mutates a form. No drift.
Validation is a pipeline, not a function
The form package validates in two phases.
Phase 1: JSON Schema. Compiled once at init from an embedded schema file. Catches the cheap, structural stuff. Wrong types, missing required fields, unknown enum values. This is the easy 80%.
Phase 2: Semantic validation. Sixteen checks that JSON Schema cannot express, including:
checkDuplicateFieldIDs — no two fields share an ID, even nested
checkLayoutFieldRefs — every layout field_id references a real field
checkLayoutCompleteness — every top-level field appears in the layout once
checkColumnWidths — column_widths length matches field_ids length
checkRuleFieldRefs — rule conditions and actions reference real fields
checkDefaultValues — default values are type-compatible
checkConditionOperatorCompat — `gt` only on numeric/date, `contains` only on text
checkSwitchNotNested — `switch` is top-level only
checkSetValueTypeCompat — set_value matches the target field's type
... (and seven more)
Each check is a single function over a parsed Definition. They're independent, easy to test, easy to add. When the
agent produces a definition, both phases run. When the user saves from the UI, both phases run. There's no "trusted"
path that skips validation, because every path eventually wrote a bug into prod once.
If you're building anything similar, my advice is: resist the urge to spread validation across the call site. The natural temptation is to validate in the API handler, validate in the patch function, validate before save. You end up with three slightly different ideas of what "valid" means. Put it in one place, run it everywhere.
The user is also editing the form
The funniest bug we hit early: the agent and the user can both edit the form at the same time.
The user opens a form, asks the agent to add a phone field. The agent does it. The user manually drags the phone field to a different page, deletes a paragraph block, renames the form, then asks the agent for one more change. The agent's last memory of the form is now several revisions stale. It proposes a patch that references a block the user deleted, against an old version of the definition. The patch fails. The user sees nothing helpful.
The fix is small but easy to forget. The conversation tracks last_form_revision. Before the agent runs its next turn,
we check whether the live form's revision is newer than what the conversation remembers. If it is, we inject a synthetic
tool call into the history:
model: get_current_form()
tool: { definition: <current form>,
_note: "The form was edited manually since your last response.
This is the current state." }
Then the user's message runs. The model sees the current state in its own history, as if it had just asked for it. No retraining the prompt, no special "user changed things" code path in the agent loop. Just a synthetic exchange that gets the model back in sync.
Optimistic concurrency does the rest. Every save carries an expected version. If the version doesn't match, the save
returns ErrVersionConflict, the agent gets it back as a retryable tool error, and it tries again with fresh state.
The LLM is the easy part
When someone asks "what's the AI part of PropelForm?" the honest answer is: a prompt template, four tool declarations,
and a dispatchTool switch statement. Maybe 800 lines including streaming. That's it.
The other 90% is Go. Validation. Patch operations. Immutable deep copies so a failed save can't half-apply. A state machine for the form lifecycle. Version vectors for concurrency. SSE plumbing so the editor UI updates the moment a tool call modifies the form. None of it is glamorous. All of it is what determines whether the product actually works.
LLMs got good fast. Schema design didn't. The interesting engineering at PropelForm, and I suspect at most AI products that have to produce structured artifacts, is mostly in the layer underneath the model.
This blog is going to be more of that. Architecture decisions, tradeoffs we made, mistakes we'd undo if we could. If you're building something similar, the prompt is rarely where you'll get stuck. The boring middle is where the work lives.
Raul Pleitez
Building reliable software at mercably. Passionate about Go, distributed systems, and developer tools.
Learn more about the team →Enjoyed this post?
Stay updated on mercably products and insights.