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" }
}
}
}

Connect Figma’s AI Agent

To connect Figma’s AI Agent to Grails via MCP, provide the Grails server endpoint and credentials:

  • Server URL: https://api.grails.design/mcp/v1
  • Transport: HTTP (Streamable HTTP)
  • Authentication: Bearer token (Authorization: Bearer grails_sk_...)
  • Key Scopes:
    • read:library: Required to inspect components, query properties, search tokens, and resolve modes.
    • write:docs: Optional, required if the agent writes or updates documentation blocks.
  • Node IDs: Query tools (findEntities, getEntities, getComponent) accept native Figma node IDs (e.g. "1:234") and component keys directly.

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.

The walk reaches patterns too: from a component, the patterns that use it; each pattern comes with its status (draft or published). relatedTo does not take a pattern’s id: what a pattern uses is in its getPattern, and what contains it in getBlastRadius({ entity_type: "pattern" }).

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.

Component and doc-page results carry path, their folder. A search is where two components with the same name come back side by side, and path is what tells them apart.


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. Twelve of its arguments narrow which rows come back — a name substring, sync state, publish state, depth (exact, minimum or maximum), node type, collection, name prefix, and whether an entity carries documentation, a spec or drift. There is no filter by parent set: a set’s variants are not rows of their own, they are in its spec (getComponentSpec). 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.

They come a page at a time, in the designer’s order: 50 by default, limit up to 200, and pagination.next_cursor to continue. A slot can suggest more than a thousand components, so the spec serves only the first 20 of each property and says how many there are in props[name].candidates (shown, total, at most what the next page costs, and this call). The first page here starts at the first suggestion, so it repeats the ones the spec showed. A cursor issued before the component synced again is refused: the new spec is another list. Restart without one.

Over REST the same door is GET /v1/components/{id}/props/{prop}/candidates, with {prop} url-encoded.


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 the Figma reference you have both work: a node id (1234:5678), a node id with its file (<file key>/<node id>), a Figma URL with node-id, or a component’s key. A variant’s node id or key answers its set, with variant: { configuration } saying which one, and getComponentSpec serves that variant. If you are coming from Figma you should not have to translate anything.

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.

A Figma node id can be ambiguous as well: it is unique inside one Figma file, and a project can hold several. When two files have a component under the same node id, matched names both, each with its file_key: ask again as <file key>/<node id>.

An id can also come back not-indexed: nothing matched, and the project has sets whose variants have that kind of id unrecorded (sets_without_ids says how many), so it may be one of them and the API cannot tell. A variant’s key is recorded from the next time its set’s whole spec is synced; until then, in its library file, the variant’s node id resolves it.


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, path (for a component or a doc page), 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.

If the component also lives in your code, getComponentSpec answers pairing_map: which Figma prop, value and element is which code one (Size → size, Medium → default). Use it to translate the spec instead of guessing the names.

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, path, 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.

Then read dropped_mentions in the answer. The dry run and the write both list each {{type:id}} that names nothing in this project ([] when none). Its text stays in the block but is never recorded as a mention: find the entity with findEntities and write the block again.

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.

Complete a component’s pairing

listEntities({ type: "component", pairing: "open" }) → the components with parts left to pair
getComponentPairing({ id }) → each side's open parts, by key, and the links so far
writeComponentPairing({ …, operations, dryRun: true }) → what the links would be, nothing written
writeComponentPairing({ …, operations, expect }) → written, once a person agreed

Grails links a part by itself only when the match is certain, so what stays open is what needs judgement: a layer named Label keyed Label2 against a code element label, a variant value Medium against default. Read the open parts of both sides, propose the links, run them with dryRun: true and show a person what would change before writing. Pass the read’s pair.expect when you write, so a side synced in between refuses the write instead of landing it on another layer. Each component in a listing carries pairing (linked, open in Figma, open in code), so you can see how far each one is before opening it.


The tools

getComponent

One component by id, with figma_key — the Figma id that persists across files, used to find the component in the library — and its derived signals: chi_status, has_docs, has_spec. has_drift is not among them — it is an audit signal, requestable through fields.

name is the component’s own name ("Services"), never the Figma layer path above it, and path is its Figma folder — null until the component re-syncs, "" at the top level. Every row that is a component carries that pair: the catalogue, getEntities, findEntities and the build order. A reference to a component inside another response — a graph end, a via step, origin, a mention — carries only its name. display_name was retired: name carries its value.

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 or a Figma node id, interchangeably, as everywhere else on this surface. A name is not an id: it comes back as a 404. 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.

