Engineering · Design Systems

Building an Agentic Design System: Tokens, MCP, and Code Connect

The reference architecture is easy to draw and hard to live in. Here’s the full pipeline, and the engineering decisions that make it hold up in production.

Josef Richter

The era of the static design system is over. We are no longer designing only for human engineers; we are designing for large language models and autonomous agents. And the defining challenge of that shift is contract disambiguation: ensuring an AI agent can read, reason over, and implement a component without hallucinating properties or guessing at visual states.

This guide builds an agent-ready design system from the ground up, deliberately bypassing framework-locked, React-centric pipelines in favor of a language-agnostic foundation. A single Button component carries us through the whole pipeline: from W3C DTCG tokens to Style Dictionary compilation, to intent metadata exposed over the Model Context Protocol (MCP), and finally back to the design canvas via Code Connect.

Theory meets practice. Most design-system write-ups stop at the reference architecture: the tidy diagram where everything is already wired. This one goes a step further. After each theoretical step you’ll find an In Practice note grounded in real Elixir/Phoenix engineering: the decisions, tradeoffs, and traps that only surface once the architecture meets a production codebase. The interesting lessons live in the details the diagram leaves out.

State of the art (July 2026)

Before we build, a snapshot of what’s solid versus what’s still moving, as of July 2026:

One through-line ties the whole pipeline together, and it’s worth stating up front: keep DTCG canonical, and treat everything downstream (the generated CSS, the agent metadata) as derived from it. Every step below is an argument for that single rule.

The Agentic Pipeline Architecture

Before diving into the code, let’s look at how data flows through a modern system. The goal is strict state isolation: visual intent is separated from platform execution.

agentic pipeline architecture
DESIGN LAYER (VISUAL INTENT) Figma Canvas Design Variables SOURCE OF TRUTH (AGNOSTIC DATA) W3C DTCG JSON Tokens Intent-Based UI Metadata / MCP COMPILATION (STYLE DICTIONARY V4) Build Pipeline PRODUCTION LAYER (EXECUTION) Tailwind v4 / CSS Swift / iOS Compose / Android VALIDATION LAYER Storybook / Isolated UI Webhook / CI Code Connect Mapping

Step 1: The 3-Tier Token Architecture (W3C DTCG Standard)

Token architecture requires strict state isolation. If you have been building design systems for a while, the 3-tier architecture (Primitive → Semantic → Component) is not entirely new to you.

In the past, we attempted to achieve this isolation using workarounds. In Figma, we mapped Local Styles to component properties. In code, early utility frameworks (like Tailwind v2/v3) encouraged us to define primitives (colors.blue.500) and map them to semantics inside a massive tailwind.config.js theme block.

What has changed is standardization. The W3C Design Tokens Community Group (DTCG) specification provides a universal JSON schema. Instead of relying on framework-specific JavaScript configs or proprietary plugin data, this JSON acts as the pure, language-agnostic source of truth.

And as noted above, this is no longer a moving target. In v2025.10 (October 28, 2025) the DTCG published its first stable module of the format, backed by 24+ organizations including Adobe, Google, Meta, and Figma (designtokens.org). Adoption followed: design tokens reached roughly 84% of teams in 2026 (zeroheight survey), up from 56% a year earlier. The 3-tier Primitive → Semantic → Component model, with a $description role annotating each token, is now the industry consensus rather than one house style among many.

tokens/button.json
{
  "primitive": {
    "color": {
      "blue-500": { "$value": "#1A73E8", "$type": "color" },
      "blue-700": { "$value": "#1557B0", "$type": "color" },
      "white": { "$value": "#FFFFFF", "$type": "color" }
    }
  },
  "semantic": {
    "color": {
      "action-primary-default": {
        "$value": "{primitive.color.blue-500}",
        "$type": "color"
      },
      "action-primary-hover": {
        "$value": "{primitive.color.blue-700}",
        "$type": "color"
      }
    }
  },
  "component": {
    "button": {
      "primary": {
        "background": {
          "default": {
            "$value": "{semantic.color.action-primary-default}",
            "$type": "color"
          },
          "hover": {
            "$value": "{semantic.color.action-primary-hover}",
            "$type": "color"
          }
        }
      }
    }
  }
}
token mapping diagram
Primitive #1A73E8 Semantic action-primary-default Component button-primary-bg-default Inherits to Inherits to

🛠 In practice: encoding the semantic tier

