{"slug": "lessons-from-embedding-an-llm-inside-a-drag-and-drop-editor", "title": "Lessons from Embedding an LLM Inside a Drag-and-Drop Editor", "summary": "A developer building WebDigitize, an AI-powered website generator for Nigerian small businesses, found that embedding an LLM inside a drag-and-drop editor requires the model's output to be structurally valid, not just semantically good. By representing component schemas as a compact formal notation in the system prompt, they achieved reliable generation of valid JSON for the Puck editor, preventing hallucinated field names and broken components.", "body_md": "When you integrate an AI generation step into a drag-and-drop editor, you face a problem that is rarely discussed in LLM tutorials: **the model's output must be structurally valid, not just semantically good**. A hallucinated CSS class name is a style regression. A hallucinated prop key in an editor schema is a broken component.\n\nThis is a writeup of what I learned building WebDigitize, an AI-powered website generator and editor for Nigerian small businesses, using [Puck](https://github.com/measuredco/puck) as the editor layer.\n\nPuck stores page state as a JSON document:\n\n```\n{\n  \"root\": { \"props\": {} },\n  \"content\": [\n    {\n      \"type\": \"HeroSection\",\n      \"props\": {\n        \"headline\": \"Fresh Bread, Delivered Daily in Lagos\"\n      }\n    }\n  ],\n  \"zones\": {}\n}\n```\n\nEach entry in `content`\n\nmaps to a React component registered in Puck's config:\n\n``` js\nconst config: Config = {\n  components: {\n    HeroSection: {\n      fields: {\n        headline: { type: \"text\" },\n        subheadline: { type: \"text\" },\n        ctaLabel: { type: \"text\" },\n        ctaHref: { type: \"text\" },\n      },\n      render: ({ headline, subheadline, ctaLabel, ctaHref }) => (\n        <section>\n          <h1>{headline}</h1>\n          <p>{subheadline}</p>\n          <a href={ctaHref}>{ctaLabel}</a>\n        </section>\n      ),\n    },\n  },\n};\n```\n\nThe critical insight: **Puck only renders props defined in fields**. If the AI emits\n\n`headlineText`\n\ninstead of `headline`\n\n, the component renders silently with `undefined`\n\n. No error. No warning. An invisible field.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:\n\n\"The HeroSection component takes a headline, a subheadline, a CTA label, and a CTA href.\"\n\nThis 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.\n\nThe approach that worked: **represent your component schemas as a compact formal notation in the system prompt**.\n\n```\n=== PUCK BLOCK SCHEMAS (STRICT) ===\nOnly use the exact prop keys listed. No extra keys. No variations.\n\nHeroSection:\n  headline: string           (max 60 chars — punchy, specific to this business)\n  subheadline: string        (max 120 chars)\n  ctaLabel: string           (max 30 chars)\n  ctaHref: string            (WhatsApp link preferred; format: https://wa.me/{number})\n\nServicesSection:\n  title: string\n  services: Array<{\n    icon: \"star\" | \"check\" | \"bolt\" | \"heart\" | \"shield\" | \"phone\"\n    title: string            (max 40 chars)\n    description: string      (max 100 chars)\n  }>                         (2–4 items, no more)\n\nContactSection:\n  address: string\n  phone: string              (must match brief — do not invent)\n  hours: string              (format: \"Mon–Sat, 8am–6pm\")\n  mapEmbedQuery: string      (Google Maps query string, e.g. \"Chicken Republic Ikeja Lagos\")\n```\n\nKey decisions in this notation:\n\n`\"star\" | \"check\" | ...`\n\n) are extremely effective — the model treats them as a closed set.`max 60 chars`\n\n) prevent verbose fields that break layouts.`must match brief — do not invent`\n\n) address the most common semantic failures.I spent some time debugging why the critic pass kept ignoring my schema constraints. The answer was placement.\n\n**Schema in the user message:**\n\n```\n[System]: You are a web design assistant. Output only valid JSON.\n[User]:   Generate a home page for Mama Titi's Kitchen.\n          Here are the allowed block schemas: ...\n          Business details: ...\n```\n\nIn 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.\n\n**Schema in the system prompt:**\n\n```\n[System]: You are a web design assistant. Output only valid JSON.\n          === PUCK BLOCK SCHEMAS (STRICT) ===\n          HeroSection: ...\n          [all schemas]\n[User]:   Generate a home page for Mama Titi's Kitchen.\n          Business details: ...\n```\n\nThe 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.\n\nEven with strict prompting, I added a validation step before storing any generated content:\n\n```\nALLOWED_BLOCKS = {\n    \"HeroSection\": {\"headline\", \"subheadline\", \"ctaLabel\", \"ctaHref\"},\n    \"ServicesSection\": {\"title\", \"services\"},\n    \"ContactSection\": {\"address\", \"phone\", \"hours\", \"mapEmbedQuery\"},\n    # ...\n}\n\ndef validate_puck_content(content: dict) -> list[str]:\n    errors = []\n    for block in content.get(\"content\", []):\n        block_type = block.get(\"type\")\n        if block_type not in ALLOWED_BLOCKS:\n            errors.append(f\"Unknown block type: {block_type}\")\n            continue\n        allowed_keys = ALLOWED_BLOCKS[block_type]\n        for key in block.get(\"props\", {}):\n            if key not in allowed_keys:\n                errors.append(f\"{block_type}: unknown prop '{key}'\")\n    return errors\n```\n\nWhen validation fails, I log the raw output and retry generation with an amended prompt that includes the specific error:\n\n```\nif errors:\n    additional_constraint = f\"CRITICAL: Previous attempt used invalid props: {errors}. Fix these.\"\n    return await generate_with_retry(brief, page_type, extra_constraint=additional_constraint)\n```\n\nIn practice, the retry only triggers for about 2% of generations.\n\nArrays are the most reliably broken output format. Consider `services: Array<{icon, title, description}>`\n\n. The model produces:\n\n```\n\"services\": [\n  {\"icon\": \"star\", \"title\": \"Fresh Bread\", \"description\": \"Baked daily\"},\n  {\"icon\": \"check\", \"title\": \"Fast Delivery\", \"description\": \"Within 2 hours\"}\n]\n```\n\nThis is valid. But then on a retry or critic pass, I have seen:\n\n```\n\"services\": {\n  \"items\": [...],\n  \"count\": 2\n}\n```\n\nOr:\n\n```\n\"services\": [\n  {\"icon_name\": \"star\", \"service_title\": \"Fresh Bread\", \"text\": \"Baked daily\"}\n]\n```\n\nThe second failure (renamed keys inside array items) is invisible without deep validation. I extended the validator to recurse into array items:\n\n``` python\nSERVICES_ITEM_KEYS = {\"icon\", \"title\", \"description\"}\n\ndef validate_services(items):\n    for i, item in enumerate(items):\n        for key in item:\n            if key not in SERVICES_ITEM_KEYS:\n                return [f\"services[{i}]: unknown key '{key}'\"]\n    return []\n```\n\nOne of the blocks — a `ContentBlock`\n\nfor long-form text — accepted a `body`\n\nfield that was supposed to be a Puck rich text value (a Lexical editor serialised state, not a plain string).\n\nI 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.\n\nThe fix: **never ask an LLM to produce rich text editor state**. Instead, the `ContentBlock`\n\naccepts a `bodyText: string`\n\nprop. A `useEffect`\n\nin the component converts it to a Lexical initial state on first render. The AI never touches the editor's internal format.\n\nAfter building this, I restructured the Puck config with AI consumers in mind:\n\n**Use consistent naming conventions.** All text fields are `string`\n\ntypes named with a noun (`headline`\n\n, `subheadline`\n\n, `address`\n\n). All boolean toggles start with `show`\n\n(`showCta`\n\n, `showImage`\n\n). The model learned these conventions quickly and stopped inventing alternatives.\n\n**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.\n\n**Provide default prop values in the component.** If `ctaHref`\n\nis missing, the component should gracefully render without a CTA link rather than throwing. This makes partial AI output survivable.\n\n**Separate \"AI-generated\" fields from \"user-configured\" fields.** Fields like `backgroundImage`\n\nand `colorScheme`\n\nare 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.\n\nPuck renders in two modes in WebDigitize:\n\n`<Puck config={config} data={data} />`\n\n) — used in the dashboard, full interactive editing`<Render config={config} data={data} />`\n\n) — used on public-facing site pages, no editing chromeBoth modes use the same `config`\n\nand `data`\n\n. 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.\n\nThis 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.", "url": "https://wpnews.pro/news/lessons-from-embedding-an-llm-inside-a-drag-and-drop-editor", "canonical_source": "https://dev.to/innocodes/lessons-from-embedding-an-llm-inside-a-drag-and-drop-editor-apm", "published_at": "2026-07-15 14:00:00+00:00", "updated_at": "2026-07-15 14:03:26.959654+00:00", "lang": "en", "topics": ["large-language-models", "generative-ai", "ai-products", "developer-tools"], "entities": ["WebDigitize", "Puck", "Mama Titi's Kitchen"], "alternates": {"html": "https://wpnews.pro/news/lessons-from-embedding-an-llm-inside-a-drag-and-drop-editor", "markdown": "https://wpnews.pro/news/lessons-from-embedding-an-llm-inside-a-drag-and-drop-editor.md", "text": "https://wpnews.pro/news/lessons-from-embedding-an-llm-inside-a-drag-and-drop-editor.txt", "jsonld": "https://wpnews.pro/news/lessons-from-embedding-an-llm-inside-a-drag-and-drop-editor.jsonld"}}