Each entry of spec.props carries canonical_name beside the Figma name that keys it, unless that name has no letter or digit (↳) or another property of the component shares its canonical name. The canonical name is the Figma name split at every run of characters that are not letters or digits, in any script, with the first letter of the first word lowered and the first letter of each other word raised; nothing else changes. ↳ Supporting label → supportingLabel, Show Placeholder → showPlaceholder, ShowCenterContent → showCenterContent, and a letter outside ASCII stays in its word: Tamaño → tamaño. Set the property with the KEY: that is the string Figma matches on. Use canonical_name to match a property you know by another spelling. listEntities({ of: "prop-candidates", prop }) accepts either spelling and echoes prop with the exact one; a canonical name two properties share answers 404 with both in details.ambiguous.

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. An id that matches more than one component is a 404 too, with each match in details.matched: ask again with one of those ids.

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, path, component_depth, is_leaf, grails_sync_status } — name the component’s own and path its folder, null until it re-syncs. 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, 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.invalidPropCombinations is ignored here. It is derived from the same fact this check reads, which configurations have an element tree, so subtracting it would leave nothing to report — 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 only its name engine.

Five engines, and the result says which matched

EngineOver whatFinds
semanticthe entity’s indexed documentation, by vector similaritymeaning — text input → Input Field
textthe same documentation, full-textthe words the documentation actually uses
namethe entity’s name, a literal substring (with a pg_trgm fuzzy floor below it)the name, and only the name — but over 100% of the catalogue
graphedges measured by the Figma sync, expanded outward from the other engines’ hitsentities structurally connected to a match
declaredrelationships a person asserted between entities — not measuredan opinion about connection: weigh it high for relevance, low for impact

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.

One of the engines is not a measurement

Every engine but declared measures something: a name matches, a meaning is close, an edge was extracted from the Figma file. declared is the one where a PERSON asserted the connection — “these two go together”, “this one replaces that one”.

It arrives as its own entry in matched_by, never folded into graph, and that separation is the point: the same list would otherwise mean two different things depending on the row behind it, with nothing in the response to tell them apart.

Weigh it high for relevance and low for impact. A person saying two components belong together is excellent evidence for “what should I look at?” and no evidence at all for “what breaks if I change this?” — an opinion is not a dependency. For that second question use the graph endpoint, which defaults to measured relationships only.

coverage.declared.ran: true with no results is a real answer, and today it is the usual one: nobody has declared anything yet. It is not the same as the engine failing.

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 takes provenance, and findEntities({ relatedTo }) does not. A derived edge is what the sync measured off the Figma file; a declared one is what a person asserted about two entities. The default is derived, because “what breaks if I change this?” is not answered by somebody’s opinion. Pass declared or any to see the rest; every edge carries provenance.

⚠️ origin in a relatedTo response is a different thing — it is the node the walk started from. That is why the graph field is called provenance and not origin: one word for two meanings in the same section would be learned on one door and misread on the other.

The walk itself follows measured relationships only: it reports structural distance, so an asserted relationship is not a hop.

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”. When the token sits on one side of a value, property is the path (padding.bottom, cornerRadius.topStart, effects.shadows.0.color, typography.fontFamily); a token used on two sides reports only the first. 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. A result whose type lives in a folder also carries path; the via steps name the node they passed through and nothing more. 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.

getBlastRadius

“If I change this, what do I affect?” (since v1.317.0). Everything that uses a component, token, style or pattern, directly or not: the aliases of a token, the styles and components that bind it, the components that use those styles or components, in Figma and in code, and the patterns that use those components or contain those patterns.

getBlastRadius({ entity_type: "token", entity_id }) direct uses only (max_depth 0, the default)
getBlastRadius({ entity_type: "token", entity_id, max_depth: 16 }) the whole radius

max_depth is 0 by default: direct uses only. Ask 16 for everything. The walk is bounded by entities × levels, so a deep one stays cheap.

Each row of affected[] is one entity, by its canonical id: a component that lives in Figma and in code is one row, with sides: ["design", "code"], and a variant counts for its set. A deleted entity is not an answer. Each row says depth (0 = a direct use), via (direct, or what it uses on its way: alias, style, component, pattern) and through, the entity it uses one level closer (null at depth 0), so the map can be drawn as a tree. A pattern has sides: [] and its status (draft or published): drafts are answered too, because a change breaks them as well.