A real tokens.json often starts life as clean, genuine DTCG (correct $schema, $type, $value) but primitive-only. The semantic meaning lives in prose rather than in the token graph:

tokens.json
{
  "$schema": "https://tr.designtokens.org/format/",
  "color": {
    "$type": "color",
    "blue": {
      "400": { "$value": "#0086e6", "$description": "PRIMARY action" },
      "600": { "$value": "#00467f", "$description": "primary hover" }
    }
  }
}

Notice what’s happening: "PRIMARY action" is a $description string, not a semantic token. There is no action-primary-default → {color.blue.400} reference and no component tier at all. The 3-tier model above collapses to a single tier: the intent exists only as a human-readable comment a compiler can’t follow. This is the single most common real-world shortcut: teams author beautiful primitives and describe the semantics instead of encoding them.

“Aren’t semantic tokens just CSS classes again?”

If you’ve internalized Tailwind, that semantic tier should make you flinch. Tailwind’s founding argument was that semantic class names (.button, .card, .article-header) are a leaky, ever-multiplying abstraction, and that you should stop naming things in CSS and just compose utilities in the markup. So doesn’t a --color-action-primary token quietly smuggle that rejected layer back in?

No, because there were always two semantic layers fused into that old .button {} class, and Tailwind split them apart rather than deleting them.

So the honest framing: Tailwind didn’t remove the semantic layer, it split it in two, component intent to the template, value intent to the theme token, so the two can vary independently. That is the thing a .button { color: blue } class could never give you: restyle every button by editing one function and rebrand every primary by re-pointing one token, without the two changes ever colliding.

The genuine risk your instinct is picking up on is over-abstraction: semantic-izing values that never vary. The discipline is one test: add a semantic token only where the value must change independently of its usage (theming, dark mode, rebrand, “this is THE primary action”). A one-off border-gray-200 divider needs no token; bg-gray-200 in the markup is correct. Two tiers is the floor, not a target to maximize.

And here is the twist that decides it for an agentic system. Tailwind’s “just use utilities, naming is overhead” assumes a human who holds the design in their head and knows blue-400 is the primary. An AI agent does not. Point it at a codebase of raw bg-blue-400 / bg-blue-500 / bg-sky-500, ask for “a primary button,” and it scatters whichever blue it happens to guess. Semantic tokens are how you state intent to a reader that cannot infer it. In a hand-built app a heavy semantic tier is often over-engineering; in an agent-built app it is exactly what keeps the walls straight.

Step 2: The Transformation Engine (Style Dictionary v4)

Because we want our system to be framework-agnostic, we use Style Dictionary v4 to compile our pure JSON intent into platform-specific code.

A word on tooling reality first. As of 2026, Style Dictionary (Amazon) remains the genre default and the safe pick when you emit to multiple platforms (CSS, Swift, Compose). If you target Tailwind v4 specifically, Terrazzo is the newer Tailwind-v4-native option. And if you only have a single CSS target (one app, one output file), you can skip a build framework entirely: a ~50-line generator that reads tokens.json and writes a @theme block is enough, and often the more honest choice. Match the tool to the number of targets, not to the hype.

With Tailwind v4’s shift to a modern, CSS-first configuration, we no longer need to generate bulky tailwind.config.js objects. Instead, we configure Style Dictionary to output standard CSS variables that seamlessly plug into Tailwind’s @theme directive, maintaining perfect colocation of utility classes without losing semantic control.

build.js
import StyleDictionary from "style-dictionary"

const sd = new StyleDictionary({
  source: ["tokens/**/*.json"],
  platforms: {
    css: {
      transformGroup: "css",
      buildPath: "build/web/",
      files: [
        {
          destination: "variables.css",
          format: "css/variables",
          options: {
            outputReferences: true, // Keeps the CSS variables linked to semantics
          },
        },
      ],
    },
    ios: {
      transformGroup: "ios-swift",
      buildPath: "build/ios/",
      files: [
        {
          destination: "StyleDictionary.swift",
          format: "ios-swift/class.swift",
        },
      ],
    },
  },
})

await sd.buildAllPlatforms()

Integrating with Tailwind v4

The beauty of Tailwind v4 is that it eliminates JavaScript-based configuration entirely in favor of CSS. Once Style Dictionary generates your variables.css, you import it directly into your main CSS file and map it using Tailwind’s @theme directive.

This tells Tailwind’s engine to automatically generate utility classes based on your AI-ready tokens.

