Lessons from Embedding an LLM Inside a Drag-and-Drop Editor
What Puck Expects
Puck stores page state as a JSON document:
{
"root": { "props": {} },
"content": [
{
"type": "HeroSection",
"props": { "headline": "Fresh Bread, Delivered Daily in Lagos" }
}
],
"zones": {}
}
Each entry in content maps to a React component registered in Puck's config:
const config: Config = {
components: {
HeroSection: {
fields: {
headline: { type: "text" },
subheadline: { type: "text" },
ctaLabel: { type: "text" },
ctaHref: { type: "text" },
},
render: ({ headline, subheadline, ctaLabel, ctaHref }) => (
<section>
<h1>{headline}</h1>
<p>{subheadline}</p>
<a href={ctaHref}>{ctaLabel}</a>
</section>
),
},
},
};
The critical insight: Puck only renders props defined in fields. If the AI emits headlineText instead of headline, the component renders silently with undefined. No error. No warning. An invisible field.
The Schema Representation Problem
Your LLM knows nothing about your Puck config. You need to tell it what is valid. The naive approach is to describe your components in prose: "The HeroSection component takes a headline, a subheadline, a CTA label, and a CTA href." This works for simple cases and fails for complex ones. If you have 15 components, each with 4β8 fields, prose descriptions become ambiguous, and the model drifts toward invented field names that sound plausible.
The approach that worked: represent your component schemas as a compact formal notation in the system prompt.
=== PUCK BLOCK SCHEMAS (STRICT) ===
Only use the exact prop keys listed. No extra keys. No variations.
HeroSection:
headline: string (max 60 chars - punchy, specific to this business)
subheadline: string (max 120 chars)
ctaLabel: string (max 30 chars)
ctaHref: string (WhatsApp link preferred; format: https://wa.me/{number})
ServicesSection:
title: string
services: Array<{
icon: "star" | "check" | "bolt" | "heart" | "shield" | "phone"
title: string (max 40 chars)
description: string (max 100 chars)
}> (2β4 items, no more)
ContactSection:
address: string
phone: string (must match brief - do not invent)
hours: string (format: "MonβSat, 8amβ6pm")
mapEmbedQuery: string (Google Maps query string, e.g. "Chicken Republic Ikeja Lagos")
Key decisions in this notation:
- Pipe-separated enums (
"star" | "check" | ...) are extremely effective - the model treats them as a closed set. - Inline constraints (
max 60 chars) prevent verbose fields that break layouts. - Explicit notes (
must match brief - do not invent) address the most common semantic failures.
System Prompt vs User Message
I spent some time debugging why the critic pass kept ignoring my schema constraints. The answer was placement.
Schema in the user message:
[System]: You are a web design assistant. Output only valid JSON.
[User]: Generate a home page for Mama Titi's Kitchen. Here are the allowed block schemas: ... Business details: ...
In multi-turn conversations (generation β critic β refinement), the schema in the user message is treated as part of the task context that can be "overridden" by subsequent turns. The critic saw new information in its turn and occasionally invented prop names that were not in the original schema.
Schema in the system prompt:
[System]: You are a web design assistant. Output only valid JSON.
=== PUCK BLOCK SCHEMAS (STRICT) ===
HeroSection: ... [all schemas]
[User]: Generate a home page for Mama Titi's Kitchen. Business details: ...
The system prompt is treated as a persistent context across all turns. Schema violations dropped to nearly zero. The model seems to correctly understand that system prompt constraints are inviolable while user message constraints can flex.
Validating Output at the Boundary
Even with strict prompting, I added a validation step before storing any generated content:
ALLOWED_BLOCKS = {
"HeroSection": {"headline", "subheadline", "ctaLabel", "ctaHref"},
"ServicesSection": {"title", "services"},
"ContactSection": {"address", "phone", "hours", "mapEmbedQuery"},
# ...
}
def validate_puck_content(content: dict) -> list[str]:
errors = []
for block in content.get("content", []):
block_type = block.get("type")
if block_type not in ALLOWED_BLOCKS:
errors.append(f"Unknown block type: {block_type}")
continue
allowed_keys = ALLOWED_BLOCKS[block_type]
for key in block.get("props", {}):
if key not in allowed_keys:
errors.append(f"{block_type}: unknown prop '{key}'")
return errors
When validation fails, I log the raw output and retry generation with an amended prompt that includes the specific error:
if errors:
additional_constraint = f"CRITICAL: Previous attempt used invalid props: {errors}. Fix these."
return await generate_with_retry(brief, page_type, extra_constraint=additional_constraint)
In practice, the retry only triggers for about 2% of generations.
The Array Prop Problem
Arrays are the most reliably broken output format. Consider services: Array<{icon, title, description}>. The model produces:
"services": [
{ "icon": "star", "title": "Fresh Bread", "description": "Baked daily" },
{ "icon": "check", "title": "Fast Delivery", "description": "Within 2 hours" }
]
This is valid. But then on a retry or critic pass, I have seen:
"services": { "items": [...], "count": 2 }
Or:
"services": [
{ "icon_name": "star", "service_title": "Fresh Bread", "text": "Baked daily" }
]
The second failure (renamed keys inside array items) is invisible without deep validation. I extended the validator to recurse into array items:
SERVICES_ITEM_KEYS = {"icon", "title", "description"}
def validate_services(items):
for i, item in enumerate(items):
for key in item:
if key not in SERVICES_ITEM_KEYS:
return [f"services[{i}]: unknown key '{key}'"]
return []
Rich Text: The Unexpected Footgun
One of the blocks - a ContentBlock for long-form text - accepted a body field that was supposed to be a Puck rich text value (a Lexical editor serialised state, not a plain string). I made the mistake of exposing this to the AI. The model produced beautiful-looking text, but it was a plain string. The Lexical editor expected an object. The result was a crash on the editor load that only appeared when the user tried to edit the block.
The fix: never ask an LLM to produce rich text editor state. Instead, the ContentBlock accepts a bodyText: string prop. A useEffect in the component converts it to a Lexical initial state on first render. The AI never touches the editor's internal format.
Structuring the Puck Config for AI-Friendliness
After building this, I restructured the Puck config with AI consumers in mind:
- Use consistent naming conventions. All text fields are
stringtypes named with a noun (headline,subheadline,address). All boolean toggles start withshow(showCta,showImage). The model learned these conventions quickly and stopped inventing alternatives. - Keep array items flat. Nested object arrays are reliable. Arrays of arrays are not. If you need complex nested structures, flatten them at the schema layer.
- Provide default prop values in the component. If
ctaHrefis missing, the component should gracefully render without a CTA link rather than throwing. This makes partial AI output survivable. - Separate "AI-generated" fields from "user-configured" fields. Fields like
backgroundImageandcolorSchemeare set by the user through the visual editor, never by the AI. Keeping these out of the generation schema prevents the model from guessing colours or image URLs, which it consistently does poorly.
The Rendering Architecture
Puck renders in two modes in WebDigitize:
- Editor mode (
<Puck config={config} data={data} />) - used in the dashboard, full interactive editing - Render mode (
<Render config={config} data={data} />) - used on public-facing site pages, no editing chrome
Both modes use the same config and data. When the AI generates content and stores it, it is immediately renderable on the public site. When the user opens the editor, they see the AI output in the Puck canvas, ready to drag, drop, and edit. This single-schema-serves-both-modes design is the most important architectural decision. It means the AI, the editor, and the public renderer are all working with the same contract.
Takeaways
- Formal schema notation in the system prompt outperforms prose descriptions for structured output tasks.
- System prompt beats user message for persistent constraints across multi-turn generation pipelines.
- Validate at the boundary, not at render time. Silent failures are the enemy.
- Never send rich text editor state formats to an LLM. Accept plain strings and convert at the component layer.
- Design your block schemas with AI consumers in mind from the start - consistent conventions, flat structures, clear defaults.
Comments
No comments yet. Start the discussion.