counts covers the whole answer, every page, by kind, with levels. next_cursor is at the root and walks the rest. types keeps only some kinds in the answer; the walk still goes through every kind.

It is not findEntities({ relatedTo }). That one is the neighbourhood: every relation, both ways, at most 3 hops. This one follows only the relations of use, inbound, up to 16 levels. A 504 GRAILS_TIMEOUT means the walk did not finish within the time budget: ask a lower max_depth.


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.


describeTokenExport

The fourth layer, and the only one that is not for reading tokens one at a time: “what would a DTCG export of this design system contain, and what would be missing from it?”

describeTokenExport({ includeHidden }) the report, the stamp, and the route to the documents
GET /v1/tokens:export the documents themselves, over REST

The tool returns no tokens, deliberately. A full bundle is ~139 KB of JSON for the largest design system we host — served into your context that is a dump, not an answer. What comes back is:

report.findingsOne entry per thing the export cannot carry, each with a consequence: lost (not in the export at all), approximated (present, but not exactly as Figma holds it), info. An empty list means nothing was left out
report.notesThe same findings as plain sentences
etagThe stamp of this export
downloadThe method and path to fetch the documents

Download with the same API key: GET /v1/tokens:export, adding ?include_hidden=true if you passed includeHidden. It returns { files, resolver, report } — files maps a path to a DTCG document, resolver is the DTCG resolver document describing how the modes combine.

Never in the export: anything deleted in Figma, with no option. Out by default: variables hidden from publishing — includeHidden turns them on. While they are out, the omission is declared as a lost finding, so leaving it off never costs you silently; turn it on and nothing is omitted, so no such finding appears.

Some tokens have no $type, on purpose. DTCG has no type for a boolean, or for text that is not a font family. A Figma BOOLEAN variable and a STRING variable that is not a font family arrive with no $type, a string $value and the real value in $extensions["design.grails.value"]: { "type": "boolean" | "string", "value": … }. No finding says so, because nothing is missing from them. A colour whose opacity comes from a token keeps the applied colour in $value and adds $extensions["design.grails.composed"]: { "color": …, "opacity": "{path}" }, where the opacity points at the token as it is (for Figma, 0 to 100, not 0 to 1). That token is a number with no unit, never px, unless a scope on the variable in Figma says otherwise.

If it refuses with GRAILS_AUTH_PLAN_REQUIRED (403), this team’s plan does not include exporting tokens through the API. That is not about your key or your scopes, and retrying will not help; the team owner changes it. The export inside the Grails app is unaffected.

getCodeTokenMatch · writeTokenMatchRules — your code’s tokens against Figma’s

For a codebase synced with Grails Code Sync. getCodeTokenMatch({ source }) answers which Figma token each code token is, and whether their values agree: every pair is same, differs, unaligned (no mode in common) or conflict (two code tokens that disagree about one Figma token; you decide, it never does). Without source it lists the project’s code sources. When a side has no snapshot yet, status says so instead of calling everything missing.

Colours are equal under thresholds.color_delta_e, a ΔE2000 that is the project’s setting, else 1.5; thresholds.color_delta_e_from says which.

A naming rule renames a code word to the word Figma uses (gray → neutral), for one code source. The answer lists the source’s rules and suggestions read from tokens with the same value and one different word. Suggestions are never applied; accepting one is writeTokenMatchRules({ mode: "set", source, from, to }), which is idempotent. mode: "delete" deletes a rule; mode: "threshold" sets the project’s colour threshold. Editing needs write:tokens; reading, read:library. Rules live in Grails, never in your repository.

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 — or, when the binding also carries candidate_token_ids, that several do and none is in the style’s own Figma file, so none was picked.


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. No path: a mention is a reference, not a row
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.



findPatterns · getPattern · writePattern — patterns

