When integrating an LLM generation step into a drag-and-drop editor, the model's output must be structurally valid, not just semantically correct. A hallucinated CSS class name can break styling, and an invalid prop key can silently render a component invisible. This article shares lessons from building WebDigitize, an AI-powered website generator for Nigerian small businesses using the Puck editor.
The Schema Representation Problem
Puck stores page state as a JSON document where each content entry maps to a registered React component with specific fields. If the LLM emits `headlineText` instead of `headline`, the component renders with `undefined` — no error, no warning. The naive approach of describing components in prose works for simple cases but fails when there are 15 components with 4–8 fields each, as the model drifts toward invented field names.
The solution was to represent component schemas as a compact formal notation in the system prompt. This notation uses pipe-separated enums (e.g., `"star" | "check" | "bolt"`), inline constraints like `max 60 chars`, and explicit notes such as "must match brief — do not invent." The model treats the enums as a closed set, preventing hallucinated values.
System Prompt vs User Message
Placement of the schema matters. In multi-turn conversations where the schema was placed in the user message, the critic pass occasionally invented prop names not in the original schema. Moving the schema to the system prompt — which is treated as persistent context across all turns — dropped schema violations to nearly zero. The model correctly understands that system prompt constraints are inviolable, while user message constraints can flex.
Validating Output at the Boundary
Even with strict prompting, a validation step is essential before storing generated content. The validator checks each block's type and prop keys against a whitelist. When validation fails, the raw output is logged and generation is retried with an amended prompt that includes the specific error. In practice, retries trigger for only about 2% of generations.
The Array Prop Problem
Arrays are the most reliably broken output format. The model may produce valid arrays initially, but on a retry or critic pass, it can wrap items in an object (e.g., `{"items": [...], "count": 2}`) or rename keys inside array items. The fix is to extend the validator to recurse into array items and check each key against a set of allowed keys.
Rich Text: The Unexpected Footgun
One block accepted a `body` field that was supposed to be a Puck rich text value — a Lexical editor serialized state, not a plain string. Exposing this to the LLM produced beautiful-looking text that was a plain string, causing a crash when the user tried to edit the block. The fix was to never ask an LLM to produce rich text editor state. Instead, the block accepts a `bodyText: string` prop, and a `useEffect` converts it to a Lexical initial state on first render.
Structuring the Puck Config for AI-Friendliness
After building this, the Puck config was restructured with AI consumers in mind. Consistent naming conventions were used: all text fields are string types named with a noun (e.g., `headline`, `subheadline`, `address`), and all boolean toggles start with `show` (e.g., `showCta`, `showImage`). The model learned these conventions quickly and stopped inventing alternatives. Array items were kept flat, and default prop values were provided in components to handle missing fields gracefully.