Two pieces of modern Tailwind v4 guidance are worth encoding into your generator (Tailwind theme docs, Mavik Labs):

app.css
/* 1. Import the Tailwind core */
@import "tailwindcss";

/* 2. Import your compiled Style Dictionary tokens */
@import "./build/web/variables.css";

/* 3. Map semantic variables into the Tailwind v4 engine */
@theme {
  --color-primary-default: var(--color-action-primary-default);
  --color-primary-hover: var(--color-action-primary-hover);

  /* Tailwind automatically generates utilities from this block.
    You can now confidently use `bg-primary-default` or `text-primary-hover`
    knowing they are strictly tied to your agnostic W3C JSON.
  */
}

🛠 In practice: the compiler is the load-bearing step

This is where theory and practice diverge most sharply, and where teams most often cut a corner. It’s tempting to document the pipeline (“a ~50-line script emits the @theme block”) while actually maintaining the CSS by hand. The moment that happens, you have two sources of truth and no compiler between them.

A hand-typed @theme block that mirrors the token values by copy-paste looks harmless enough:

theme.css
/* theme.css: maintained by hand, NOT generated from tokens.json */
@theme {
  --color-blue-400: #0086e6; /* also lives in tokens.json... */
  --color-blue-600: #00467f; /* ...kept in sync manually */
}

Two sources of truth with no compiler between them means exactly one thing: drift. The hand-maintained file inevitably grows values the token file never gets (extra shades, a display font, one-off pills), and the moment the two disagree, “single source of truth” is a fiction. The token file quietly decays into a Figma export target rather than a build input for the app.

The lesson: Step 2 is not optional glue. It is the load-bearing wall. Without the automated Primitive→CSS compile, tiers 1 and 2 can’t actually reach your components, no matter how clean your JSON is.

Step 3: Authoring Intent-Based UI Metadata for MCP

This is the most critical step for an Agentic Design System. AI agents cannot “see” the design canvas. If an agent is autonomously generating new views, it relies entirely on explicit text constraints.

We must author machine-readable metadata that an MCP server can expose to the LLM, and as of 2026 there’s a real standard for exactly this layer: DESIGN.md, open-sourced by Google Labs in April 2026. It’s the design-system leg of a three-part convention for instructing AI agents: the AGENTS.md / SKILL.md / DESIGN.md trio (dev.to writeup):

A DESIGN.md entry for the Button component pairs the same values in a YAML front matter block with plain-prose usage rules underneath:

button.design.md
---
component: Button
description: >
  The primary interactive element used for finalizing actions,
  submitting forms, or primary navigation.
props:
  variant:
    type: enum
    values: [primary, secondary, ghost]
    default: primary
---

## Usage rules

- NEVER use more than one Primary Button per view.
- For secondary actions (e.g. "Cancel"), use the Secondary Button variant.

Status: alpha. The CLI (@google/design.md, v0.1.1, May 2026) is young. npx @google/design.md lint already does real work, YAML validation, broken-reference detection, and WCAG contrast checks, but reviewers are candid that it’s “not yet a safe production dependency”. Its weakest area is the components schema: compound state (hover × disabled × loading × variant) gets verbose fast, and there’s no official Figma plugin yet.

The critical framing: DESIGN.md and DTCG are complementary, not competitors (WaveSpeed analysis). Keep DTCG canonical: the machine source of truth your compiler reads. DESIGN.md sits downstream as the agent-facing layer that adds human-readable rationale and usage rules on top. Don’t fork your source of truth in two; generate the agent layer from the canonical tokens.

Step 4: Taming Legacy Code (The Agentic Advantage)

Tutorials usually assume a pristine, greenfield codebase. Real design systems carry legacy baggage. Consider a representative Phoenix button component, the kind any long-lived app accumulates, burdened with deprecations, hand-written CSS classes, and version-suffixed variants:

elixir
defmodule EnaiaWeb.Components.Buttons.Button do
  @moduledoc """
  DEPRECATED: This component is obsolete and being phased out.
  Please use EnaiaWeb.Components.Core.button instead.
  """
  use Phoenix.Component

  # ... legacy attributes ...

  def button(assigns) do
    assigns = assign(assigns, css_class: fetch_class(assigns.variant))

    ~H"""
    <button class={[@css_class, @opts[:class]]} type={@type} disabled={@disabled}>
      <span class="v2-button-text">{@label}</span>
    </button>
    """
  end

  defp fetch_class("primary"), do: "v2-button v2-button-primary"
  defp fetch_class("secondary"), do: "v2-button v2-button-secondary"
  defp fetch_class("primary-v3"), do: "button-new"
  defp fetch_class("secondary-v3"), do: "button-new outline"
  defp fetch_class("icon"), do: "v2-button v2-button-icon"
