~/satyajit

Astryx: what Meta's design system tells an agent, and how its search ranks

mdjsonmcp

2026-11-09 · 25 min · agents · mcp · developer-tools · generative-ui · agentic-coding

Why read this

Notabletop 60%

Counts what Astryx ships, then runs both agent channels, hosted MCP and the CLI, on the same 24 queries, plus doc sizes, init output and a version gap.

  • Runs in a browser
  • Original analysis
  • Widely used

Developer tools & infraMITPractitioner tool

How this was scored
Is it new?
1 of 3: An incremental tweak
Can I trust it?
2 of 3: Measures key facts from files, code or configs
Can I run it?
3 of 3: Open, permissive, runs on reader hardware with instructions
Will I understand it?
2 of 3: Mechanism from first principles with figures
Can I act on it?
2 of 3: A concrete recipe, numbers or comparison
Will it last?
1 of 3: Relevant for months
Does it affect many?
2 of 3: A widely used model, tool or lab release
Only here?
2 of 3: A teardown or measurement few others did

Score 65 of 100, ranked 260 of 701 rated articles. Each question is answered 0–3 by hand, and a 3 is rare. How articles are scored

Two posts on 8 November said Meta had open-sourced Astryx, "a design system built for people and the AI agents working with them". One listed React 19 and StyleX, docs, Storybook and a sandbox, an MIT licence and a beta label. The other, in Chinese, added the bigger numbers: eight years of internal use, more than 13,000 apps, 150-plus components, and a CLI and an MCP server through which an agent can read component docs and templates.

The news part is stale. Astryx's own blog dates the launch to 18 June 2026, and a "one month" post from July reports 10.3K GitHub stars by then. What is still worth an article is the part the posts gesture at and nobody shows: what does a design system "for agents" actually hand an agent, and is the answer any good? So I cloned the repository at its newest commit, counted what it ships, read the agent surfaces, and queried the hosted MCP server directly. On a first pass the sandbox's permission layer refused to execute the CLI's npm package, and this article said so. With that permission granted, on 10 November I installed the published CLI, Core and the neutral theme at 0.7.11 into a scratch project and ran it: the same 24 searches, every component read at every detail level, init both ways, and Core pinned to an older release. Those results are in the CLI section and are marked measured.

meta/astryx@0840fcc · snapshot 2026-11-10
tracked files
10,477
license
MIT
branch
main
tests
3044 files
source
71.9 MB
commit date
2026-11-10
source by language
TypeScript47.4 MB(4903)JavaScript23.1 MB(3352)Swift897.4 kB(84)CSS393.4 kB(46)Shell35.4 kB(8)HTML22.7 kB(21)Python12.8 kB(1)

by size of tracked source at this commit, file counts in brackets; docs, data and vendored trees excluded

