Back to blog

Building an agentic pipeline that turns Figma designs into email-safe HTML

September 11, 2026 4 min read
aitypescriptfigma

Email HTML is not web HTML

A modern web page can use flexbox, grid, CSS custom properties, and a <div> for basically everything. None of that is safe in an email client. Outlook on Windows renders HTML using Microsoft Word's layout engine, not a browser engine — no flexbox, no grid, unreliable position, and it needs <table>-based layouts with inline styles, because <style> blocks get stripped by several major clients entirely. Getting this wrong doesn't mean a page that looks slightly off; it means a layout that collapses into a single column of stacked, unstyled content in a large fraction of real inboxes.

That's the actual problem figma-to-html solves: take a Figma design (a node tree plus a rendered screenshot) and produce table-based, inline-styled, MSO-compatible HTML that survives that gauntlet — not just HTML that looks right in Chrome.

Four stages, not one prompt

The instinct with an LLM is to describe the whole task in one prompt and hope for a good single-shot result. The orchestrator here doesn't do that — it runs four distinct steps, each with a narrow job:

const steps = [
  { id: 'analyze',  label: 'Analyzing design...' },
  { id: 'generate', label: 'Generating HTML...' },
  { id: 'inline',   label: 'Inlining styles...' },
  { id: 'validate',  label: 'Validating output...' },
]

Analyze is a multimodal call — it sends both the Figma node tree JSON and the actual rendered screenshot to Gemini, with a strict JSON schema for the response, and gets back a structured intermediate representation (IR) of the design: sections, children, types, styles. Passing the screenshot alongside the raw node data matters because Figma's node tree alone can miss visual details that only exist in how things actually render — spacing quirks, overlapping elements, effects that don't show up cleanly in the JSON.

Generate takes that IR and produces the actual HTML. Inline is the one non-AI step — a local pass that pushes CSS into style="" attributes on each element, since that's the one part of the "email-safe" requirement that's mechanical, not a design decision, and doesn't need a model call. Validate reviews the inlined HTML and fixes anything wrong with it.

Chaining validation onto generation's own context

The interesting part isn't that there's a validation step — it's how it's connected to the step before it. Instead of validation seeing only the raw HTML as a cold, out-of-context string, it's chained to the exact same model conversation that generated it:

const interaction = await ai.interactions.create({
  model: MODEL,
  system_instruction: VALIDATE_PROMPT,
  input: `Here is the inlined HTML to review and fix:\n\n${inlinedHTML}`,
  previous_interaction_id: generatorInteractionId,
})

previous_interaction_id means the validator isn't reviewing an anonymous blob of markup — it has the entire generation conversation as context, including whatever the generator was actually trying to build and why it made the choices it made. That's a meaningfully different task than "here's some email HTML, find bugs in it" from scratch; it's closer to a developer reviewing their own code five minutes after writing it, rather than a stranger seeing it cold.

What the intermediate representation buys

Analyzing to a structured IR instead of asking the model to jump straight from screenshot to HTML gives the pipeline a place to intervene mechanically between steps. Images are a concrete example: the IR's image nodes get real image data swapped for lightweight placeholders before generation —

for (const section of ir.sections) {
  for (const child of section.children) {
    if (child.type === 'image') {
      const placeholder = `__IMAGE_${imageIndex}__`
      child.src = placeholder
      imageReferences.push(placeholder)
      imageIndex++
    }
  }
}

— so the generation and validation model calls never have to carry full base64 image payloads through their context, and the real image data gets substituted back into the final HTML afterward with a plain string replace. That's not something you can do cleanly if the model is asked to go straight from an image to final markup with no structured stop in between.

What's still rough

This pipeline is genuinely a work in progress, and I'd rather say that directly than pretend otherwise — the orchestrator's own comments flag at least one open issue in the generation step that hasn't been root-caused yet. Multi-step agentic pipelines like this one tend to have exactly this shape of problem: each individual stage is easy to reason about and test in isolation, but a bug can still surface only when real data flows through all four stages together, and reproducing it means re-running the whole chain, not just the step that appears to be failing.