# onsystem > Keeps coding agents on your design system: it knows your real components, props, variants and tokens, catches the moment an agent invents one and has it fix it, and the same check gates your PRs. Local, zero config, works alongside @shadcn/lint. It reads React components, props, cva variants and tokens from source with the TypeScript compiler. In Claude Code, a hook checks each file right after it is written and hands the errors back with their fixes; other agents run check_ui over MCP; the CI check gates pull requests. Static analysis: no model calls, no API key. ## Quickstart No config is needed in a shadcn/ui project (Tailwind v3 or v4), an app whose components live in a workspace package, or the design-system package itself ([how it finds them](https://onsystem.vercel.app/docs/configuration#zero-config)). Other layouts take a [config file](https://onsystem.vercel.app/docs/configuration#config-file). Run it from the app's folder, or from a [monorepo root](https://onsystem.vercel.app/docs/configuration#monorepo-roots), where it finds every app and design-system package and checks each file against its own project. > [!NOTE] > It needs Node.js 20.19 or later. ### See what it found From the app's folder or the monorepo root: ```sh npx -y onsystem inspect ``` It lists the components with their props, variants and parts, and the tokens by category. `inspect --explain` adds every place it looked and why each was taken or not, the config it resolved to, tokens that did not resolve and components whose props did not. If the list is empty, see [troubleshooting](https://onsystem.vercel.app/docs/troubleshooting#inspect-found-nothing). To keep what it found in a file you can edit, run `npx -y onsystem init`: it writes `onsystem.config.json` from it (or asks where the components and tokens are when it found nothing), checks the project once and offers to record the findings as a baseline. `--yes` answers for you. ### Hold your agent to it Pick one: - **Claude Code**: install the [plugin](https://onsystem.vercel.app/docs/plugin). Its hook checks every UI file Claude writes and hands the errors back before Claude moves on; it also brings the MCP server and a skill that has Claude look components up first. ```text title="Claude Code" /plugin marketplace add dgesteves/onsystem /plugin install onsystem@dgesteves ``` - **Cursor or VS Code**: use the [one-click install links](https://onsystem.vercel.app/docs/clients#cursor-and-vs-code), or commit the config there to the repository. - **Any other MCP client**: run `npx -y onsystem` as a stdio server. There are [configs for Claude Desktop, Codex CLI, GitHub Copilot CLI, Windsurf, JetBrains IDEs, Zed, Gemini CLI and Grok Build](https://onsystem.vercel.app/docs/clients#other-clients). ### Try it Ask your agent for some UI, such as _"Add a danger-zone card to the settings page."_ It looks the components, variants and tokens up before writing and runs `check_ui` on the result. In Claude Code the skill loads by itself for UI work (or run `/onsystem:onsystem`), and the hook checks each file as it is written. ### Gate your pull requests Install it as a dev dependency, record the findings the codebase already has, and fail CI only on new ones: ```sh npm install --save-dev onsystem npx onsystem check . --update-baseline # writes onsystem.baseline.json: commit it ``` ```yaml title=".github/workflows/onsystem.yml" - run: npx onsystem check . --format github --require-design-system ``` `check .` lints every `.tsx` and `.jsx` file under the folder, whatever the layout, and leaves out the design system's own components, tests and stories, and what git ignores. [CI and baselines](https://onsystem.vercel.app/docs/ci) has the whole workflow. ## Claude Code plugin The plugin is the fastest way to keep Claude on your design system. It bundles three things: - **A hook** that runs `onsystem check` on every `.tsx` or `.jsx` file Claude writes or edits, and hands the errors back so they get fixed in the same turn instead of in review. - **The MCP server**, so Claude can look components, props, variants and tokens up before it writes. - **A skill** that tells Claude to do that, and to fix every `check_ui` finding. It loads by itself for UI work, or run `/onsystem:onsystem`. ```text /plugin marketplace add dgesteves/onsystem /plugin install onsystem@dgesteves ``` ### What Claude sees when it invents something ```text ⏺ Write(app/promo/page.tsx) ⎿ PostToolUse hook: app/promo/page.tsx breaks the project's design system 1:58 error [no-hardcoded-color] Hardcoded color `bg-[#f5f5f5]` → `bg-muted`. 1:77 error [prefer-design-system-component] Native ``` - 2:4 error: Native where the design system has . Use (import { Input } from "@/components/ui/input"). - 3:4 error: Native ``` #### no-unknown-component (error by default) Components must exist in the design system: no invented components or dot-notation members. Catches: Invented components, dot-notation members that do not exist (``), typos. A name from a module the model left out (an excluded folder, a barrel that re-exports one) is not called invented: `check` says once which components it could not check Suggests: The flat part (``) or closest name, auto-fixed only when that component is imported Why it matters: Agents mix up conventions between libraries: `` where the system exports flat parts, or a component that only exists in another kit. TypeScript reports it once the file compiles in the project; `check_ui` reports it in a snippet the agent has not saved yet, and names the part to use. Example: ```tsx Billing ``` - 2:4 error: does not exist: Card is composed from flat parts. Use (import { CardHeader } from "@/components/ui/card"). - 3:6 error: does not exist: Card is composed from flat parts. Use (import { CardTitle } from "@/components/ui/card"). Fixed: ```tsx Billing ``` #### no-unknown-prop (error by default) Props must exist on the component (own props or the HTML attributes it forwards). Catches: Props a component does not accept, own or inherited (`tone`, `isDisabled`) Suggests: Closest prop, with cross-library synonyms (`tone` → `variant`) and equivalents in either direction (`checked` ↔ `isSelected`, `open` ↔ `isOpen`); `asChild` on a Base UI component (or `render` on a Radix one) gets how that library composes Why it matters: Props from other libraries (`tone`, `isDisabled`, `isOpen`) do nothing or break the build. The rule suggests the prop this system uses, with its allowed values, and explains how Radix (`asChild`) and Base UI (`render`) compose when an agent mixes them up. Example: ```tsx
Active
``` - 2:10 error: has no prop "tone". Did you mean "variant" ("default" | "secondary" | "destructive" | "success" | "outline")? - 3:10 error: has no prop "isDisabled". Did you mean "disabled"? Fixed: ```tsx
Active
``` #### no-unknown-variant (error by default) Variant props (cva variants and string-literal unions) only accept their declared values. Catches: Values outside a cva variant or literal union (`variant="danger"`) Suggests: Allowed values and a synonym match (`danger` → `destructive`, `small` → `sm`) Why it matters: `variant="danger"` on a `Button` that only knows `destructive` renders without that variant's styles, so the mistake looks like a working button. The rule lists the allowed values and maps synonyms across libraries (`danger` → `destructive`, `small` → `sm`). Example: ```tsx ``` - 1:17 error: "danger" is not a valid variant for ``` #### icon-button-accessible-name (error by default) Buttons whose only content is an icon need an aria-label (or visually hidden text). Catches: Buttons whose only content is an icon (from an icon library or icons module, or named like one: `XIcon`, `Trash2`), with no `aria-label`, `aria-labelledby`, `title` or visually hidden text. A child with text in its props (``) or an unknown component makes it skip; a button passed as Base UI's `render`, conditionally too, is judged by its own and its host's children, a render-prop child (`{() => }`) by what it returns, and hidden buttons and a story's `args.render` are skipped Suggests: `aria-label`, guessed from the icon (`Trash2` → "Delete") Why it matters: A button with only an icon is announced as just "button" to screen-reader users. It is the most common accessibility slip in real apps: `check` found 11 in vercel/ai-chatbot and 105 in midday's dashboard. The fix guesses the label from the icon (`Trash2` → "Delete"). Example: ```tsx ``` - 1:2 error: Icon-only ``` #### How fixes are chosen Color matches under ΔE 0.02 count as the same color; under 0.1 the fix is offered; beyond that the message names the nearest token but leaves the choice to the agent. A fix is only offered for a token of the same hue (within 15°), or a gray for a gray, whether the color is a palette class, an arbitrary value or a literal: a pale color counts as a hue by its chroma relative to its lightness, so `bg-[#fef2f2]` is never swapped for a light gray that happens to be close. Status colors (`destructive`, `warning`, `success`, `info` and their foregrounds) are only offered for nearly the same hue (within 8°), never for a gray, so a brand-orange button does not become `bg-destructive`. Among the tokens that qualify, the one made for the utility wins: `foreground` and `muted-foreground` for `text-*`, `fill-*` and `stroke-*`; surfaces such as `muted` for `bg-*`; `border`, `input` and `ring` for `border-*` and `ring-*`. `sidebar-*` and `chart-*` tokens are only suggested in a sidebar or a chart (by file, enclosing component or classes), and so is any other family that at most one design-system file uses, such as syntax-highlighting colors or one screen's background, outside that file's components; `dark:` classes are compared with dark-mode values. Likewise, a spacing or radius step that is off by more than half the value (and more than 4px) is suggested but not auto-fixed. Rules that need tokens are skipped when the design system defines none of that category; a stylesheet that imports `tailwindcss` brings Tailwind's default spacing unit and radius scale, unless the theme resets that namespace (`--spacing-*: initial`, `--radius-*: initial`), in which case only the project's own steps are suggested. Syntax errors are reported as `syntax`, and so is code nested too deeply for TypeScript's parser (thousands of levels), which is then not checked. #### Suppressing findings A comment turns rules off where a finding is intended, and says why after `--`: ```tsx // onsystem-disable-next-line no-hardcoded-color -- the brand's exact orange

