MCP for AI agents
Connect
One command, using an API key from Team settings → Integrations → Manage API keys:
claude mcp add --transport http grails https://api.grails.design/mcp/v1 \ --header "Authorization: Bearer grails_sk_XXXXXXXX"For editors that use a config file:
{ "mcpServers": { "grails": { "type": "http", "url": "https://api.grails.design/mcp/v1", "headers": { "Authorization": "Bearer grails_sk_XXXXXXXX" } } }}findEntities — when you cannot name it
findEntities({ query: "the destructive action button" }) by descriptionfindEntities({ relatedTo: "2072:9432" }) by an id you havefindEntities({ relatedTo: id, depth: 2 }) two hops outExactly one of query or relatedTo. Both, or neither, is an error that names both.
Both halves open on read:library. They needed different scopes until September 2026 —
relatedTo returns the tokens and styles a component binds, which name lookup never revealed —
and the split turned out to be the problem rather than the fix: the same key could reach those
tokens through this door and was refused them by listEntities({ type: "token" }).
With relatedTo you do not pass a type. The id determines it — and it can be a Grails id or a
Figma node id, interchangeably. The walk paginates with a cursor and does not tell you how many
are left: answering that would mean walking the whole graph for a question you might not ask.
With query, read coverage first. It reports how much of the catalogue each dimension reaches.
If semantic coverage is 3%, an empty result means almost nothing is indexed, not there is nothing
like it. There is no switch to turn the semantic dimension on: it always runs, and degrades on its
own when it cannot.
listEntities — walking the catalogue
listEntities({ type: "component" }) the catalogue, paginatedlistEntities({ type: "component", q: "button", filtered syncStatus: ["synced"], hasDocs: true })listEntities({ type: "component", fields: ["id","name"] }) fewer columns, same rowslistEntities({ of: "prop-candidates", what a slot accepts componentId: id, prop: "icon" })It filters, but it does not rank. Thirteen of its arguments narrow which rows come back — a name
substring, sync state, publish state, depth (exact, minimum or maximum), node type, parent set,
collection, name prefix, and whether an entity carries documentation, a spec or drift. type is not
one of them: it chooses which catalogue you are walking, and it is a closed list that takes no free
text. That is what keeps this apart from findEntities, which takes a phrase and ranks. q here is
a substring of the name, not a search.
Identity, not detail. You get id, name, type and sync status. The detail is getEntities, asked
for only where you want it — that split is the whole point of the pair.
Walk the whole set by passing pagination.next_cursor back as cursor. Do not build your own
offsets — there is no page number, by design, so a sync running mid-walk cannot make you skip or
repeat a row.
Arguments are camelCase; the response echoes snake_case. You pass hasDrift, syncStatus,
sortBy; filters_applied comes back with has_drift, sync_status, sort_by. It echoes the
filters that took effect after defaults, which is how you check the server understood you.
search_mode_used says which strategy actually served q.
Prop candidates come back whole, ready to instantiate: id, type, name, path, whether it is an asset, sync status and the Figma node id.
getEntities — one door, any entity
getEntities({ ids: ["2072:9432"] }) the abstractgetEntities({ ids: [id1, id2, id3] }) three at once, one callgetEntities({ ids: [id], include: ["include-props"] }) add a layerIds are interchangeable. Where an id goes, a Grails id or a Figma node id both work. If you are coming from Figma you have the second one and should not have to translate it.
Ask for several at once. ids is always plural, so one id and ten are the same call — and ten
ids in one call cost far less than ten calls. There is no separate batch tool to choose.
Layers accumulate; none replaces the abstract. include-props, include-tokens,
include-values, include-mentioned-by.
include-mentioned-by answers which documentation talks about this entity, and it is the only
layer that works on every type — anything documentation can name can be discussed, including types
that have no other layer at all.
A layer that a given type cannot serve is skipped rather than refused — a token has no properties,
and asking for them is not an error. Read included to see what was actually applied, which is how
you tell not applicable from empty. An empty included where you asked for a component layer
means that component has no extracted spec: re-sync it from the plugin, because asking again will
not change it.
Variants
For a component set the abstract lists every configuration it has under variants, each priced
on its own:
getEntities({ ids: [setId] }) → variants: [ { configuration: { Size: "Medium", Type: "Initial" }, est_tokens: 590 }, { configuration: { Size: "Large", Type: "Initial" }, est_tokens: 1130 }, … ]Nothing is truncated, and that changed on purpose. The list used to cut off at 20 and point the rest at a layer that returned every tree — so a caller who wanted configuration 21 had exactly one route: pull the whole document. Measured against the largest set in production: all 397 configurations cost 10,959 bytes against 771,103 for the trees — 1.4%. Listing them all is a rounding error next to the document it used to force you into buying.
variants_how closes the abstract with the exact, copy-pasteable calls to getComponentSpec — one
configuration, several, or ["all-variants"] — plus est_tokens_all, the cost of asking for
everything:
getComponentSpec({ componentId: setId, variants: [<one configuration above>] })getComponentSpec({ componentId: setId, variants: [<configurations above>] })getComponentSpec({ componentId: setId, variants: ["all-variants"] })Ids that resolve to nothing come back in unresolved, with the reason, and the rest still
return. Ten ids where one is wrong give you nine entities — not an error.
Recipes — the sequences that do real work
A single tool call rarely answers a real question. These are the paths worth knowing.
Build one component
getEntities({ ids: [componentId] }) the abstract — start heregetEntities({ ids: [componentId], include: ["include-props"] }) what it acceptsgetComponentSpec({ componentId }) the default treegetComponentSpec({ componentId, variants: [{ … }] }) one configurationThe first call gives you the abstract: id, name, figma_id, context, sync status, and — for a
variant set — every configuration that exists, each priced under variants. It is a fraction of the
cost of the whole document, and it is enough to decide what to ask for next.
The abstract carries neither props nor anatomy. include-props, on getEntities, is what
the component accepts. anatomy — the element trees — is not a layer of this tool at all: it lives
in getComponentSpec, a door of its own, because a layered call is stateless and would resend this
abstract on every variant it fetched. more tells you how many entities each remaining getEntities
layer covers and the exact call that fetches it; variants_how, on the abstract itself, does the
same for getComponentSpec.
To turn bindings into values, add include-tokens. data[].tokens comes back with data[] — each
variable resolved per mode, the alias chain in via — plus names_in_spec, the count of $token
names over the whole document, not_in_this_project, and style_refs. The style keys under style_refs
go straight to getEntities({ ids: ["S:…,"] }), which takes them raw.
Build something composed
getComponentBuildOrder({ componentId }) → ordered_components[] ← the component you asked for is LASTgetComponentSpec({ componentId: orderedIds[0] })getComponentSpec({ componentId: orderedIds[1] }) → one call per id — getComponentSpec does not batch across componentsBuild order returns dependencies first, so you can walk the list top to bottom and never
reference something you have not built yet. Each entry is { position, component_id, name, component_depth, is_leaf, grails_sync_status }.
One component per call, and that is on purpose. The build order of ten unrelated components is not a list of anything — you build one card at a time, and what the first teaches you changes what you ask next. It is also computed for your request rather than read from a stored column, so a batch would be N sequential computations hiding inside one response.
Fetching the trees afterwards costs one call per component too: getComponentSpec takes a single
componentId — it can batch variants of one component, never several components at once.
Find the thing you want to reference
findEntities({ query: "brand", types: ["token"] })Use it before writing documentation or any content that points at another entity. References are uuids, and this is the only way to turn a name into one.
It reaches all eight documentable kinds — components, tokens, styles, token collections, screens, flows, doc pages and the project itself — so the set of things you can find is the set of things you can document.
Document an entity
describeDocumentationBlockTypes() → the guide: the order of steps, every writable shape, a valid example of eachgetEntityDocumentation({ entityType, entityId }) → what is already there, plus `mentions`: what it references, resolvedwriteDocumentationBlocks({ mode: "append", → iterate here, this never writes entityType, entityId, blocks, dryRun: true })writeDocumentationBlocks({ mode: "append", entityType, entityId, blocks })Read before you write. writeDocumentationBlocks({ mode: "replace" }) replaces everything on the
entity — it does not merge and it does not ask. getEntityDocumentation({ entityType, entityId })
returns blocks in the same shape you send, which is what makes it possible to compare instead of
overwriting blind. When in doubt, append.
This writes documentation about something that already exists. Writing a standalone document — one with a title, a URL and a folder — is the next recipe, and the difference is spelled out under Two kinds of documentation.
Write a standalone page
listDocumentationPages() → what exists, and which folders existcreateDocumentationPage({ title: "Motion principles", → READ the `slug` it returns path: "Foundations" })writeDocumentationBlocks({ mode: "append", entityType: "doc-page", entityId: <the id it returned>, blocks })The page is created empty, and the blocks go in separately — the same writer that documents a
component, pointed at entityType: "doc-page".
List before you create. A folder is not an entity: the only way to know Foundations already
exists is to see a page carrying it, so inventing the name blind is how a project ends up with two
folders spelled the same.
Audit before a refactor
checkHealth() → what is running on an old capturelistEntities({ type: "component", hasDrift: true }) → the same set, paginatedcheckHealth({ entityIds }) → which variant combinations are missingBoth scales open on read:library. Until September 2026 the argumentless one asked for
read:drift — a scope the key-minting screen never offered, so no key anyone could create was
able to make that call.
The tools
getComponent
One component by id, with its derived signals: chi_status, has_docs, has_spec, has_drift.
A component deleted in Figma is still readable by id — check sync_status to tell.
getComponentSpec — reading the anatomy
The component as captured at sync time — anatomy, variantConfigurations, default, _grails and
everything else the sync extracted. It is its own tool, not a layer of getEntities, because a
layered call is stateless and would resend the shell on every request: a blind probe was metered
making seventeen consecutive calls for the variants of one component, paying again each time for an
index it already had.
Read the entity first. getEntities({ ids: [componentId] }) returns the shell: every
configuration this component has, each priced under variants, plus variants_how with the exact
call for one, some, or all of them. Come here with that list and ask for exactly what you decided you
need.
getComponentSpec({ componentId }) the default treegetComponentSpec({ componentId, variants: [{ "Size": "Medium" }] }) one configurationgetComponentSpec({ componentId, variants: ["Size=Medium"] }) the Figma spelling works toogetComponentSpec({ componentId, variants: ["all-variants"] }) every tree — no cap, no pagingcomponentId accepts a Grails id, a Figma node id or a name — interchangeably, as everywhere else on
this surface. variants takes exactly one of four shapes, and they do not mix:
| you pass | you get |
|---|---|
nothing, or ["default"] | the default tree alone — the cheap answer to “what does this look like” |
| a list of configurations | those trees, and only those |
["all-variants"] | every tree — the read costs this server the same either way, and you already have the price from variants_how.est_tokens_all |
A wildcard cannot be mixed with configurations — ["all-variants", { "Size": "Medium" }] is a
400 GRAILS_INVALID_FILTER, because the two readings serve very different amounts and the server
will not guess which one you meant.
A configuration that does not exist is a 404 naming it, not an empty result:
{ "error": { "code": "GRAILS_NOT_FOUND", "message": "No variant of this component matches [{\"size\":\"huge\",\"type\":\"success\"}]", "details": { "missed": [{ "size": "huge", "type": "success" }], "how": "getEntities({ ids: [...] }) lists every configuration this component has" } }}A typo and a component that genuinely lacks that combination look identical otherwise, and only one
of them is worth another call. Copy configurations straight out of the abstract’s variants; names
and values match case-insensitively.
The response is one document, not a batch: { component_id, requested, spec, spec_schema_version, served }. served echoes what was actually returned — "default", or the list of configurations —
so a caller tracking several calls does not have to re-derive it.
getComponentBuildOrder — what to build first
getComponentBuildOrder({ componentId })Every component it depends on, transitively, ordered so that everything an entry needs appears before it — and the component you asked for last. Iterate the list top to bottom and you never reach something whose parts you have not built.
componentId takes a Grails id or a Figma node id. A name is not an id — it comes back as a
404 for a component that exists. Find one with findEntities({ query }) first.
One component per call. The build order of ten unrelated components is not a list of anything: you build one card at a time. The order is computed for your request rather than read from a stored column, so a batch would be N sequential computations hiding inside one response.
| Field | What it is |
|---|---|
status | computed (it has dependencies) or trivial (it depends on nothing) |
ordered_components | The list. Never null, never []; the component asked for is last |
grails_sync_status | Per entry. deleted_in_figma means that step cannot be built |
dependency_count | ordered_components.length - 1 |
requested | Echo of the id you sent — component_id carries the resolved Grails uuid, which is not what you sent |
unresolved_deps | Always [] today — see below |
Each entry is { position, component_id, name, component_depth, is_leaf, grails_sync_status }.
position is 1-based and agrees with the array order. component_depth may be null — the sync
has not ranked that component — and it is never 0, because real leaves are depth 1. The order does
not depend on it: it is computed from the dependency edges themselves.
A component that does not exist in this project is a 404. An id that resolves to something that is
not a component — a token, a style — is also a 404, with GRAILS_COMPONENT_NOT_FOUND, because the
remedy differs: you named the wrong kind of thing rather than a thing that is not here.
checkHealth
Says whether what this server returns is current with Figma. Everything here comes from a sync, not from Figma live: between the last sync and now, a designer can have changed anything.
The two scales answer different questions and come back in different shapes. With no arguments:
the project-wide picture, under summary — when the last sync ran and how many entities have
drifted since. With entityIds: a coherence check on those entities, under entities and shaped
{ valid, issues, checked } — no sync timestamp and no drift count in it. The two never share a
response.
checkHealth() → summary: { minimal_spec_version, drifting_count, components, truncated }checkHealth({ entityIds }) → entities: [{ valid, issues, checked }]The project-wide scale reports every component whose spec was captured before the minimum supported schema version — the ones whose data may be incomplete relative to what the platform extracts now.
The scoped check finds things the project-wide one cannot. missing_variant is the one to know: a
variant set that declares a combination and does not have it in Figma. A component can report
twelve of those while the project reports drifting_count: 0 — they are different faults, and
drift does not cover completeness.
Components only, despite the name. A token id comes back valid: false with no_spec, which
answers a question you did not ask; a Figma node id is rejected. Pass Grails component ids.
spec.invalidVariantCombinations is ignored here. It is derived as cartesian − variants[], so
subtracting it from that same difference would always leave the empty set — and Figma offers no way
for a designer to mark a combination invalid, so it never carried intent to begin with. A component
with no variant properties is reported as not_a_variant_set and does not make the set invalid,
so a mixed list of atoms and variant sets validates cleanly.
Ask it before building on what you read, not after. And before an expensive decision — generating code, replicating a whole component — ask it for those entities specifically.
findEntities
Find any documentable entity by term or concept, and get its id.
component · token · style · token-collection · screen · flow · project · doc-pageThose eight are exactly the entityType values the documentation tools accept, so anything you
can document, you can find — which is what makes a coverage audit possible. entity_types
defaults to all eight; narrowing is worth it twice over, because each type is a separate query and
coverage costs one count per requested type.
It replaces searchEntities, which was this tool running one of its three engines.
Three engines, and the result says which matched
| Engine | Over what | Finds |
|---|---|---|
semantic | the entity’s indexed documentation, by vector similarity | meaning — text input → Input Field |
text | the same documentation, full-text | the words the documentation actually uses |
name | the entity’s name, a literal substring | the name, and only the name — but over 100% of the catalogue |
They are fused with Reciprocal Rank Fusion, which combines rankings, not scores: a cosine similarity and a text rank do not live on the same scale, and averaging them would mean choosing a normalisation nothing in the data justifies.
Every match carries matched_by (a list), surfaces (how many engines found it) and signals,
where each raw figure stays tagged with its engine and is never fused into one number.
There is no confidence
score is a fused reciprocal-rank sum: comparable within one response, meaningless across
responses, and it supports no threshold. Neither does a cosine — it measures an angle, not a
probability.
What answers should I choose, or ask again? is shape.verdict, per group:
one_stands_out— the top match beat the second on evidence you can re-derive from the same response: more engines found it (surfaces), its name matched in a stronger tier (signals.name.matched:exact>segment>prefix>substring), or its cosine led by at least 0.05.flat_ambiguous— none of that held. The query was ambiguous: ask a narrower question instead of taking the first row.
Grouped by type, and honest about what it cut
Results are grouped by entity type, because your next call depends on the type: a component takes
getComponentSpec({ componentId }), a token takes include-values. A type you asked for that is absent from
groups matched nothing — filters_applied.entity_types echoes what was actually searched.
Each group carries truncated: { shown, total }, and each match carries excerpt — the fragment of
documentation that matched, so you can judge a hit without spending another call. An excerpt of
null means the match came from the name alone.
Each hit also carries context, and what it holds depends on the type — a token’s collection, a
screen’s Figma node id, a doc page’s slug. It is null when nothing in that table tells two
same-named rows apart.
For a doc page that context is not decoration — it is the handle. Pass it straight back as
entityId to getEntityDocumentation or writeDocumentationBlocks, or as page to
getDocumentationPage, no uuid needed. It is how you learn a slug without knowing one in advance;
read the limits under getEntityDocumentation before storing it anywhere.
sync_status is null for screen, flow, project and doc-page. That means the concept
does not apply, not “unknown”: only the four Figma-synced kinds have a deleted-in-Figma state, so
include_deleted does nothing for the other four.
Names, ids and a documentation excerpt. No token values, no bindings. It does not search Figma keys
— for a style key out of a spec, use getEntities({ ids: ["S:…,"] }).
getEntityGraph · findEntities({ relatedTo })
Two tools over the same edges, answering two different questions.
getEntityGraph({ entity_type, entity_id }) the EDGES that touch this node, with their metadatafindEntities({ relatedTo: entity_id }) the ENTITIES reachable from it, with hops and viagetEntityGraph is the one with metadata — a token binding carries property, variants and
nodeNames, so the answer is not “this component uses this token” but “in itemSpacing, on the
Mobile variant, in the Card Grid Image node”. One hop, raw. It is also the one that still takes
entity_type and entity_id as a pair, in snake_case.
findEntities({ relatedTo }) deduplicates and walks. Every result in related[] carries hops —
a number, not a “direct / indirect” label — and via, the route: one entry per edge crossed, naming
the edge type, the direction, and (on every step but the last) the node it passed through. That is
the difference between “this component binds the token” and “a style this component uses binds
it”, which a label cannot express. The response also echoes what it resolved your id to, under
origin and resolved.
relatedTo takes no type. The id determines it, and a Figma node id works where a Grails id
does. Both tools default direction to both: with one node and no direction stated, returning one
side would hide half the graph. Ask inbound for the impact question — what uses THIS.
describeTokens · listEntities({ type: "token", includeValues: true })
Three layers, because serving the whole token tree costs on the order of 58,000 model tokens and the job in front of you usually needs five values.
describeTokens() the map — collections, modes, counts, the pricelistEntities({ type: "token", collection, the index — ids and names, no values nodeType, namePrefix })listEntities({ type: "token", includeValues: true }) the values — per mode, alias chain in `via`describeTokens is not going anywhere: it takes no ids, so getEntities cannot absorb it. It
answers “what is there?”, which is a different question from “what is this?”.
The token index moved into listEntities, where nodeType is the Figma type — COLOR, FLOAT,
STRING, BOOLEAN — because type now selects the entity family.
Values come with includeValues: true, and they are scoped to the page — the rows limit
returned, not everything the filter matches. A values payload that ignored limit would be an
unbounded response wearing a paginated shape, and a project-wide dump of every value is the most
expensive thing this API can produce. Ask without the flag and more tells you what adding it
costs.
A value is null only when unresolved says why, and the reasons are actionable —
ambiguous_target_mode carries the value under each candidate mode, so the answer is a set rather
than a failure. Colours come back twice: value is hex rounded to 8 bits per channel, rgba is
exactly what Figma stored.
listEntities({ type: "token" }) with no further filter returns the first page and the
describeTokens map, under unfiltered, rather than the whole index. Page order is by internal id
— stable, not alphabetical.
describeStyles · listEntities({ type: "style", includeValues: true })
The same three layers for styles, on the same read:library scope. Styles are the other half of
one question — what is the thing this component binds actually worth?
describeStyles() the four types, their counts, the price of each layerlistEntities({ type: "style", nodeType, the index — and `figma_style_id`, the raw spec key namePrefix })getEntities({ ids: ["S:…,"] }) the propertiesdescribeStyles, like describeTokens, takes no ids and is not absorbed by anything. Here
nodeType is TEXT, PAINT, EFFECT or GRID.
Style properties come from listEntities({ type: "style", includeValues: true }), page by page, or
from getEntities({ ids, include: ["include-values"] }) when you already have the ids.
properties is passed through exactly as Figma stored it, and its keys depend on type:
TEXT carries fontName, fontSize, lineHeight, letterSpacing, textCase, textDecoration,
paragraphIndent and paragraphSpacing; PAINT carries paints[]; EFFECT carries effects[];
GRID carries layoutGrids[]. Read the keys rather than assuming them — the set varies with what
the design file contained.
bound_variables lists the properties driven by a token rather than by the literal beside them.
Where one is present, the literal is what that token was worth at the last sync; follow
token_id into getEntities({ ids, include: ["include-values"] }) for the live value. A token_id of null means no variable in
this project carries that key.
Two kinds of documentation, and the names now say which
Everything below writes into the same block format, but there are two different subjects, and picking the wrong one is the mistake this vocabulary exists to prevent.
| Documentation OF an entity | A documentation page | |
|---|---|---|
| What it is | What a team wrote about a component, a token or a style — when to use it, when not to, accessibility decisions | A standalone document, with a title, a URL slug and a folder |
| Where it lives | In Library and Foundations, attached to the thing it describes | In the docs section, as a document of its own |
| Who organises it | Nobody. It hangs off its entity and goes wherever the entity goes | The team. Pages are created, renamed, moved between folders and deleted |
| Read it with | getEntityDocumentation({ entityType, entityId }) | listDocumentationPages · getDocumentationPage |
| Write it with | writeDocumentationBlocks({ entityType, entityId, … }) | createDocumentationPage first, then the same writeDocumentationBlocks with entityType: "doc-page" |
A page’s content is blocks, exactly like an entity’s, and one writer writes both. What a page has and an entity does not is an identity of its own — a title, a URL, a place in a folder — and that is the whole of the difference.
describeDocumentationBlockTypes
describeDocumentationBlockTypes() → the guide, and every writable shapedescribeDocumentationBlockTypes({ section: "criteria" }) → how to decide WHICH shape content belongs indescribeDocumentationBlockTypes({ section: "shapes" }) → the exact JSON of each shape, field by fieldOne thing where there used to be two: how documentation is organised in this system, the order of the steps, which skill covers each part, and every block type that exists with what it means. Start here — it is a callable tool and not only a document because an MCP client always enumerates tools but may never enumerate skills. Read it before writing for the first time: blocks are typed, not markdown, and guessing the shape wastes a write.
Each type under types carries:
| Field | Why you need it |
|---|---|
description | What the shape does. Present on every type |
question | The selection criterion — pick the shape by the question the reader came to ask. Optional: sections has none, and the absence is the signal. Any question that describes the catch-all matches everything, which is precisely how an agent ends up putting every block in it |
whenNot | The half that is usually missing — and where it says to go instead |
example | A complete block you can pass to writeDocumentationBlocks({ dryRun: true }) unchanged. It is checked against the same validator that runs on write |
allowedOwners | null means no restriction. It is not an empty list, which would mean the opposite |
It also returns categories — the other axis.
The authoring guide, fetched by name
The answer closes with more.authoring_guide, which announces two sections and what each costs. They
are not attached by default: together they are around 4,850 model tokens, and an agent that
already knows which shape it wants should not pay for them.
section | Answers | Ask for it when |
|---|---|---|
criteria | Which shape a piece of content belongs in, and why — the two axes, the boundaries between shapes that look alike, the mapping heuristics, the antipatterns | You are mapping content you did not write |
shapes | The exact JSON of every shape, field by field, with what the validator rejects — plus the alt discipline, the data/text split and how references work | You are about to write the JSON |
The split is by moment, not by file: choosing a shape and writing one are different questions asked at different times.
getEntityDocumentation
getEntityDocumentation({ entityType, entityId }) → the documentation on one entitygetEntityDocumentation({ entityType: "doc-page", entityId: "brand-guidelines" }) → a doc page's blocks BY ITS SLUGBoth arguments are required now, and one without the other is a 422 whose message names
describeDocumentationBlockTypes. Omitting them used to return the guide, which is how one name came
to answer two questions — and it is exactly what the rename undid.
The documentation a team wrote about an entity: when to use it, when not to, accessibility
decisions, content rules. It is human intent, not data extracted from Figma — which is why it
lives in its own tool and not as a layer of getEntities.
It returns that entity’s blocks, ordered, in the same shape you write. Call it before writing.
A doc page can be named by its slug, and it is the only thing here that can. Every other entity is addressed by a uuid or a Figma id; a component name moves whenever a designer renames it. The slug does not: it is not a field of the edit action, so renaming the page title leaves it alone.
⚠️ That is not the same as “it can never change”, and the difference matters if you are storing one. Changing a page URL breaks every link anyone already shared, so it is deliberately a separate operation with its own confirmation — one that does not exist yet, and is expected to.
So the contract is not immutability. It is this: a slug never silently becomes someone else.
- A rename of the page does not move it. Only an explicit URL change would, and that is a decision a person takes on purpose.
- A slug that does not resolve is a
404naming it, never a silent empty result and never a different page. That is what makes the day it changes legible instead of wrong. - Unique per project, not globally. Two projects may hold the same slug. The uuid stays the canonical id, and is what to store if you need one that cannot move at all.
⚠️ A deleted page frees its slug. Nothing reserves it, so a slug you stored can later resolve to
a different page with no error at all — which is why deleteDocumentationPage refuses one.
Documentation is sparse by nature and that is normal: no team documents everything. An entity without documentation is not an anomaly and not something to report.
Next to blocks comes a second branch — mentions, the entities this documentation references,
already resolved:
{ "blocks": [ … ], "mentions": [ { "entity_type": "component", "entity_id": "f2809b69-…", "count": 2, "name": "Section Button Group S", "exists": true }, { "entity_type": "token", "entity_id": "0f8b1e2a-…", "count": 1, "name": null, "exists": false } ]}| Field | |
|---|---|
entity_type · entity_id | What the reference points at. The same pair every other tool takes |
count | How many times it is mentioned across all the blocks of this entity, not one |
name | Resolved, so a reference does not cost you a lookup each. null when exists is false |
exists | Whether it still points at something in this design system |
It is a branch beside the blocks and not a field inside them, so reading the text alone costs you
nothing extra. Each block still carries its own raw mentions — that is the only thing that tells
you which block references what, which the merged branch cannot say.
The branch is always there, [] included. A branch that shows up only when it has data is
indistinguishable from one that does not exist — which is exactly how this capability went unused:
the raw field travelled in every response for months and no tool named it.
writeDocumentationBlocks
writeDocumentationBlocks({ mode: "append", entityType, entityId, blocks }) adds at the endwriteDocumentationBlocks({ mode: "replace", entityType, entityId, blocks }) replaces, and loses what was therewriteDocumentationBlocks({ mode: "delete", entityType, entityId, blockIds }) removes blocks by idwriteDocumentationBlocks({ …, dryRun: true }) validates without writingThis is the only write of documentation CONTENT on this entire server — nothing else here modifies the design system, which stays read-only and is fed by the Figma sync. Creating, moving and deleting a page are their own operations: they touch the page row, not its blocks.
It is also how a page gets its content: pass entityType: "doc-page" and the page’s id or slug.
mode is required, and it is the one thing to get right:
mode | What it does |
|---|---|
append | Adds at the end. The safe one. Deletes nothing |
replace | ⚠️ Destructive. Deletes every existing block on the entity and writes the ones you pass. No merge, no confirmation, no undo |
delete | Hard delete by id — there is no trash for this. Requires blockIds |
When in doubt, append.
Give each block a category. It is what the block is ABOUT — usage, accessibility,
dev_specs, content_guidelines, brand, general — and it is how a reader finds the part they
need instead of reading everything. describeDocumentationBlockTypes() lists them under
categories.
Documentation pages
listDocumentationPages() → the pages, and the folders they reveallistDocumentationPages({ path: "Foundations" }) → only the pages in that exact foldergetDocumentationPage({ page }) → one page, whole: its identity AND its blockscreateDocumentationPage({ title, path?, slug? }) → a new, EMPTY pageupdateDocumentationPage({ page, title?, path?, → rename, move, edit the abstract or icon abstract?, icon? }) (`page` is the uuid, not the slug)deleteDocumentationPage({ page }) → ⚠️ hard delete: the page, its blocks, its filesrenameDocumentationPath({ from, to }) → rename a folder across every page in itclearDocumentationPath({ path }) → unfile every page in a folderA page is created empty. createDocumentationPage writes the row and nothing else; the content
goes in afterwards with writeDocumentationBlocks({ entityType: "doc-page", entityId: <the id> }).
Until these existed, only blocks could be written — into pages somebody had made by hand in the web
app — so an agent with something new to document had nowhere to put it.
listDocumentationPages paginates: limit is 1–100 and defaults to 20 (above the cap it truncates
rather than failing), and cursor is the next_cursor of the previous response. path filters on
an exact folder, not a prefix: a folder is a whole string, and there is no nesting for a prefix
to mean anything against. getDocumentationPage takes the uuid or the slug.
A folder is not an entity
It is the value of a page’s path, and it exists exactly as long as some page carries it. There
is no table, no parent_id and no hierarchy — and three things follow that nothing else would tell
you:
- There is no “create a folder”, because it is not an operation. A folder happens as a side
effect of filing the first page into it: pass a
pathno page carries yet and it exists from then on. An empty folder cannot be represented at all. - There is no folder catalogue to query.
listDocumentationPagesreturns apathsbranch derived from the rows it returned, and it is the only surface that aggregates the folders of a project — the only one at all for a key carrying justread:docs. Call it before moving a page anywhere. (listEntities({ type: "doc-page" })andfindEntitiesdo carry each page’spathon its row, but both takeread:libraryand neither adds them up for you.) updated: 0from a rename is a valid answer, not an error. A folder that “does not exist” is simply a string no page carries, and answering404would invent an entity this model does not have.
null is a member of paths rather than something dropped from it: “some pages here are unfiled”
is a fact you act on, and it is the state path: null and clearDocumentationPath leave a page in.
Uploading an image or a file
An image or file block points at stored bytes. Nothing is visible until a block references
them: an uploaded object nothing points at is an orphan the collector eventually clears.
uploadDocumentationImage({ entityType, entityId, → ONE call. The image as base64, or a contentType, data }) `sourceUrl` on the allow-listprepareDocumentationUpload({ kind, entityType, → a URL for YOU to PUT the bytes to entityId, contentType, sizeBytes })completeDocumentationUpload({ r2Key }) → measures what landed, settles the reservationcompleteDocumentationUpload answers with the measured size, which may differ from what you
declared: a presigned URL signs the key and the content type but not the size.
Then write the block
writeDocumentationBlocks({ entityType, entityId, mode: "append", blocks: [ { type: "image", category: "usage", content: { r2_key: "<what the upload returned>", layout: "below", i18n: { es: { alt: "…" } } } }]})alt is required. describeDocumentationBlockTypes() returns the authoring guide, which says what makes a good one.
A file’s text is extracted afterwards, and that is not your problem
The upload is complete whether or not the extraction works. To see how it went, read the block:
extraction.status is done, no-text (a scanned document — it FINISHED and there was no text),
unsupported or failed, and chars says how much came out. There is no separate tool to ask.
The extracted text itself never comes back to you: it feeds the search index, and serving it would add up to 30,000 characters to every documentation read.
The documentation contract
What a block looks like
{ "type": "guidelines", "category": "accessibility", "content": { "…": "the shape of `type`, from describeDocumentationBlockTypes()" }}Three fields, and only three. type is the shape and category is the subject — they are
orthogonal: a guidelines block can be accessibility or content_guidelines, and a specs can
be either too.
categories comes from describeDocumentationBlockTypes(). A category outside that set is rejected with 400 naming
the value — it used to fail the whole insert with an error that named nothing.
What you must NOT send
mentions and version are not parameters. The server computes both.
mentions is derived data with a single writer: it is recalculated from the content on every
write. A client that supplied it would be asserting a relationship its own text does not make — and
a mention can be a warning ("do not use X here"), where an edge would claim the opposite of
what was written.
position is not a parameter either. The order of the array is the order of the blocks.
Referencing another entity
Inside a rich text field, a reference is a marker:
Use {{component:9f3a…}} for navigation instead.The uuid comes from findEntities. Never invent one. The marker carries type and id, never
the name — the label you see is the entity’s current name, resolved at render time, so a rename
in Figma updates the documentation on its own. If the entity is gone it renders as a broken
mention, which is a useful signal rather than an error.
All or nothing, per request
Every write validates completely before touching anything. If one block in the array is invalid, nothing is written and the error names the block index and the field.
writeDocumentationBlocks({ mode: "replace" }) runs its delete and its insert in one transaction,
so a failure mid-write leaves
the entity exactly as it was — never empty.
Errors you can act on
| Status | Means |
|---|---|
400 GRAILS_VALIDATION_FAILED | Your payload. The message names the field, and details.errors carries the block index |
403 GRAILS_AUTH_INSUFFICIENT_SCOPE | The key lacks read:docs or write:docs. The message names which |
500 GRAILS_INTERNAL_ERROR | Ours. Retrying the same payload will not help |
What is not here yet
Variables and artifacts are designed but not built. If you need one, say so — the order they land in is driven by what people actually ask for.
Tokens and styles are built, in three layers each — see the sections above.
Documentation is built, with one gap worth knowing before you rely on it:
writeDocumentationBlocks({ mode: "append" }) has no idempotency key, so a retry after a timeout
duplicates. Check with getEntityDocumentation({ entityType, entityId }) first.
Documentation pages are built too — listed, read, created, renamed, moved, deleted, and their
folders renamed or emptied. See Documentation pages above.
The entity graph is built and documented — see getEntityGraph · findEntities({ relatedTo })
above.
Token scopes — what a token may be applied to
A token’s scopes is what Figma lets a designer apply it to, and it has three states. The empty one
is the one to understand, because it reads like missing data and is not.
| in the database | means | how it comes back |
|---|---|---|
["ALL_SCOPES"] | applicable anywhere its type allows | "ALL" |
[] | hidden from Figma’s property pickers — deliberate | "NONE" |
["CORNER_RADIUS", "GAP"] | only there | the list |
Ask for the catalogue before you filter. listEntities({ of: "token-scopes" }) returns the valid
scopes per resolved type with how many tokens of this project carry each. The valid set is Figma’s
and is not derivable from the data: a scope this project happens not to use is still a legal filter,
and a catalogue built from the rows would teach you it does not exist.
listEntities({ of: "token-scopes" }) the vocabulary + countslistEntities({ type: "token", scope: "CORNER_RADIUS" }) which tokens fit a radiuslistEntities({ type: "token", scope: "NONE" }) the ones hidden from pickerslistEntities({ type: "token", includeScopes: true }) attach the state to each rowincludeScopes is off by default because it is per-token, not per-collection: it grows with the
page. An unknown scope is refused with a 400, not answered with an empty page — the two look
identical to you and only one is worth another call.