end

The fetch_class/1 function returns opaque class names (v2-button, button-new) whose styling lives in a hand-written stylesheet, versioned by ad-hoc suffixes: primary, then primary-v3, then a parallel button-new lineage. Every suffix is an archaeological layer of “we restyled buttons again but couldn’t migrate the call sites.”

Legacy is rarely one component: it’s layers

The real archaeology usually runs deeper than a single deprecated module. A mature codebase can carry several coexisting generations of the same component:

Generation How you write it Styling mechanism Status
1. Surface (oldest) <Button> macro markup in .sface templates Surface component + its own CSS oldest; being migrated away
2. Buttons.Button <.button variant="primary-v3"> fetch_class/1v2-button / button-new hand-written CSS deprecated, but its CSS still lingers
3. Core.button <Core.button color={:primary} variant={:solid}> composed Tailwind utilities (bg-blue-400, h-[3.8rem]) the canonical, current component

An AI agent staring at a codebase like this sees several valid-looking ways to render a button, most of which are traps. Grep alone won’t save it: a deprecated component may be imported in only a handful of files, yet its CSS classes (v2-button, button-new) can be smeared across dozens that never import anything. Pattern-matching on the “clean” example still surfaces the wrong answer a good fraction of the time.

Even the “clean” component may not reach the tokens

Here’s the subtlety the tidy diagram hides: even the current, “clean” component often isn’t fully token-driven:

core.ex
# core.ex: the "clean" component still hardcodes primitives
defp button_color_classes(:primary) do
  %{solid: ["text-white bg-blue-400 hover:bg-blue-600", "disabled:bg-gray-200"]}
end

defp button_variant_classes(:solid, color_classes) do
  ["... rounded-[0.6rem] px-[1.6rem] h-[3.8rem]", color_classes.solid]
end

bg-blue-400 reaches the primitive token value via the @theme block, but it’s bound to the primitive name, not the intent. Rebrand “primary” from blue to teal and you must find-and-replace blue-400 across every component instead of flipping one semantic token. And the geometry (rounded-[0.6rem], h-[3.8rem], px-[1.6rem]) is hardcoded arbitrary values rather than the radius and spacing tokens the system already defines. It’s a common gap: tokens get authored ahead of the components that should consume them.

The 62.5% trap: when a primitive name outlives its meaning

The deepest legacy hurdle isn’t a component at all. It’s a single line of CSS from a decade-old convention:

css
html {
  font-size: 62.5%;
} /* 1rem = 10px, "easy" px math */

Setting the root to 62.5% makes 1rem = 10px, so every rem reads as px ÷ 10. It was a popular 2015-era trick. The problem is that it silently rescales every rem-based value by 0.625, and Tailwind’s t-shirt type scale (text-sm, text-base, text-lg, …) is calibrated for a 16px root. To claw back a sane pixel ramp, a team redefines the --text-* variables in @theme, and it’s easy to assign the ascending names to a ramp that starts at base = 10px:

Utility The value What every developer expects
text-base 10px (a caption) body text
text-xl 14px (actual body!) a large, heading-ish size

The names now contradict both Tailwind’s convention and ordinary intuition: text-base is the base of nothing, and the body font is called “xl.” And this is never a footnote: the de-facto body size ends up referenced hundreds of times across a codebase. The confusion is hard-coded at scale.

You cannot fix it in place cheaply, because a name can only mean one thing: text-xl can’t be 14px today and 14px-as-base tomorrow. Making the names honest means touching every call site.

This is the same disease as bg-blue-400-means-primary - a name drifted from its meaning - but it teaches a sharper lesson: the primitive layer’s job is to hold values, not meaning, and a t-shirt name is meaning smuggled into a primitive. The moment a foundational decision (the 62.5% root) shifts the values under those names, the meaning breaks and no in-place edit can repair it. radius tokens, by contrast, are often role-named from the start (pill, base, panel) and never develop the problem, the control group that gets it right.

