Skip to content

The agent surface

Tabula treats "an AI coding agent can use this project correctly without guessing" as a build requirement, not an afterthought. Every artifact below is generated by tabula build — none of it is hand-maintained, so it cannot drift from the registry the way a hand-written style guide would.

llms.txt and llms-full.txt

.tabula/llms.txt is the short form: the Ten Laws (see concepts.md) plus a map of which file to read for what. It is capped at 2048 bytes — tabula build throws if the rendered file would exceed that budget, so it can never silently balloon past what a model reliably attends to in a system prompt. Read it once, at the start of a session:

# Tabula — flat styling profile for reference-ui@1
## The ten laws
1. Only classes in vocabulary.txt exist; anything else emits NO CSS, silently. ...
...
## Files (.tabula/)
- vocabulary.txt — every legal class. READ BEFORE WRITING A CLASS.
- tokens.resolved.json — every literal, per axis. READ BEFORE CHOOSING A VALUE.
- registry.json — class → declarations, slots, rank. Ground truth.
- llms-full.txt — full reference (vocabulary, merge, bans).

.tabula/llms-full.txt is the long form, organized under stable ## headings so a retrieval step can pull one section instead of the whole file: ## The ten laws, with the reason each exists, ## The merge algorithm (worked by hand, step by step), ## Worked examples, ## Vocabulary — <family> (one section per class family, every class's real declarations and description), ## Banned mechanisms, ## Error codes (every TAB-Exxx/TAB-Wxxx with its cause and fix), and ## Exceptions — do not imitate these.

The MCP server

@tabula-css/mcp (binary tabula-mcp) is a read-only stdio server over your project's .tabula/ artifacts. It refuses every tool call with a STALE_REGISTRY result (plus the fix command) if the tokens or config changed since the last build, so an agent is never served an answer from a registry that no longer describes the project. Every response carries { profileVersion, sourceHash, stale, staleCheck? } — see Staleness and the artifacts-only check for what the last field means and why an agent must read it.

ToolAnswers
resolve_classes"What does this element actually look like?" — the full local styling model for a class string: base declarations, conditional bands, ambient properties, atomic/unknown classes, declared group dependencies.
preview_merge"What will this cn() call produce?" — the merged string plus every class that got dropped, what shadowed it, and on which CSS property.
find_class_forReverse lookup by intent — the highest-value tool here. { intent: "raised card background" }bg-surface-raised. Matches real class names, families, declared values, and token descriptions; never a synonym table or a fuzzy score.
get_tokensEvery token (or a namespace/substring slice), straight from tokens.resolved.json.
get_vocabularyThe full class list, paged (never silently truncated — a hasMore/pages field says so explicitly).
explain_banWhy a class is banned or unregistered, with its replacement — never a bare "not found", which is what pushes an agent toward inventing an arbitrary value.
find_group_markerWhere a named group/<name> marker is declared, so a cross-element dependency is a lookup, not a tree walk.
check_classesPre-flight: call before writing a class attribute. Reports unknown classes (with did-you-mean), banned mechanisms, and same-string slot conflicts.
resolve_elementPoint at a JSX element by { file, line, col } and get its own resolved classes, the text context it inherits from its same-file ancestors, and whether each group/peer it references has a marker in that file. At a component boundary it returns status: "unknown" with the next file to open — it never guesses what a component renders.
validate_sourceLint a source string with the real strict ESLint config against your registry. Catches what check_classes structurally cannot, because it sees the AST: runtime class construction, className ordering, group/peer structure, inheritance boundaries, exception scope. Inline eslint-disable comments are ignored, so a snippet cannot talk its way to a clean verdict.
propose_exceptionValidates a proposed exception through the real token validator and returns a patch — writes nothing. Also nudges toward an existing token if one is within ~5% of the requested value.
propose_tokenThe same for an ordinary token — the one you should reach for first, since a token is permanent vocabulary and an exception is paperwork with an expiry. Validates by splicing into your real token document and running the real validator, so an incomplete axis map or a meaningless value is rejected here rather than at build time. Returns a patch — writes nothing.
doctorThe health check: staleness, drift, expiring exceptions, escape budget — answered even while stale, because refusing the staleness reporter for being stale would never say what drifted.
explainA TAB-Exxx/TAB-Wxxx code's cause and fix, from the frozen catalog in @tabula-css/core.