A pattern describes how components are composed for one use (an empty state, a PIN step, an account home) and the rules for doing it. findPatterns lists the project’s patterns, the most recently changed first, without their spec; with no filter it lists every one. Each comes with its kind (section: one section; screen: several sections; flow: several screens), its steps (screens), its shape (sections per screen) and who changed it last. To narrow it: kind, status, query (a piece of the name), component (the patterns that use it, by row id or Figma node id or key; one that matches several components answers 404 GRAILS_COMPONENT_NOT_FOUND with details.matched), domain (an exact, case-sensitive match), platform, components (several, with usesMode: any, the default, or all) and createdBy / updatedBy (people’s ids, as updated_by.id answers them). order lists by name_asc or created_desc instead of the most recently changed; a cursor keeps its order. include adds more to each item, each at its own cost: intent (the problem it solves, domain, platforms), created (who and when), review (whether it still fits your design system) and uses (the components and patterns it names, the heaviest).

getPattern reads one whole, with review: ok when everything it names is still in your design system as the spec describes it, or needs-review with each issue and its path into the spec. Both need read:patterns.

writePattern

Creates a pattern (no id) or replaces one (id): its name, status and whole spec. What a replace leaves out is removed. Needs write:patterns. A new pattern is a draft unless you send status: "published".

The spec, specVersion: 2, has three parts:

  • intent: the problem it solves, when to use it and when not (optionally naming another pattern to use instead), its domains and its platforms.
  • screens: each screen lists its sections from top to bottom. A section says what it is for, how many items it holds (cardinality) and what it accepts: components, optionally narrowed to some values of their props, or other patterns, never both in one section. It can also carry guidance for its text, and parts: sections nested inside it, with the same shape. One section makes a section pattern, several make a screen, and several screens make a flow, in order.
  • rules: statements with a strength (must, should or may) and a category, about the whole pattern, one screen or one section.

Screen, section and part ids are lowercase with dashes, unique in the pattern, and rules point at them. A canonId is the id findEntities answers for a component, or a pattern’s id. At most one component per section can be the default: the one to use when nothing else decides.

{
"specVersion": 2,
"intent": {
"problem": "A list with nothing in it yet has to say so and offer the first step.",
"useWhen": ["A list the person has not filled yet"],
"avoidWhen": [{ "situation": "A filter hides every item" }],
"domain": ["banking"],
"platforms": ["web", "ios", "android"]
},
"screens": [{
"id": "empty-list",
"name": "Empty list",
"sections": [
{
"id": "message",
"name": "Message",
"purpose": "Say what is missing",
"region": "body",
"cardinality": { "min": 1, "max": 1 },
"accepts": [{ "kind": "component", "canonId": "00000000-0000-4000-8000-000000000001" }],
"content": { "guidance": "One sentence that says what will appear here.", "maxLength": 80 }
},
{
"id": "action",
"name": "Action",
"purpose": "Start the first one",
"region": "footer",
"cardinality": { "min": 1, "max": 1 },
"accepts": [{
"kind": "component",
"canonId": "00000000-0000-4000-8000-000000000002",
"constraints": [{ "prop": "variant", "values": ["primary"] }],
"default": true
}]
}
]
}],
"rules": [{
"id": "one-action",
"statement": "Offer exactly one action.",
"strength": "must",
"scope": { "kind": "section", "sectionId": "action" },
"category": "composition"
}]
}

Every problem comes back at once as 422 GRAILS_VALIDATION_FAILED, each with its path (screens[0].sections[1].accepts[0].canonId), so fix them all and send again. A spec with specVersion: 1 is a 422 too. A value that a constraint names and the component does not have is not refused: review reports it.

Saving a pattern records which components and patterns it uses, and getEntityGraph shows those relationships.

To replace a pattern without undoing someone else’s change, send expectedUpdatedAt: the updated_at you read from getPattern or from your previous write. If the pattern was saved since, nothing is written and the answer is 409 GRAILS_PATTERN_CHANGED, with details.current_updated_at; read it again and redo your change on what is there. Every write answers its new updated_at. Without expectedUpdatedAt, a replace overwrites whatever is there.

getComponentPairing · writeComponentPairing — which Figma part is which code part

When a component lives both in Figma and in your code, Grails keeps which element, prop and prop value of one is which of the other. getComponentPairing({ id }) reads those links; id is the Figma component, the code component or the entity. Grails links a part by itself when the match is certain (the same name, and nothing else on either side it could be). What it could not match stays unlinked until a person or an agent links it: Grails does not try again.

A component in several Figma files has one pair per file. The answer always lists pairs; with more than one it reads none of them (choose_pair: true) until you ask again with pair: { design, code } from that list.