And note the fix is not the same as for color. Color earns a permanent primitive and semantic tier, because a color’s value and its intent genuinely diverge (dark mode re-points it; charts reuse the raw hue). A font size has no such split life - it essentially is its role. So type wants one honest scale, not two tiers: rename to unambiguous roles (text-body, text-caption, text-heading), alias the old t-shirt names during a file-by-file migration, then delete them. The destination is a single scale whose names tell the truth, reached incrementally, because the alternative, an atomic rename of 700 usages, is exactly the big-bang a live product can’t afford. Semantic tiering is the right tool for values whose intent varies; for values that are their intent, the fix is simply an honest name.

How the agent navigates it: the contract, not the code

How does an AI agent know how to pick its way through several component generations, stray CSS classes, and a not-quite-tokenized “clean” component? It doesn’t have to, if the contract is explicit. You route the agent away from the legacy with machine-readable metadata:

core.button.design.md (updated for legacy routing)
---
component: EnaiaWeb.Components.Core.button
---

## Usage rules

- **CRITICAL:** do not use the deprecated `EnaiaWeb.Components.Buttons.Button` module.
- Do not author new Surface `<Button>` markup. Surface is being migrated away.
- Never use legacy class names like `v2-button-primary` or `button-new`.
- Always route primary-action intents to `Core.button` with an explicit `color` and `variant`.

In practice this contract lives directly as the component’s own DESIGN.md entry, or as prose in an AGENTS.md and a dedicated design-system skill that tells any coding agent which component is canonical and which class names are off-limits. The agentic insight is that this guidance should be structured and machine-first, so the agent bypasses the legacy web by construction rather than by luck.

Step 5: Closing the Loop with Code Connect Integration

Finally, we need to ensure that when human engineers look at the design tool’s developer mode, they see the actual, production-ready code mapped to the visual variants.

This loop got materially tighter in 2026 (Figma MCP server announcement). Bidirectional Claude Code integration with the Figma MCP server launched in February 2026; get_component_metadata streams a component’s props and variants straight into the agent’s context, and use_figma (March 2026) lets the agent run Plugin-API JavaScript against a file. Code Connect now officially covers React, iOS, and Android, with community packages filling in HTML/CSS. In other words, the “closing the loop” step below is no longer aspirational plumbing, the agent can read component contracts from the canvas and write mappings back.

We create a mapping file (e.g., .figma.ts) in our codebase. Notice how we map the Figma UI to our clean, new architecture, completely ignoring the legacy structures.

Button.connect.ts
import codeConnect from '@design-tool/code-connect';
import { CoreButton } from './components/Core';

// Links directly to the specific Component in the design canvas
codeConnect.connect(CoreButton, 'https://design-tool.com/file-id?node-id=1-234', {
  props: {
    variant: codeConnect.enum('Type', {
      Primary: 'primary',
      Secondary: 'secondary'
    }),
    label: codeConnect.string('Text')
  },
  example: (props) => (
    <CoreButton variant={props.variant}>
      {props.label}
    </CoreButton>
  )
});
component mapping diagram
DESIGN CANVAS NODE Variant: Primary Text: 'Submit' CODE CONNECT MAPPING Property Mapper CLEAN CORE COMPONENT variant='primary' label='Submit'

Conclusion: the pipeline, not the finish line

The reference architecture (W3C DTCG tokens, a compile step into Tailwind v4, MCP-ready intent metadata, and Code Connect) is the right target. But the value of walking real engineering through it is seeing exactly where a live codebase meets friction the diagram never shows.

The recurring lessons are consistent, and they compound:

The ordered path these lessons imply:

  1. Build the compiler first. Generate the @theme CSS from tokens.json so there is exactly one source of truth. Highest leverage; it’s what kills drift.
  2. Add the semantic tier. Encode action-primary-default → {color.blue.400} instead of describing it in a comment.
  3. Point components at semantics. Swap bg-blue-400 for bg-action-primary, and arbitrary rem values for radius.base / space.*.
  4. Retire older generations. Migrate legacy call sites, then delete the hand-written CSS they depended on.
  5. Formalize the agent contract. Promote prose guidance into structured, machine-first metadata.

Reassuringly, the July-2026 state of the art validates this arc rather than complicating it: keep DTCG canonical, generate the theme from it, encode the semantic tier, and hang DESIGN.md downstream as the agent-facing layer. The steps above aren’t just one team’s opinion, they’re the direction the whole ecosystem converged on.

An agentic design system, then, is not a finish line you cross once. It’s the pipeline that lets both humans and agents pay down that list safely, one tier at a time, without the next restyle becoming Generation Four.

Sources / further reading