- custom Figma MCP
- design tokens from the source
- measured from the live DOM
- phased, resumable builds
The problem
I worked on an AI builder that turns a store owner's Figma design into working storefront code. In practice the result drifted from the design, often badly. When I dug in, none of the reasons were "the model isn't good enough":
- The design workflow was optional. The agent could skip the Figma instructions entirely.
- The quality bar was vague. The agent was told the page should "look production-quality".
- The convergence loop was prompt-ware. The instructions told the agent to iterate until the build matched, using a diff tool that did not exist yet.
- The agent never saw the design. It got a JSON summary, so it could not close the loop even in principle.
An instruction to converge, pointing at a missing tool, judged by a model that cannot see the target. The first job was to make the workflow real and required, not to write a better prompt.
A Figma MCP built for the job
I built a custom Figma MCP server on the Figma REST API instead of using an off-the-shelf one. It gives the agent design-to-code data, design token extraction, asset export and on-demand inspection of any node.
Auth is a personal access token that the store owner pastes into settings. It is stored encrypted and resolved per request by the server, so it never lives in an image or a shared secret.
Reading the design correctly took more care than I expected. Two examples:
- Per-side borders. A design had a single bottom border, but the summary only read one uniform stroke weight, so the agent drew a full box. I read per-side weights from both forms the API can return them in. Then I fixed the instruction file too, because it told the model to map a stroke straight to
border. Fixing the data without fixing the instruction that misread it would have changed nothing. - Mixed-style text. A heading with one emphasised word keeps its styling in per-run overrides. Reading only the node-level style flattened it into one uniform run.
Measure, don't eyeball
The original loop took a screenshot, diffed it against the design and asked a vision model what was wrong. Every step was an estimate. In one session the model compared a high-density screenshot to a design in CSS pixels and told the agent that a correct component was "about 2× too big". The agent believed it, more than once.
The fix came from one observation: the browser is already attached to the page. getBoundingClientRect is right there.
// read what the browser actually laid out
const box = el.getBoundingClientRect()
const style = getComputedStyle(el)
report({ width: box.width, height: box.height, fontSize: style.fontSize })So the loop now iterates on measured element metrics from the live DOM and compares them to the design's numbers. A vision model can still help with things that are hard to measure. It no longer decides whether a width is right.
design tokens ──▶ build step ──▶ live page ──▶ measure DOM ──▶ compare
▲ │
└────────── fix the largest gap ◀──────────┘Rules that keep the loop honest
Measuring was not enough on its own. The loop needed rules:
- A ratchet. One session accepted edits that made the score worse, then reported the best score it had ever seen, not the score of the code it committed. Now a worse pass is rejected, and every score is bound to the exact tree that produced it.
- A gate that can pass. The pixel half and the structural half of the fidelity check disagreed about transparent pixels: one treated them as white, the other as black. The target was unreachable for any design with a transparent background.
- A preflight check. One run spent its whole budget converging against a font that had never loaded. Now the build refuses to start until the environment can actually render the design.
- Shipped is not blocked. Components that matched on every measured dimension were marked blocked by an uncalibrated text threshold, and that stalled the rest of the run. They now finish as done, with the leftover difference recorded.
Phases, not one giant turn
A large design section holding every variant of a component does not fit in one turn. One run tried, hit its pass limit on the first component, and finished with most of its plan untouched. Builds now go through import, understand, split into phases, then build one thing at a time, resuming across turns. That uses the same idea as durable agent turns.
What I'd do differently
- Calibrate thresholds before trusting them. I deliberately kept the text-similarity threshold out of the "shipped is not blocked" fix because it needed its own evidence. That calibration should have come before the gate existed.
- Keep one source for every script. The renderer scripts once lived in two places with different release cycles, and the builder broke when they drifted apart.
- Write the honesty rules first. The ratchet and the score-to-tree binding turned into a wider contract for every verification tool. That story is in honest verification and LLM judges.
Takeaway
Don't ask a model whether a page looks right when the browser can tell you exactly what it rendered. Measure what can be measured, keep the model for what can't, and make the loop prove that every pass made things better.