Read at da85f28 (9 November 2026, the depth-1 clone's only commit). MIT. Published @astryxdesign/core and @astryxdesign/cli on npm at 0.7.11; the repo's CLI package.json says 0.7.12.

local clone, 2026-11-10 at 0840fcc — branch, commit, commitDate, fileCount, hasTests, languages, license, licenseFile, shallow, testFileCount

shallow clone: counts describe the pinned tree, not the history

What is in the repository

The repo is a pnpm monorepo with three top-level directories: packages/ (what is published), apps/ (the docsite, Storybook, a sandbox, a template viewer and six example apps for Next.js and Vite, with and without Tailwind), and internal/ (test utilities, an ESLint plugin, and the "vibe tests" I come back to). LICENSE is MIT, copyright 2026 Meta Platforms. The root package.json declares the same.

The published surface is four kinds of package:

Three more packages exist but are not stable: lab (experimental components, used by Storybook and not published), and charts and vega, which the README says ship only under the @canary dist-tag.

Counting the 150+

Every component documents itself in a .doc.mjs file next to its source, typed as a ComponentDoc or a HookDoc from the CLI's authoring contract. That makes the count checkable without running anything. In packages/core/src (measured):

WhatCount
ComponentDoc files198
HookDoc files63
directories holding them104
ComponentDoc files in lab, charts, richtext44, 6, 4
test files under core/src812

The 198 include sub-components with their own docs (DialogHeader, TableCell, TypeaheadItem), and the 104 directories are closer to what a person would call a component. Either way "150+ components" holds for Core alone if you count documented parts, and it would hold for directories if you added lab. The README's own phrase is "150+ accessible components". I would read it as documented parts.

One small tell that the number moves: the CLI's agent-docs generator counts components live from the installed Core, and when it cannot, it falls back to a hard-coded string, '90+' (packages/cli/foundation/agent-docs/agent-docs.mjs:620). An agent in a project where discovery fails is told about 90.

The 13,000 apps and eight years

These are reported, and only by Meta. The README says Astryx "grew inside Meta over the last eight years" and powers "13,000+ apps". The docsite blog says "13,000+ products" in one post and "over 13,000 apps" in another. Nothing in the repository can check either number, and nothing tries. The git history starts fresh: my clone is one commit deep, and the copyright line says 2026. I treat both as Meta's description of the internal system this was rebuilt from, which is what the blog post on how it works says it is: "that internal system rebuilt on the open-source stack".

Theming in one paragraph

A theme is a flat map of CSS custom properties, each either one value or a [light, dark] pair that becomes light-dark(). defineTheme in core/src/theme/defineTheme.ts takes that map plus optional icon overrides; the theme either injects a <style> tag at runtime or is pre-compiled by astryx theme build into a CSS file selected by a data-astryx-theme attribute. Core's Tailwind bridge, tailwind-theme.css, names 218 distinct custom properties (measured with a grep), which is a fair proxy for the token surface a theme can touch. The colour code includes an HCT implementation (theme/hct.ts), the perceptual space Material uses, for expanding one accent into a scale. Consumers never see StyleX: they import pre-built CSS and override with className in Tailwind, CSS modules or plain CSS.

Three ways an agent learns it

The README's claim is that "the API, docs, and CLI are designed together so a person and an AI assistant build the same way." Reading the CLI package and the docsite, an agent can learn Astryx through three different channels, and they differ in one way that matters: which version of the docs answers.

The init block. astryx init writes a managed block, fenced by <!-- ASTRYX:START -->, into AGENTS.md, and a one-line import of AGENTS.md into GEMINI.md. It writes the same block into CLAUDE.md only when the project already has one (measured below; --agent claude asks for it explicitly). The block is short on purpose: about 2,100 characters. It states the version and component count, the CLI prefix to use, the CSS imports without which every component renders unstyled, a template-first workflow, a handful of house rules (no raw <div>, tokens instead of hex), and the commands and docs topics an agent should know to ask for; the generator's comment says why: "An agent cannot ask for a topic it was never told about". With --user, init instead writes one skill file to ~/.agents/skills/astryx/SKILL.md and links it into ~/.claude/skills and, where those folders exist, the skills folders of Kiro, Windsurf, Letta and Xcode's assistant, so agents anywhere on the machine know Astryx exists.

The CLI. Sixteen commands, every one with --json, which returns a typed envelope: { apiVersion, type, data, meta }. meta.answeredBy names the CLI version, the Core version it paired with, and which copy answered: every read I ran in the scratch project came back { cli: "0.7.11", core: "0.7.11", copy: "project" } (measured), and init --user, run outside any project, came back copy: "carried". --detail brief|compact|full and --dense trade completeness for tokens; --zh gives Simplified Chinese where a translation exists. Each component's doc file carries all three: docs, docsZh and docsDense live in the same .doc.mjs. The important property is that the project's own install answers, so an agent reading astryx component Button gets the props of the Button the project actually has.

The MCP server. A Next.js route on the docsite, apps/docsite/src/app/mcp/route.ts, served over Streamable HTTP at https://astryx.atmeta.com/mcp. No install. Two tools, search and get. It answers from the docsite's generated registries, so it describes whatever the docsite last deployed, not the version in your lockfile. Its get responses do include an isReady flag and the exact install command (@canary for components that are not ready), which is the server's way of telling an agent that it may be describing something your version does not have.

That last difference is the one I would weigh. The CLI is versioned to the project and the MCP server is not. A team pinned to an older Core gets current docs from MCP and correct docs from the CLI; I pinned one and checked. The Astryx team clearly thought about this: one of the decisions its blog lists as settled by a vibe test is "CLI vs MCP", and the CLI is the channel init points agents at.

What the MCP server returns

The server was the first surface I exercised end to end. I sent initialize, tools/list, and then 84 tool calls from a short Python client on 9 November 2026 (measured; the response headers say Vercel, serverInfo says astryx 2.0.0). Every call I timed returned in under a third of a second; the median search took 0.20 s.

tools/list confirms the two tools. search takes query and an optional limit (default 8). get takes name and an optional section for long doc topics.

How search ranks

The scoring is about a hundred lines and worth reading in full, because it explains everything I measured. For each component it takes the best of four numbers:

Docs and templates are scored the same way without the keyword boost. Sub-components lose one point so a parent outranks its parts. The list is sorted by score, highest first, and cut to limit.

The keyword index is the interesting part (keywordIndex.ts). It maps each lowercased keyword to the components that list it, and a keyword "resolves" for a query when the query contains the keyword or the keyword contains the query. Two consequences follow directly from that rule:

  1. Everything that resolves ties at 90. A component with one matching keyword and a component with three get the same score. JavaScript's sort is stable, so the tie is broken by registry order, which in every tied run I looked at was alphabetical.
  2. Short keywords match inside words. A keyword as resolves for "toast notification", because "toast" contains "as". ai resolves for "breadcrumb trail", ul for "multi step form wizard", toc for "autocomplete search box", pan for "side panel".

Neither is a guess about the hosted behaviour. I re-implemented only the keyword stage from the repository's own keywordIndex.ts, ran it over the keyword lists parsed from Core's doc files (173 documented components and hooks, 1,599 keywords), and compared: of the 121 results the server put in its top eight because of a keyword, my re-implementation placed 120 in the same tied band (measured). The one miss is illustrations, an entry defined in the CLI package rather than in Core.

Twenty-four queries

The Astryx team's own rule for vibe-test prompts is that they "should describe the user experience rather than specific components". I wrote 24 queries that way (a toast notification, a side panel that slides in, confirm before deleting, an on off toggle) and named, before sending, the component a person who knows the library would want. Then I recorded where the server put it.

Expected component in…Hosted serverSame lists, whole-word tie-break
first place11 of 2416 of 24
top three15 of 2420 of 24
top eight (the default)22 of 2422 of 24

The first column is measured. The second is reasoned: I took the eight results the server returned, kept its order outside the keyword band, and inside the band sorted by how many keywords matched the query as whole words. It is not a server run, and it cannot rescue a component the server never returned.

A few cases carry the pattern:

The server is not bad at this. When the query names the thing ("command palette", "user avatar", "breadcrumb trail", "empty state"), the right component comes first every time. It is weakest exactly where the vibe-test rule says agents live: describing the experience instead of naming the component. And an agent does not see scores, only an order. Most agents take the first plausible hit and call get on it.

Two small fixes would move most of these: count keyword hits instead of flagging them, and match keywords on word boundaries. Both are a few lines in keywordIndex.ts. The CLI's astryx search is a different implementation that does roughly both, and it is compared below on the same 24 queries.

What one answer costs

The other thing an agent pays is context. A brief search result carries the import line, the install command, up to six key props, related components and a hint to call get. A get returns everything: props with types and descriptions, usage guidance, theming notes, related hooks, and two code examples.

Measured over 24 components: a get response is 5,375 characters for Spinner at the small end and 17,753 for Selector at the large end, median about 10,300 characters, or roughly 2,600 tokens at four characters a token (the token figure is an estimate; I did not run a tokenizer). A search answer at the default limit is a median of about 5,400 characters. A page that needs a table, a dialog, a selector and a toast pulls about 58,400 characters of reference, around 14,600 tokens, once.

That is the design choice worth noticing, and it is the opposite of the one on this site's json-render + Jev piece. There, the whole component catalog travels with every model call, about 7,100 tokens each time, because a decision model picks from options your code enumerated. Astryx sends nothing until asked, then sends a lot about one thing. Retrieval is cheaper when an agent builds a page from a handful of components; it is only as good as the search that decides which handful.

The CLI, run against the same questions

Everything in this section is measured on 10 November 2026: npm install of @astryxdesign/cli, core and theme-neutral at 0.7.11 (the latest tag that day) into an empty scratch project, every command run through npx astryx with HOME pointed at a scratch folder so nothing touched a real home directory.

How the CLI ranks

The CLI does not share the server's scorer. Its search lives in about 4,400 lines under api/search/ in the installed package: a lexical ranker that scores names, keywords, titles, synonyms and plurals, and gives each result a reason it prints ("exact name", "plural of the name", "matches 2/2 terms: toast, notification"). The score falls into one of eight bands, with floors at 195, 188, 170, 160, 151, 100 and 55 and one more below that (_order.mjs), and a learned order, shipped as weights files beside the code, rearranges results inside a band but never across one. Default limit is 20, not 8.

Two of the server's problems are gone by construction. Matching is on words, so short keywords no longer match inside query words: useInteractiveRole, which the server returned for "toast notification" because of as, and Outline, returned for "autocomplete search box" because of toc, appear nowhere in the CLI's full result lists for those queries. And a result that covers every word of the query scores in the top tier, above one that covers half of it, which is close to the "count the hits" fix proposed above.

Expected component in…Hosted MCP (9 Nov)CLI, all domainsCLI, --type component
first place11 of 2415 of 2417 of 24
top three15 of 2421 of 2422 of 24
top eight22 of 2422 of 2423 of 24

Against the server, the CLI moved the expected component up on ten queries, down on four, and left ten where they were. "Autocomplete search box" goes from tenth to first, "tooltip on hover" from seventh to first, "on off toggle" from sixth to first, "date picker" from 11th to second. Each of those was a server failure caused by an alphabetical tie or an infix keyword.

It does not remove ties. When two results score the same, the CLI keeps them in name order too: "modal dialog" puts four components at 159 (AlertDialog, BottomSheet, CommandPalette, Dialog) and Dialog is fourth (fifth on the server); "toast notification" has Banner, Toast and useToast tied at 159, so Toast is second.

And it has a failure of its own, from the learned order inside a wide band:

So the CLI's search is better on the queries this article was written to test, and --type component is worth passing when an agent wants a component rather than a template. It is still a ranking an agent sees only as an order.

Speed is not a reason to prefer either. A search through npx took 1.5 to 2.1 seconds of wall time here, almost all of it Node starting up on a shared, deprioritised CPU; the server answered in a median 0.20 s over the network.

What a CLI read costs

The size measurements, for the same 24 components the server was measured on (all measured, characters of stdout):

ReadMedianSmallestLargest
MCP get (9 Nov)10,3225,37517,753
component <Name> --json, any --detail10,0325,25419,790
component <Name>, text, --detail full6,6423,28613,399
text, --detail compact5,1961,54610,625
--dense5,4752,79413,051
--props --json3,9381,25110,107
text, --detail brief8222611,347

The --json reads were byte-identical at brief, compact and full, for every one of the 24. That matches the CLI's own type file, where --detail shapes the JSON of a component list and the JSON of a single component has one shape. An agent that asks for JSON pays for full detail whatever it passes, about the same as the server's get. The cheap reads are text: --detail brief is a signature line, one line of description and the main props in a median of 822 characters, about a twelfth of the full read. The table, dialog, selector and toast page from earlier costs 51,076 characters as CLI JSON and 3,310 as brief text. The context-cost chart above now has a switch for each of these.

Search answers are about the size of the server's at the same limit: --json --limit 8 has a median of 5,219 characters, against about 5,400 from the server. At the CLI's default limit of 20 the median is 12,272.

What init actually writes

In an empty project with a package.json and the three packages installed, astryx init wrote two files: AGENTS.md, holding a heading plus the managed block, and GEMINI.md, holding the block markers around one line, @./AGENTS.md (55 bytes). It did not create CLAUDE.md. In a second project that already had a CLAUDE.md, it wrote the block into that too, byte-identical to the one in AGENTS.md, below the existing notes.

The block is 2,136 characters, 317 words. Its first line is Astryx v0.7.11 · 174 components, counted live from the installed Core, not the '90+' fallback. Then the npx astryx prefix, the two CSS imports, a three-step workflow that starts every page from a template (astryx build "<idea>", then astryx template), a rule to run astryx doctor --built <dir> before delivering, six rules (no <div>, read astryx docs layout before changing a template's frame, dense data as rows, props first and then tokens with no raw hex or px, palettes through the theme, and a self-check), and the key commands. The first version of this article, written from source, undersold it: it is a short operating manual, not only a pointer to the docs.

astryx init --user wrote one 1,880-byte SKILL.md under the scratch home's .agents/skills/astryx/, made .claude/skills/astryx a relative symlink to it, and recorded that link in a settings.json so that removal undoes exactly what it did. The skill tells an agent to start with astryx search, to read astryx component rather than the package's files, and that "in a project, the project's version of Astryx answers automatically".

The version gap, measured

The argument above, that the CLI answers for the installed version and the server does not, is now measured. Core has 3,266 versions on npm, 47 of them outside the canary and test tags. I installed CLI 0.7.11 beside Core 0.5.0 in a second scratch project.

An agent in that project that trusted the server would import a component the project does not have, and miss a prop it does. One that ran the CLI would be told the truth about its own install.

How Astryx measures itself

The most unusual thing in the repository is not a component. It is internal/vibe-tests/: a harness that gives the same prompt battery to fresh agents building with Astryx, with Astryx plus Tailwind, with shadcn and Tailwind, and with raw HTML, and has a separate judge agent score the results. The blog post describing it reports (these are Meta's numbers; I did not run the harness, which needs agent API access):

A nightly job runs the battery, and a second agent, the "vibe test debugger", reads failures and opens pull requests against the docs or CLI. A proposal in the same directory, cli-discovery-test/PLAN.md (status: "proposal / design (not yet implemented)"), asks the upstream question this article circles: when an agent lands in a project that installed Core but never ran init, does it find the CLI at all? Its hypotheses rank a @see banner in the type definitions above a README callout, and a postinstall banner near the bottom, since pnpm skips dependency scripts.

I like that this exists more than I like any single number in it. Most design systems that say "AI-ready" mean they wrote a doc page. This one runs a nightly experiment against a baseline and publishes the nights it loses.

Against the site's other agent-facing UI

This site has looked at three other answers to "how does an agent build a decent interface":

Astryx sits upstream of all three. It does not judge the output and it does not take the decision away. It gives the agent a real component library with a reference it can query, and bets that a well-documented system with predictable naming makes the agent's own guesses right more often. The vibe-test ledger is the evidence for that bet, and by its authors' account the bet is roughly even against shadcn and Tailwind, which models have seen far more of in training.

What I ran and what I did not

Ran, measured: a shallow clone of meta/astryx at da85f28; counts of doc files, hook docs, directories and tests by grep; 84 MCP tool calls against the hosted server from Python's standard library; a re-implementation of the keyword stage over keyword lists parsed from Core's doc files (text parsing, nothing executed); an npm install --ignore-scripts of the published @astryxdesign/cli, core and theme-neutral at 0.7.11. On 10 November, with permission to execute the package: astryx search --json on the same 24 queries (at limits 8, 20 and 400, and with --type component); astryx component for 24 components as JSON at three detail levels, as text at three, --dense and --props --json; astryx init in two scratch projects and init --user with HOME redirected to a scratch folder; and CLI 0.7.11 against Core 0.5.0, compared with two fresh get calls to the hosted server.

Refused or not attempted: on the first pass the sandbox's permission layer refused to execute the CLI; that is resolved above, and nothing was worked around. Speed comparisons are rough: the CLI ran under nice on a shared CPU. I did not run the repo's own CLI tests (they need the monorepo installed, not the published package), its Vitest or Playwright suites, build Storybook, or run the vibe tests (they need agent API keys). I could not read the GitHub issue behind the "CLI vs MCP" decision; this session's GitHub access does not cover meta/astryx.

Should you use it

If you are starting a React 19 app and you want agents to write most of the UI, Astryx is worth a trial. The licence is MIT, the component set is large, theming is plain CSS variables, and the agent channels are thought through further than any other design system I have read. Use the CLI, not the MCP server, as the agent's reference, and now that I have run both, for two measured reasons instead of one. It answers for the version you installed: pinned to Core 0.5.0, it said Drawer does not exist while the server described it as ready to install. And its search is better at the job agents actually do, describing an experience: first place 15 of 24 against the server's 11, top three 21 against 15. Run astryx init so the block lands in AGENTS.md (and create a CLAUDE.md first if you use Claude Code, or it will not get one). Two habits help the agent: pass --type component when it wants a component (17 of 24 first), and read with --detail brief text before the full doc, because JSON reads cost the full ~10,000 characters whatever --detail says.

If you do point an agent at the MCP server, give it names, not experiences. "Toast" works. "Toast notification" puts the toast fifth. Neither channel is free of rank surprises: the CLI puts TabList 13th for "tabs". And if you write your own keyword search for an agent to use, count the hits and match whole words: of the 24 queries here, that alone takes the right first answer from 11 to 16 on the server's own lists, and the CLI, which does roughly that, gets 15.

It is still beta. The @canary flags in get responses are there for a reason, and charts and vega are not on a stable tag yet.

Cite this article

For attribution, please use the following reference or BibTeX:

Satyajit Ghana, "Astryx: what Meta's design system tells an agent, and how its search ranks", ai.thesatyajit.com, November 2026.

bibtex
@misc{ghana2026astryx,
  author = {Satyajit Ghana},
  title  = {Astryx: what Meta's design system tells an agent, and how its search ranks},
  url    = {https://ai.thesatyajit.com/articles/astryx},
  year   = {2026}
}
share