Each link names its two parts by their key (design, code), with the name each side shows beside it (design_label, code_label). A layer named “Label” can be keyed Label2, and writing takes the key. method says whether Grails made the link (auto) or a person or an agent did (manual). Links belong to the component, not to a variant. reconcile_failed: true means the links may be behind the specs until the next sync succeeds.

pair.state is what the Both view shows, computed the same way. For each side it lists the props and elements, each linked, ok or open, by the key a write takes; an enum prop lists its values. summary counts the links that match, drift (partial) or do not match at all, and what each side has with no counterpart. links says, by link id, how far each one agrees (wire): for a prop, how many of its values are settled on each side; for an element, each property both sides set (its size, padding, gap, radius, colours, font size) with its value and tokens on each side, and how many differ. A side that sets a property only through a token whose value Grails could not read (usually one it has not synced) has value: null, the token’s name in tokens and unresolved: true (since v1.378.0; a side with a value does not carry it): the property is set, its value is unknown, so same is null and its wire is partial, or token when both tokens are one canon (since v1.376.3; before, the property was left out). A variable Grails has not synced whose spec carries the value Figma resolved it to (a component synced since v1.377.0) has that value, no unresolved and in_figma: true, and is compared by it (since v1.378.1): it is the value in Figma’s mode for the layer, paint opacity included, so it does not follow modes. A Color Style is read that way too, synced or not (since v1.389.4): its row carries no colour. An element link whose shared properties are all of this kind is uncompared; one with others compared is partial whatever those say, because what was not compared may agree, and it is counted as drifting. Two tokens that Grails matched as one are a token wire even when their values differ, and token_drift says they do. An element link with nothing to compare is uncompared, never match: no property is set on both sides, or the Figma element is not shown in the variant compared. summary.uncompared counts these links. A link with no wire at all (null) has a code element that is not part of the code configuration compared, or a part that is no longer in its spec.

Elements are compared in one Figma variant, one code variant and one mode per token collection. compared_in says which, and can_ask lists what you can ask for instead: pass configuration (one value per Figma axis), codeConfiguration, modes (a collection left out shows its default) or booleans. A name the pair does not have is refused with the valid ones (422, missing). state is null while a side has no spec yet.

writeComponentPairing({ design, code, operations }) links and unlinks parts of one pair, in order and all or nothing. An operation is { op: "link", part, design, code } — part is element, prop or prop_value; a value names its two props in design/code and its values in designValue/codeValue, and needs its props linked — or { op: "unlink", decisionId }. A part has one link per side: to link it elsewhere, unlink it first, in the same call. Linking a prop also links its values whose names match (brought); unlinking it removes them (removed). Pass the read’s pair.expect as expect: if a side was synced in between, nothing is written (409). dryRun: true checks and answers without writing. A failing operation names itself in details.field (operations[2]), and nothing of the call is written. Links are written in the name of the key’s owner. Unlinking a part never unmatches the two components. Writing needs write:pairing; reading, read:library.

Every component also says how far its pairing is without this tool. getComponent, getEntities and listEntities answer pairing, one entry per pair: { design, code, linked, open: { design, code } }. open is null when a side has no spec yet, so there is nothing to compare; [] means the component has no pair, and null that it could not be read. getComponentSpec answers pairing_map, the links themselves: the props with their values and the elements, by key. To find components by their pairing, listEntities takes livesIn (design, code or both) and pairing (open or complete), exactly; findEntities takes the same two on a search, applied to the ranked results, so a page can hold fewer than limit; each match keeps its rank, and truncated.total then counts the candidates before the filter (counted_before_filter: true).

Request limits

Three limits apply to every MCP call, before any tool runs:

  • Body: at most 4.5 MB, the hosting platform’s limit for a request. The largest image uploadDocumentationImage takes (3 MiB) fits as base64, also wrapped in lines or with its / escaped as \/, but not both at once. A larger body is a 413; for bigger files use the presigned upload.
  • Elements: the arguments hold at most 50,000 array items and object keys, counted at any depth.
  • Lists: a list argument (ids, types, fields, blockIds, …) takes at most 1,000 items, and a variant configuration in getComponentSpec at most 100 props. Each tool’s own, lower limit still applies and answers with its own error; blocks is the one list without this limit.

A call over the elements or the lists limit is answered with an invalid-arguments error that names the limit. A configuration over 100 props is the exception: it answers Invalid input at variants[N], naming the item, not the limit.

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.