Skip to content

MCP for AI agents

Connect

One command, using an API key from Team settings → Integrations → Manage API keys:

Terminal window
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:

claude_desktop_config.json
{
"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 description
findEntities({ relatedTo: "2072:9432" }) by an id you have
findEntities({ relatedTo: id, depth: 2 }) two hops out

Exactly 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, paginated
listEntities({ type: "component", q: "button", filtered
syncStatus: ["synced"], hasDocs: true })
listEntities({ type: "component", fields: ["id","name"] }) fewer columns, same rows
listEntities({ 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 abstract
getEntities({ ids: [id1, id2, id3] }) three at once, one call
getEntities({ ids: [id], include: ["include-props"] }) add a layer

Ids 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 here
getEntities({ ids: [componentId], include: ["include-props"] }) what it accepts
getComponentSpec({ componentId }) the default tree
getComponentSpec({ componentId, variants: [{ … }] }) one configuration

The 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 LAST
getComponentSpec({ componentId: orderedIds[0] })
getComponentSpec({ componentId: orderedIds[1] })
→ one call per id — getComponentSpec does not batch across components

Build 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 each
getEntityDocumentation({ entityType, entityId }) → what is already there, plus `mentions`: what it references, resolved
writeDocumentationBlocks({ 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 exist
createDocumentationPage({ 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 capture
listEntities({ type: "component", hasDrift: true }) → the same set, paginated
checkHealth({ entityIds }) → which variant combinations are missing

Both 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 tree
getComponentSpec({ componentId, variants: [{ "Size": "Medium" }] }) one configuration
getComponentSpec({ componentId, variants: ["Size=Medium"] }) the Figma spelling works too
getComponentSpec({ componentId, variants: ["all-variants"] }) every tree — no cap, no paging

componentId 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 passyou get
nothing, or ["default"]the default tree alone — the cheap answer to “what does this look like”
a list of configurationsthose 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.

FieldWhat it is
statuscomputed (it has dependencies) or trivial (it depends on nothing)
ordered_componentsThe list. Never null, never []; the component asked for is last
grails_sync_statusPer entry. deleted_in_figma means that step cannot be built
dependency_countordered_components.length - 1
requestedEcho of the id you sent — component_id carries the resolved Grails uuid, which is not what you sent
unresolved_depsAlways [] 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-page

Those 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

EngineOver whatFinds
semanticthe entity’s indexed documentation, by vector similaritymeaning — text inputInput Field
textthe same documentation, full-textthe words the documentation actually uses
namethe entity’s name, a literal substringthe 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 metadata
findEntities({ relatedTo: entity_id }) the ENTITIES reachable from it, with hops and via

getEntityGraph 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 price
listEntities({ 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 layer
listEntities({ type: "style", nodeType, the index — and `figma_style_id`, the raw spec key
namePrefix })
getEntities({ ids: ["S:…,"] }) the properties

describeStyles, 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 entityA documentation page
What it isWhat a team wrote about a component, a token or a style — when to use it, when not to, accessibility decisionsA standalone document, with a title, a URL slug and a folder
Where it livesIn Library and Foundations, attached to the thing it describesIn the docs section, as a document of its own
Who organises itNobody. It hangs off its entity and goes wherever the entity goesThe team. Pages are created, renamed, moved between folders and deleted
Read it withgetEntityDocumentation({ entityType, entityId })listDocumentationPages · getDocumentationPage
Write it withwriteDocumentationBlocks({ 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 shape
describeDocumentationBlockTypes({ section: "criteria" }) → how to decide WHICH shape content belongs in
describeDocumentationBlockTypes({ section: "shapes" }) → the exact JSON of each shape, field by field

One 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:

FieldWhy you need it
descriptionWhat the shape does. Present on every type
questionThe 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
whenNotThe half that is usually missing — and where it says to go instead
exampleA complete block you can pass to writeDocumentationBlocks({ dryRun: true }) unchanged. It is checked against the same validator that runs on write
allowedOwnersnull 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.

sectionAnswersAsk for it when
criteriaWhich shape a piece of content belongs in, and why — the two axes, the boundaries between shapes that look alike, the mapping heuristics, the antipatternsYou are mapping content you did not write
shapesThe exact JSON of every shape, field by field, with what the validator rejects — plus the alt discipline, the data/text split and how references workYou 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 entity
getEntityDocumentation({ entityType: "doc-page",
entityId: "brand-guidelines" }) → a doc page's blocks BY ITS SLUG

Both 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 404 naming 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_idWhat the reference points at. The same pair every other tool takes
countHow many times it is mentioned across all the blocks of this entity, not one
nameResolved, so a reference does not cost you a lookup each. null when exists is false
existsWhether 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 end
writeDocumentationBlocks({ mode: "replace", entityType, entityId, blocks }) replaces, and loses what was there
writeDocumentationBlocks({ mode: "delete", entityType, entityId, blockIds }) removes blocks by id
writeDocumentationBlocks({ …, dryRun: true }) validates without writing

This 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:

modeWhat it does
appendAdds 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
deleteHard 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 reveal
listDocumentationPages({ path: "Foundations" }) → only the pages in that exact folder
getDocumentationPage({ page }) → one page, whole: its identity AND its blocks
createDocumentationPage({ title, path?, slug? }) → a new, EMPTY page
updateDocumentationPage({ 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 files
renameDocumentationPath({ from, to }) → rename a folder across every page in it
clearDocumentationPath({ path }) → unfile every page in a folder

A 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 path no page carries yet and it exists from then on. An empty folder cannot be represented at all.
  • There is no folder catalogue to query. listDocumentationPages returns a paths branch 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 just read:docs. Call it before moving a page anywhere. (listEntities({ type: "doc-page" }) and findEntities do carry each page’s path on its row, but both take read:library and neither adds them up for you.)
  • updated: 0 from a rename is a valid answer, not an error. A folder that “does not exist” is simply a string no page carries, and answering 404 would 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-list
prepareDocumentationUpload({ kind, entityType, → a URL for YOU to PUT the bytes to
entityId, contentType,
sizeBytes })
completeDocumentationUpload({ r2Key }) → measures what landed, settles the reservation

completeDocumentationUpload 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

StatusMeans
400 GRAILS_VALIDATION_FAILEDYour payload. The message names the field, and details.errors carries the block index
403 GRAILS_AUTH_INSUFFICIENT_SCOPEThe key lacks read:docs or write:docs. The message names which
500 GRAILS_INTERNAL_ERROROurs. 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 databasemeanshow it comes back
["ALL_SCOPES"]applicable anywhere its type allows"ALL"
[]hidden from Figma’s property pickers — deliberate"NONE"
["CORNER_RADIUS", "GAP"]only therethe 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 + counts
listEntities({ type: "token", scope: "CORNER_RADIUS" }) which tokens fit a radius
listEntities({ type: "token", scope: "NONE" }) the ones hidden from pickers
listEntities({ type: "token", includeScopes: true }) attach the state to each row

includeScopes 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.