What an agent actually needs from a design system
Not documentation. A contract it can resolve, and a build that says no.
The markup an agent produces
Here is breadcrumb markup an agent produced. Right class names, right order, sensible structure.
<div class="ds-crumbs">
<div class="ds-crumbs__item">
<a class="ds-crumbs__link" href="/">Home</a>
</div>
<div class="ds-crumbs__item">
<a class="ds-crumbs__link" href="/cars">Inventory</a>
</div>
<div class="ds-crumbs__item">Model S</div>
</div>It is not a breadcrumb. A screen reader announces two links and a loose fragment of text. It renders as something a person would sign off on, which is exactly the problem — .ds-crumbs is a bare class selector, so the wrong element inherits all the right styling.
Nothing in a documentation site prevents this. A human reading the docs brings enough context to write the nav without being told. An agent brings compliance — it produces what the guidance literally described, reports success, and by the only standard available to it, it is right.
What I am building
I am building a design system whose primary consumer is an agent, not a person browsing a docs site. It installs into a project as an Agent Skill: run the scaffold and you get a project-owned design-system directory — canonical tokens, component contracts, a framework-free CSS reference implementation, a brand and icon manifest, a specimen, and the validator that holds all of it to account.
It is also built the way it is meant to be used. The contracts, the validator checks and the negative tests were written by agents working against the system’s own rules, and the system’s failures surface the same way — a real build goes wrong, and the fix becomes a condition the next build cannot skip.
The useful question turned out not to be what the system tells an agent. It is what the system can resolve, and what it can refuse.
The contract splits meaning from implementation
Every component ships a contract with a portable root and per-platform bindings. The root is what the component means — slots, axes, states. The binding is how that becomes HTML on one platform.
"class": "ds-button",
"axes": {
"variant": { "modifiers": {
"primary": "ds-button--primary",
"secondary": "ds-button--secondary",
"ghost": "ds-button--ghost",
"danger": "ds-button--danger" } },
"size": { "modifiers": {
"sm": "ds-button--sm",
"md": null,
"lg": "ds-button--lg" } }
},
"states": {
"disabled": { "driver": ":disabled, [aria-disabled=true]" },
"loading": { "driver": "[aria-busy=true]" }
}The agent does not guess class names. It reads variant=danger and resolves ds-button--danger. The null on size tells it the default takes no modifier — a fact it would otherwise get wrong by inventing ds-button--md. And loading is not prose about spinners; it is an attribute the agent sets and CSS reacts to.
The same split decides where behaviour lives. CSS can prove a button’s states. A dialog needs focus trapping; a command menu needs filtering and arrow-key navigation. Those get contracted here and implemented in a runtime layer. A README cannot trap focus.
Styles bind to tokens rather than values, which is why rebranding a project never touches component code:
"root.bg": "action.primary.bg",
"root.text": "action.primary.text",
"root.bg@hover": "action.primary.hover",
"root.bg@disabled": "action.disabled.bg"Instance grammar makes the markup checkable
Slots give the agent a content interface. Instance grammar maps those slots onto required DOM — the part that catches the breadcrumb at the top of this piece.
"entry": "wrapper",
"parts": {
"wrapper": { "selector": "nav",
"attributes": { "aria-label": "Breadcrumb" },
"children": [ { "part": "root", "min": 1, "max": 1 } ] },
"root": { "selector": "ol.ds-crumbs",
"children": [ { "part": "item", "min": 2 } ],
"constraints": [ { "part": "item",
"attribute": "aria-current", "equals": "page",
"count": 1, "position": "last" } ] },
"item": { "selector": "li.ds-crumbs__item",
"cases": [
{ "when": { "aria-current": "page" },
"allowText": true, "children": [] },
{ "otherwise": true, "ordered": true,
"children": [
{ "part": "link", "min": 1, "max": 1 },
{ "part": "separator", "min": 1, "max": 1 } ] } ] }
}Every accessibility decision a human would have supplied from experience is now a structural requirement: the landmark, the ordered list, exactly one current-page marker and it must be last, separators hidden from assistive tech, the current page as text rather than a link. This also closes the oldest cheat in design systems — an approved class name on unrelated HTML no longer counts as having used the component.
The correction loop
Run the validator against the consumer project and it walks real markup against that grammar. Below is verbatim output, four rounds against the div soup at the top. No human in the loop.
$ validate_design_system.py --root . --consumer-drift
ERROR: page.html:3: Breadcrumbs root must match ol.ds-crumbs
# swap div → ol
ERROR: page.html:3: Breadcrumbs root requires nav wrapper
# add the landmark
ERROR: page.html:4: Breadcrumbs root requires exactly 1 item with aria-current='page'
ERROR: page.html:5: Breadcrumbs item requires 1..1 separator child; got 0
ERROR: page.html:6: Breadcrumbs item requires 1..1 separator child; got 0
ERROR: page.html:7: Breadcrumbs item contains uncontracted text
ERROR: page.html:7: Breadcrumbs item requires 1..1 link child; got 0
# add separators + current-page marker
Result: passed with 0 warning(s).The agent needed to know nothing about breadcrumb accessibility. It needed a grammar and something that says no. What it produces at the end is not markup that resembles the component — it is markup that satisfies the component’s structure.
Where new rules come from
The gaps are the interesting part, because I cannot invent these conditions from a desk.
A sidebar selection state shipped as a pale tint in light mode and a solid block in dark. The role resolved to accent.100 and accent.900 — same primitive family, both modes declared. Every check passed it. I caught it by looking at the browser, which is a worse place to catch it than the build.
The lineage check was asking which ramp a role comes from. It never asked how far off its own canvas the step lands. So that became a check.
$ validate_design_system.py --root .
ERROR: selection.bg emphasis is asymmetric across modes: 1.13:1 from surface.canvas in light and 1.72:1 in dark; keep subtle-fill mode contrast within 30%The 30% is derived, not chosen: it clears every deliberately mode-tuned starter role — the widest is 28% — and rejects the observed pair at roughly 50%. Two tests hold it in place: one replays the bad pair and asserts the failure, one asserts the starter roles still clear. A check nobody has watched fail is not yet a check.
That is the loop the whole repository runs on. A decision starts in a token, a contract, or the reference implementation. The validator turns it into a condition. A negative test proves the condition can fail. Real builds expose the conditions I did not know to write, and each one travels back into the source — so the next agent inherits the correction instead of a longer prompt.
No future agent will be told about the selection-state bug. It will simply be unable to ship it.
What this does not cover yet
Everything above runs today. The layers above it — runtime components, composable section recipes, a query surface that answers system questions in canonical JSON — are not built yet, so I will leave them there.
A design system for agents has to do two things a documentation site cannot: resolve an exact answer, and reject an exact mistake. Without the second, the agent follows your guidance faithfully, straight into the wrong interface.