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.
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.
State of the art (July 2026)
Before we build, a snapshot of what’s solid versus what’s still moving, as of July 2026:
- Stable: The W3C DTCG token format shipped its first stable module in late 2025 and is now the settled foundation. Style Dictionary as a compiler and Figma’s MCP server + Code Connect are mature, production-grade tooling.
- Real but alpha: The agent-facing layer, DESIGN.md (see Step 3), is genuinely emerging as a standard but is still pre-1.0 and, per its own reviewers, “not yet a safe production dependency.” Treat it as a direction to lean into, not a load-bearing dependency yet.
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.
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.
{
"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"
}
}
}
}
}
}
🛠 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:
{
"$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.
-
Component semantics (“this is a button, styled thus”)
did get evicted from CSS. But they didn’t vanish; they
moved into the component system: a React
<Button>, or in our Elixir case aCore.button/1HEEx function. That is where.button {}went. (Tellingly, the legacyv2-button/button-newclasses we’ll meet in Step 4, defined with@applyin a stylesheet, are exactly the anti-pattern Tailwind warns against; a composedCore.buttonfunction is the sanctioned replacement. That migration is “move component semantics out of CSS into the template.”) -
Value semantics (“this color means primary
action”) live in the theme. And here is what the flinch forgets:
@themeis itself a token layer, and Tailwind ships one.bg-blue-500is not a raw value;blue-500is a name for#3b82f6. You are always using a token. The only question the semantic tier asks is whether the token is named by its value family (blue-400) or its role (action-primary), and role-named theme tokens are the standard Tailwind pattern. shadcn/ui, the most-used Tailwind component library in existence, is built entirely onbg-primary,bg-background,text-muted-foreground. Nobody calls that fighting Tailwind.
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.
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):
-
@themevs. plain:rootis the tree-shaking rule. Put a token in@themewhen it should generate a utility class (bg-primary-default). Put it in a plain:rootblock when you only need the CSS variable and don’t want Tailwind emitting utilities for it. Dumping every token into@themebloats the generated CSS with utilities nobody references. - Prefer OKLCH color values. For color scales, OKLCH is the modern choice: it produces perceptually even steps, so a 400→600 ramp looks evenly spaced instead of jumping around as hex/HSL ramps do.
/* 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: 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):
- AGENTS.md: general repo instructions for the agent.
- SKILL.md: reusable capabilities the agent can invoke.
- DESIGN.md: the design system, agent-facing. It pairs YAML tokens with Markdown rationale, so the agent gets both the values and the why behind them.
A DESIGN.md entry for the Button component pairs the same values in a YAML front matter block with plain-prose usage rules underneath:
---
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:
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/1 → v2-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: 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:
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:
---
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.
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>
)
});
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:
- Primitives are the easy part; the semantic tier, encoding intent, not describing it in prose, is where a system earns its keep.
- The compiler is load-bearing: without an automated token→CSS build, the cleanest JSON never reaches your components.
- Names drift from meaning; keep primitives holding values, and let a semantic layer, or, for type, an honest scale, carry intent.
- Legacy accumulates in layers, and the reliable way to route agents past it is an explicit, machine-readable contract.
The ordered path these lessons imply:
-
Build the compiler first. Generate the
@themeCSS fromtokens.jsonso there is exactly one source of truth. Highest leverage; it’s what kills drift. -
Add the semantic tier. Encode
action-primary-default → {color.blue.400}instead of describing it in a comment. -
Point components at semantics. Swap
bg-blue-400forbg-action-primary, and arbitrary rem values forradius.base/space.*. - Retire older generations. Migrate legacy call sites, then delete the hand-written CSS they depended on.
- 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
- W3C Design Tokens Community Group format: the DTCG spec and its stable v2025.10 module.
- Style Dictionary and Terrazzo: token compilers; the latter is Tailwind-v4-native.
-
Tailwind v4 theme docs
and Mavik Labs: Design tokens in Tailwind v4:
@themevs:rootand OKLCH guidance. - AGENTS.md / SKILL.md / DESIGN.md: how AI instructions split into three layers and the DESIGN.md review: the agent-facing layer and its alpha status.
- DESIGN.md vs design tokens for AI workflows: why the two are complementary, not competitors.
- Introducing the Figma MCP server: Code Connect and the bidirectional design-canvas loop.
- Enaia: the commercial-real-estate application used as the running case study.