Every tool follows one rule without exception: never return a value that was inferred rather than read. An unknown class is reported with a suggestion, never silently corrected; a value outside a nudge's tolerance is met with silence, not a wrong guess. The failure this whole surface exists to prevent is a model confidently emitting a plausible value it never looked up — an MCP tool that guesses would reproduce that failure with a tool call's authority behind it.

Two limits worth knowing before you rely on them.

resolve_element is a same-file analysis, and says so rather than papering over it. A component ancestor, or an ancestor whose class string is built at runtime, ends the walk with status: "unknown" plus a named next step. That is the honest answer: what <Card> renders is not knowable from the file that uses it, and a tool that guessed would be wrong in exactly the cases that matter. It also omits axisValues entirely — which axis combination an element renders under is a fact about the running document, not the source, so it points you at resolve_classes with an explicit axes argument instead of quoting the default combination's numbers under a field name that implies otherwise.

validate_source needs eslint and @typescript-eslint/parser installed (they are optional peers of @tabula-css/mcp). Without them it returns LINTER_UNAVAILABLE and the install command — never a partial registry-only check dressed up as a source lint. Its answers also carry ruleCount, so you can see the verdict came from N real rules rather than from an empty config; ok: true with ruleCount: 0 would be a rubber stamp, and this is what makes that visible.

Staleness and the artifacts-only check

Staleness is recomputed on every request (the host re-reads .tabula/ when the artifacts' fingerprint changes), so the edit-then-rebuild loop is safe: an agent that edits a token, runs tabula build, and asks again gets the new answer from the same server, without a restart.

The check has three legs:

  1. Self-consistencymanifest.inputsHash vs. registry.sourceHash: were these artifacts written by one build?
  2. Integrity — each artifact's sha256 vs. the manifest's digest: was one hand-edited, or a build torn mid-write?
  3. Source drift — the tokens and config on disk, re-hashed, vs. manifest.inputsHash. This is the only leg that catches "a token was edited and never rebuilt" — the common case.

Leg 3 needs to resolve the installed versions of tailwindcss and the @tabula-css/* packages, because they are part of the inputs hash. When any of them cannot be resolved, leg 3 does not run. That happens with an isolated or pnpm-style node_modules layout, or when the project source is unreadable from the server's working directory.

The server does not paper over this. When leg 3 was skipped, every response carries:

json
{ "profileVersion": "…", "sourceHash": "…", "stale": false, "staleCheck": "artifacts-only" }

What an agent should do when it sees staleCheck: "artifacts-only": treat stale: false as weaker evidence than usual. It means "these artifacts are internally consistent and unmodified" — it does not mean "they match the current token file". A token edited since the last build will not be detected, and the answers you get will describe the pre-edit profile while claiming to be fresh. Before trusting a value in that state, run tabula build (or tabula build --check, which exits 1 on drift and writes nothing) and ask again. The field's absence is the strong case: leg 3 ran, and stale: false means the registry matches the source.

AGENTS.md.snippet

.tabula/AGENTS.md.snippet is a ready-to-paste block for your project's AGENTS.md or CLAUDE.md: the same rules as llms.txt, phrased as direct instructions, plus the live count of registered classes (Only classes in .tabula/vocabulary.txt exist (477 of them) in examples/reference-ui). Pasting it is the whole integration step — it is regenerated by every tabula build, so it never needs manual upkeep as the vocabulary grows or shrinks.

The fail-visible guarantee

An unregistered class is never a runtime error by default — the build's source(none) + @source inline(...) setup means Tailwind never scans for anything outside the closed vocabulary, so an unregistered class simply produces no CSS. This is deliberate and it is also the reason cn() behaves differently by environment:

  • Development (isDev() true): cn() throws on an unknown class, with the nearest registered names by Levenshtein distance (TAB-E300) — a typo fails your local build loudly, immediately, at the call site.
  • Production: cn() never throws. An unknown class is kept opaque — it owns no slots, is never dropped, never shadows another class — and logged once via console.error so it's visible without crashing a live render. It is also recorded for <TabulaAudit> (the dev-only DOM conformance checker) to catch during development.

The same asymmetry holds for dyn() (the sanctioned inline-style escape hatch): an unregistered --d-* key throws in development (TAB-E304) and is silently dropped in production. Nothing in this profile fails invisibly in development — every gate that matters (unknown class, merge soundness, unregistered dynamic property) is a loud throw at the point a developer or an agent would actually see it.

Released under the MIT License.