// onsystem-disable-line no-hardcoded-color {/* onsystem-disable-next-line no-hardcoded-color, no-hardcoded-spacing */}

``` `onsystem-disable-next-line` covers the next line, `onsystem-disable-line` its own, and `/* onsystem-disable */` the rest of the file (at the top, the whole file). Without rule names they turn every rule off. A misspelled rule name is reported as a `suppression` warning, with the closest one, and suppresses nothing; syntax errors cannot be suppressed. For whole folders or kinds of files, such as legacy screens or generated code, use `overrides` in the [config](https://onsystem.vercel.app/docs/configuration#config-file). ## Tools The MCP server is the part an agent asks before it writes. It serves five read-only tools, two resources and a prompt over stdio. All tools are read-only, have zod-validated input schemas with size limits (up to 1,000,000 characters of code for `check_ui`), and return compact Markdown for the model plus JSON `structuredContent` (with an output schema) for programs. | Tool | Input | Returns | | ------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `list_components` | `path?` | Every component with a one-line description, the element it renders, variant values, parts and its import | | `get_component` | `name`: `Button`, `CardHeader` or `Card.Header`, `path?` | Import, props (types, defaults, JSDoc), cva variants and the classes each applies, parts, tokens used, docs and examples | | `search_components` | `query`, `limit`, `path?` | Components ranked for an intent such as "confirm a destructive action" | | `get_tokens` | `category?`, `query?`, `path?` | Tokens with resolved values, dark-mode values and usages (`bg-primary`, `var(--primary)`) | | `check_ui` | `code` or `path`, `filename?`, `limit?` | Diagnostics with rule id, 1-based range, message, suggestion and edit-based fix (up to `limit`, 50 by default, errors first, with totals per rule), and a notice when no components or color tokens were found | **At a monorepo root** ([how projects are found](https://onsystem.vercel.app/docs/configuration#monorepo-roots)), `path` picks the project: the file the agent is editing, or a project folder, relative to the root. `check_ui` takes the project from its `path`, or for `code` from `filename`, the path the code will be saved under; without either, and with more than one project, it asks for `filename`. Without a `path`, `list_components`, `search_components` and `get_tokens` answer for every project together, each component and token tagged with the package it comes from and the projects that use it, and `get_component` answers when the name is one component, or lists the components of that name and the `path` that picks each. In a single project, `path` is ignored. When the server was started outside the project and the client reports no roots, an absolute `path` (or `filename`) is how it finds the project: see [how the server finds the project](https://onsystem.vercel.app/docs/clients). Resources: `ds://components/{name}` (Markdown, with name completion) and `ds://tokens` (JSON). Prompt: `build-with-design-system`, which takes a `task` and walks the agent through search, contract, tokens and `check_ui`. In Claude Code it is a slash command, `/mcp__onsystem__build-with-design-system` for a server added as `onsystem`. What the agent sees, from the [demo](https://github.com/dgesteves/onsystem/tree/main/examples/shadcn-demo): ```text > get_component { "name": "Badge" } # Badge A small status label: counts, states ("Active", "Overdue") or categories. import { Badge } from "@/components/ui/badge" Renders · components/ui/badge.tsx:29 · docs: docs/badge.md ## Props - variant?: "default" | "secondary" | "destructive" | "success" | "outline" = "default" - asChild?: boolean = false — Render the child element with badge styles instead of a ``. - …plus 280 props from React.ComponentProps<"span"> (onClick, id, role, children, aria-*, data-*, …) ## Variants variant (default "default") default border-transparent bg-primary text-primary-foreground destructive border-transparent bg-destructive text-white success border-transparent bg-success text-success-foreground … ``` ```text > check_ui { "code": "" } snippet.tsx: 2 errors, 1 warning 1:17 error [no-unknown-variant] "primary" is not a valid variant for