Skip to content

Contract reference

Every operation the API exposes, with the scope it needs and the MCP tool that does the same job.

Read Reading responses before you consume any of this from code. Several fields have to be read before the payload — spec_schema_version and the build order’s status exist because the payload alone is ambiguous.

34 operations, generated from contract v1.0.0. openapi.json

Components

GET /v1/components

List and search components

Scope read:library · MCP toollistEntities

A page of components with their derived signals. Walk the full set by passing `pagination.next_cursor` back as `cursor`. Scores are only comparable within the same `search_mode_used`. **There is no `total`.** The filters are applied inside the database function, including the derived `has_docs`, `has_spec` and `has_drift`, so a count computed outside it would answer a different question than the page it accompanies. To count: if `pagination.has_more` is `false` on the first page, `data.length` is the exact total for those filters at no extra cost; otherwise walk with `fields=id&limit=100`, which is one call per 100 components and a few bytes each; and for the drift subset, `GET /v1/components:driftSummary` returns `drifting_count` in one call (read its `truncated` flag — it says when that number is a floor).

Name In What it does
q query Search term matched against component names. An empty `q=` is treated as absent, not as an empty filter.
search_mode query `exact` | `semantic` | `hybrid`. Default `exact`. **`semantic` and `hybrid` are accepted and do not fail today** — embeddings are not installed, so they are served lexically and the response reports what was actually used in `search_mode_used`. Read that field rather than assuming.
limit query 1..100, default 20. Above 100 truncates silently; below 1 is a 400.
cursor query Opaque cursor from a previous page — pass `pagination.next_cursor` back verbatim. Do not construct one.
fields query Restrict returned columns. Repeatable or comma-separated. `id` is always present. `score` and `match_reason` only exist on a search.
type query `COMPONENT` | `COMPONENT_SET`. A set is the variant container, and its variants are not rows of their own — they live in the set's spec. See `parent_id`.
parent_id query The id of a COMPONENT_SET, to return its variants. Returns an empty page for every set: a variant is not a component here — it has no row and no id of its own. An empty result says the set has no variants *as separate entities*, not that it has no variants. Read them from `GET /v1/components/{id}/spec`, under `spec.variants[]`.
sync_status query `synced` | `outdated` | `sync_pending` | `syncing` | `deleted_in_figma` | `schema_upgrade_required`. Repeatable or comma-separated. **`deleted_in_figma` is excluded unless you ask for it** — the default page hides it.
publish_status query `CURRENT` | `CHANGED` | `UNPUBLISHED`. Repeatable or comma-separated. Whether the Figma component has unpublished changes.
has_docs query `true` | `false`. Only components that already have documentation, or only those without. Anything else is a 400 — never a silent `false`.
has_spec query `true` | `false`. Whether a spec has been extracted. A component with no spec cannot answer `/spec`.
has_drift query `true` | `false`. Whether the stored spec predates the minimum supported schema version.
component_depth query Exact depth(s) in the composition tree. Repeatable or comma-separated. `0` is an atom — nothing else in the system composes into it.
component_depth_gte query Minimum depth, inclusive. Combines with `component_depth_lte` to make a range.
component_depth_lte query Maximum depth, inclusive.
sort_by query `name` | `created_at` | `component_depth` | `variant_count` | `chi_score`. Default `name`. **`last_synced_at` is filterable but deliberately not sortable**: every sync rewrites it on every row, so a cursor minted before one points into a reordered set.
sort_dir query `asc` | `desc`. Default `asc`.

GET /v1/components/{id}

Get one component

Scope read:library · MCP toolgetComponent

One component with `chi_status`, `has_docs`, `has_spec` and `has_drift`. A component deleted in Figma is still readable by id — check `sync_status`.

Name In What it does
id required path

GET /v1/components/{id}/spec

Get a component spec — the abstract by default, layers on request

Scope read:library · MCP toolgetComponentSpec

**Returns the ABSTRACT, not the whole spec**: `title`, `props`, `anatomy`, `metadata`, `_grails` and — for a variant set — `variantConfigurations`, every combination that exists WITHOUT its elements. **This is a declared break.** It used to return the complete document, always; `?include=variants` returns exactly that again. No data was removed — the element trees are now opt-in, because a single call to this endpoint was measured at 80–90% of everything an agent read. **Read `more`:** it prices every layer this response left out, in model tokens, with the exact call that fetches it. ⚠️ **Do not count the `$token` names visible in the abstract and assume that is all of them.** Bindings live in `anatomy` AND inside the element trees, so the abstract shows a plausible subset — commonly under half. `more.token_values.count` counts the WHOLE document, and `?include=tokenValues` resolves every one. ⚠️ **`spec.invalidVariantCombinations` includes the set's DEFAULT configuration, which does exist.** It is derived as `cartesian − variants[]`, and the default configuration is stored outside `variants[]`, so it lands in the subtraction on every variant set measured (302 of 302 in production). Read it as "combinations with no element tree of their own", never as designer intent — Figma offers no way to mark a combination invalid. **`spec._grails.chi` is the Component Health Index:** `score` is 0–100 (higher is healthier) = `100 − (po + dc + as + ta_penalty)`, and `status` buckets it (`optimal` ≥ 95, `good` ≥ 60, `monitor` ≥ 30, else `poor`). `po` (Property Overload, weight 20), `dc` (Design Coverage, weight 15) and `as` (API Surface, weight 25) are PENALTIES — higher is worse, 0 is perfect. `ta` (Token Adherence, weight 40) is an object whose `ta.score` is the percentage of style values bound to a token or Figma style, where higher IS better. `hasProps: false` means the component exposes no Figma properties, so `po`/`dc`/`as` are 0 by construction. **Read `spec_schema_version` before interpreting `spec`:** more than one schema version is normally live and they share top-level keys, so the version is the only way to know which contract the document follows. `null` when it declares none.

Name In What it does
id required path
include query `default` | `variants` | `tokenValues` — the complete set, there are no others. Repeatable or comma-separated. Omit it for the abstract, which is usually right. `default` adds the default variant's element tree. `variants` adds EVERY element tree, the N variants plus the default one — this is the pre-layering response, and the most expensive thing this API returns for one component; check `more.variants.est_tokens` first. `tokenValues` adds the resolved value of every token the component binds. Do NOT use `variants` to reach a single variant — that is `variant`, and it costs a fraction.
variant query One variant, named by its configuration, as a url-encoded JSON object: `variant={"Size":"Medium","State":"Hover"}`. Copy one out of `variantConfigurations` in the abstract; property names and values match case-insensitively. **A configuration and not an index on purpose** — an index is a position in `spec.variants[]` and nothing keeps that array ordered between syncs, so a remembered index silently moves. A partial configuration is allowed when it selects exactly one variant; when it matches several the response lists them instead of choosing. Not valid on a component without variants, and not valid together with `include=variants`.

GET /v1/components/{id}/build-order

Get a component build order

Scope read:library · MCP toolgetComponentBuildOrder

Every component it depends on, transitively, ordered so that everything an entry needs appears before it — and the component you asked for LAST. Computed per call, so ask for one component at a time. **`status` is one of two:** `computed` (it has dependencies, ordered below) or `trivial` (it depends on nothing, so the order is the component alone). `ordered_components` is never `null` and never `[]` — a component that exists always has an order, at minimum itself; one that does not is a 404. A third status, `not_computed`, was removed on 2026-09-05. It meant "the sync has not ordered this yet" and the sync no longer orders anything, so nothing can be behind. A client branching on it can drop that branch. Each entry is `{ position, component_id, name, component_depth, is_leaf, grails_sync_status }`. Dependencies Figma has but Grails does not are excluded on purpose: an unsynced library component cannot be built from what this project knows, and listing it would put an unbuildable step in a build order. **A component deleted in Figma IS in the list**, in place, with `grails_sync_status: "deleted_in_figma"` — read that field before building. It used to be filtered out, and the filter did two things nobody wanted: a deleted component vanished from its OWN order, and a deleted component in the middle of a chain was skipped while its dependencies were promoted into its parent's list, asserting a dependency the parent never had. `component_depth` may be `null`, meaning the sync has not ranked that component. It is not `0` — production leaves are depth 1, so 0 would read as "an atom". The ORDER does not depend on this field: it is computed from the dependency edges. **A very large dependency graph can time out with `GRAILS_TIMEOUT` (504).** The order is computed per request and its cost grows with the square of the closure size — measured, 1001 components take 3.2 s against a ceiling of 8 s. Retrying the same id will not help: it is a limit, not a transient failure. No real component comes close today; the largest closure in production is 26.

Name In What it does
id required path

POST /v1/components/specs:batchGet

Get several component specs

Scope read:library · no MCP equivalent

Up to 50 specs in one call. **Check `missing`** — the requested ids that returned nothing — and never match `data` against your request by position. Each entry is `{ id, reason }`, and the two reasons need opposite fixes: `no_spec` means the component is in this project and only its spec is absent (re-sync from the Figma plugin, or filter with `has_spec=true`), while `not_found` means no component with that id is in this project, so retrying will not help. Another project's id reads as `not_found`, the same as one that never existed.

Takes a JSON body — see openapi.json for its schema.

components:validate

POST /v1/components:validate

Validate variant consistency

Scope read:library · MCP toolcheckHealth

Checks that every declared variant combination exists. A component with no variant properties is reported as `not_a_variant_set` and does NOT make the set invalid — `valid` is decided by `missing_variant` and `no_spec` only. `spec.invalidVariantCombinations` is ignored here: it is derived as `cartesian − variants[]`, so subtracting it would make the check vacuous.

Takes a JSON body — see openapi.json for its schema.

entities:match

GET /v1/entities:match

Find any documentable entity by term or concept

Scope read:library · no MCP equivalent

Search across the eight things documentation can be attached to. Returns ids, so a writer can point at real entities instead of inventing uuids. The type list is exactly the `entityType` enum the documentation endpoints accept, so anything that can be documented can be found here — which is what makes a coverage audit possible. **Three engines run and the result says which matched.** `semantic` (vector similarity over indexed documentation), `text` (full-text over that same documentation) and `name` (a literal case-insensitive substring of the name, the only engine that covers the whole catalogue). They are fused with Reciprocal Rank Fusion; every match carries `matched_by`, `surfaces` and `signals` with each raw figure tagged by its engine and never fused into one number. ⚠️ **Read `coverage` before believing an empty result.** It is per engine: an engine with `ran: false` did not look, which is a different fact from an engine that looked and found nothing. The semantic index is nearly empty today, so without that distinction an empty answer reads as "there is nothing like this" when it means "almost nothing is indexed". **There is no `confidence`.** `score` is a fused reciprocal-rank sum — comparable within one response, meaningless across responses, and it supports no threshold. `shape.verdict` is what answers *choose or ask again*: `one_stands_out` when the top match beat the second on `surfaces`, on name-match tier, or by at least 0.05 of cosine; `flat_ambiguous` when none of that held. Results are grouped by type, because the next call depends on the type, and each group carries `truncated: { shown, total }` plus `excerpt` — the fragment of documentation that matched, `null` when the hit came from the name alone. It replaces `GET /v1/entities:search`, which served the name engine alone under a different response shape.

Name In What it does
query required query Required. A term, a name fragment, or a concept. The name engine matches a literal case-insensitive SUBSTRING (`%` and `_` are escaped and match themselves; no stemming and no synonyms; a typo or a plural DOES reach a near miss, ranked below every literal match and marked `signals.name.matched: "fuzzy"` with a `similarity` in 0..1); the semantic and text engines match by meaning but only over entities whose documentation is indexed. Token and style names are paths (`Background/Brand/Default`), and a query that is one whole segment ranks above a chance substring. A shorter query does not widen the search into related names.
entity_types query component | token | style | token-collection | screen | flow | project | doc-page. Repeatable or comma-separated. Default: all eight. `token-collection` is the group, not the token in it. `screen` is a synced Figma frame, not something to reuse. **`project` is not a project directory**: a key is scoped to one project, so it returns at most that one and never another tenant’s. `doc-page` finds an existing standalone page; it does not create one. Narrow it for a sharper answer, not for speed: the per-type queries run concurrently, so dropping a type removes work but not waiting.
limit query 1..20 **per group** (that is, per entity type), default 5. Above 20 truncates. Raising it is rarely the fix: each group reports what it held back in `truncated`, and a `flat_ambiguous` verdict calls for a narrower query rather than more rows.
cursor query From a previous `next_cursor`, to continue past what each group showed. Pass it back verbatim. Keep `query` and every filter identical while paging — the ranking is recomputed per call, so a cursor replayed against a different question is rejected with `GRAILS_INVALID_CURSOR`.
has_docs query true | false. Keep only entities that already have documentation, or only those that do not. Omit for either — every match carries `has_docs` regardless. `has_docs=false` necessarily excludes every semantic and text match, since an undocumented entity has nothing indexed to match on.
include_deleted query true | false. Include entities deleted in Figma. Default false. Only component, token, style and token-collection have a Figma sync lifecycle; for screen, flow, project and doc-page this flag does nothing and `sync_status` is `null` — meaning "does not apply", not "unknown".
resolved_type query Tokens only: COLOR | FLOAT | STRING | BOOLEAN. Ignored by the other seven types. With it set, tokens come from the name engine only — the semantic index carries no value type, so a semantic-only token cannot be checked against it.
style_type query Styles only: TEXT | PAINT | EFFECT | GRID. It does not filter token-collection. Same caveat as `resolved_type`: with it set, styles come from the name engine only.

documentation

GET /v1/documentation/guide

How documentation works here — start here

Scope read:docs · MCP tooldescribeDocumentationBlockTypes

The order of steps for writing documentation, and which skill covers each part. An index, not a manual: it points at the skills rather than restating them. Exists as a callable surface and not only as documentation because an MCP client always enumerates tools but may never enumerate skills.

GET /v1/documentation/block-types

What documentation shapes exist, and where each can live

Scope read:docs · MCP tooldescribeDocumentationBlockTypes

The block types that can be written, each with the entity kinds it accepts. **Read `allowedOwners` before drafting**: without it you can propose a block on an entity that rejects it, and the rejection is not anticipable from the schema. `allowedOwners: null` means no restriction — it is NOT an empty list, which would mean the opposite. Types absent from this list are not writable. Every `example` is a complete block that can be posted to `/v1/documentation:validate` unchanged, and any id inside one is a real entity of THIS project: the `use-cases` example points its `alternatives` at an actual component, so the shape of a reference (`{ entity_type, entity_id }`, with a uuid resolved through `/v1/entities:match`) is visible instead of having to be guessed from an empty array.

Name In What it does
section query Omit for the vocabulary. Name a section to get the authoring guide instead: `criteria` — which shape a piece of content belongs in, and why; `shapes` — the exact JSON of each shape, field by field. The sections are announced in `more.authoring_guide` with what each costs, and are not attached by default: together they are around 4,850 model tokens, and a caller that already knows which shape it wants should not pay for them.

GET /v1/documentation/pages

The pages of this project, and the folders they reveal

Scope read:docs · MCP toollistDocumentationPages

Documentation **pages** are standalone documents the team writes and organises — title, URL and folder. Different from `GET /v1/entities/{entity_type}/{entity_id}/documentation`, which returns what was written ABOUT a component or a token. ⚠️ **The only surface that AGGREGATES the folders.** `listEntities` with `type=doc-page` and the match endpoint carry `path` on every row too, but neither collects the distinct set, and both take `read:library`. For a key holding only `read:docs`, this is the only one at all. A folder is not an entity: it IS the value of a page `path`, and it lives exactly as long as some page carries it. `paths` is derived from the rows RETURNED, so when `has_more` is true it is partial — moving a page into a folder you could not see would create a second one carrying the same name.

Name In What it does
path query Only pages in this exact folder. Not a prefix: a folder is a whole string and there is no nesting for a prefix to mean anything against.
limit query 1..100, default 20. Above 100 truncates rather than failing; below 1 is a 400.
cursor query The `pagination.next_cursor` of a previous response, passed back verbatim. Do not construct one.

POST /v1/documentation/pages

Create a documentation page

Scope write:docs · MCP toolcreateDocumentationPage

The first write this API makes to `doc_pages`. Until now only BLOCKS could be written, into pages somebody had created by hand in the web app — so an agent with something new to document had nowhere to put it. ⚠️ **The `slug` in the response may not be the one you asked for.** It is unique per project: if the one derived from `title` is taken, the server tries another and returns what it settled on. **Read it, do not assume it** — a caller that builds a URL from the title will build one that does not exist. If you pass an explicit `slug` and it collides, this answers `409 GRAILS_SLUG_TAKEN` and changes nothing: handing back a different URL than the one requested is worse than failing. ⚠️ **Once created, the slug cannot be changed.** No `PATCH` accepts it, and that is not an oversight: it is a separate, deliberate operation that does not exist yet. `path` is the FOLDER, and a folder is not an entity — it **is** this string. Passing one that does not exist creates it; leaving it out leaves the page loose. There is no folder catalogue to query: the folders of a project are derived from the `path` of its pages, which is what `listEntities({ type: "doc-page" })` is for.

Takes a JSON body — see openapi.json for its schema.

GET /v1/documentation/pages/{page}

One page, whole: its identity and its blocks

Scope read:docs · MCP toolgetDocumentationPage

Returns the page — title, slug, folder, position — **with** every documentation block on it. That is what separates it from the entity documentation endpoint, which returns blocks alone: a page has an identity of its own, and an entity being documented does not. Takes the page uuid **or its slug**. ⚠️ A slug is readable and unique within the project but **not permanent**: deleting a page frees its slug, so one stored earlier can later resolve to a different page with no error at all. The uuid is the stable reference.

Name In What it does
page required path

PATCH /v1/documentation/pages/{page}

Rename a page, move it, or edit its abstract or icon

Scope write:docs · MCP toolupdateDocumentationPage

**Renaming and moving are the same request**: moving a page *is* writing its `path`. A separate move endpoint would be a second door to one column. ⚠️ **An absent field and a `null` one are different instructions**, and JSON expresses both. A field you leave out is left alone; `"path": null` takes the page OUT of its folder; `"path": "Foundations"` moves it there, creating that folder if no page carried it yet. There is no folder entity — a folder IS this string, and it exists exactly as long as some page carries it. An empty body is refused rather than executed: rewriting the row with its own values would move `updated_at` and record an edit nobody made. ⚠️ **`slug` is not accepted.** Changing a page URL breaks every link already shared, so it is a separate, deliberate operation — one that does not exist yet. Renaming a page or editing its abstract re-indexes it for semantic search; moving it or changing its icon does not, because neither reaches the indexed text.

Name In What it does
page required path

DELETE /v1/documentation/pages/{page}

Delete a page, its blocks and its files

Scope write:docs · MCP tooldeleteDocumentationPage

⚠️ **Hard delete. There is no trash and no undo.** It removes three things: the page row, every documentation block on it, and the stored objects those blocks referenced. ⚠️ **It requires the page uuid and REFUSES a slug**, unlike every read on this surface. Deleting a page frees its slug — nothing reserves it — so a slug stored earlier may already point at a different page. On a read that is a wrong answer you can notice; here it would destroy somebody else's page irreversibly, and the response would look exactly like the intended one. The page also leaves the semantic index, so it stops answering searches for text that no longer exists anywhere.

Name In What it does
page required path

POST /v1/documentation/images

Upload an image in one call

Scope write:docs · MCP tooluploadDocumentationImage

Stores an image and returns the `r2_key` a documentation block points at. **There is no confirm step**, and that makes this path safer than the presigned one rather than less: the bytes arrive here, so they are measured here, and there is no window between signing and measuring. Give exactly one of `data` (the image as base64) or `sourceUrl` (an https URL to fetch it from). Sending both, or neither, is refused rather than resolved by precedence — picking one silently would make the answer depend on an order the caller cannot see. ⚠️ **`sourceUrl` accepts a short allow-list of hosts and nothing else.** Redirects are not followed, a host that resolves into a private network is refused, and the download stops at the size ceiling while reading rather than trusting `Content-Length`. The refusal names the file where the list lives, because the expected cause is that a host changed and not that you got it wrong. ⚠️ **The bytes are verified against the `contentType` you declare.** For the URL path that means the response's own `Content-Type` decides nothing. For base64 it means one wrong character is refused as corrupt instead of stored — which is the expected failure when a model re-emits tens of thousands of characters by hand. The image is not visible to anyone until a block references it: follow with a `PUT`/`POST` of an `image` block whose `content.r2_key` is what this returned.

Takes a JSON body — see openapi.json for its schema.

POST /v1/documentation/uploads:presign

Reserve storage and get an upload URL

Scope write:docs · MCP toolprepareDocumentationUpload

Step one of the upload cycle, for files and for images too large to send inline. Returns `upload_url` and `r2_key`; the client then `PUT`s the bytes straight to the URL with the same `Content-Type`, and finishes with `POST /v1/documentation/uploads:confirm`. ⚠️ **Without that third step the object is collected and the reservation released** — the upload simply never happened. The URL is valid for 15 minutes; the reservation outlives it, so an abandoned upload still has something to be charged against until the nightly collector clears it. ⚠️ **The quota is reserved BEFORE the URL is signed**, because after the signature there is no further point of control: the client uploads directly to storage and the bytes never pass through this API. A refusal is only possible here. `sizeBytes` is what you expect to send. It is checked against the plan now and against the real object at confirm, because **a presigned URL signs the key and the content type but NOT the size** — so what you declare is a promise. For files the cap is a plan property; for images it is a limit of format and does not change with the plan.

Takes a JSON body — see openapi.json for its schema.

POST /v1/documentation/uploads:confirm

Measure the upload and settle the reservation

Scope write:docs · MCP toolcompleteDocumentationUpload

Closes the cycle: measures the stored object and settles the reservation against what actually landed. Returns the MEASURED `size_bytes`, which may differ from what was declared at presign. ⚠️ **For a file, the text inside it is extracted afterwards — and that is ours, not yours.** It does not qualify this answer: the upload is complete either way. An extraction can end as `no-text` (a scanned document: it FINISHED and there was no text), `unsupported` or `failed`, and none of those means the file is missing. To see how it went, read the block: a `file` block carries `extraction.status`, how many characters came out and whether it was truncated. The extracted text itself is never served — it has one consumer, the search index, and it would add up to 30,000 characters per block to every read. "Does not exist" and "is not yours" answer identically, which is deliberate: telling them apart would leak which keys exist in another tenant.

Takes a JSON body — see openapi.json for its schema.

POST /v1/documentation/paths:rename

Rename a folder across every page that carries it

Scope write:docs · MCP toolrenameDocumentationPath

A folder is not an entity — it **is** the literal value of a page's `path`, and it exists exactly as long as some page carries it. Renaming one therefore rewrites that value on every page at once. ⚠️ **This writes rows you did not name**, which is why the response says how many it touched: a silent success on a rename that matched nothing looks identical to one that moved twelve pages, and you cannot see the rows. **If `to` already exists, the two folders merge.** `updated: 0` is a valid answer, not an error — a folder that "does not exist" is a string no page carries.

Takes a JSON body — see openapi.json for its schema.

POST /v1/documentation/paths:clear

Unfile every page in a folder

Scope write:docs · MCP toolclearDocumentationPath

**Nothing is deleted.** The pages survive and become loose; only their `path` goes to `null`. The verb is `clear` for that reason — calling it delete would make you hesitate over the safe operation, and stop you hesitating over `DELETE /v1/documentation/pages/{page}`, which really does destroy. Reports how many pages it unfiled, for the same reason the rename does.

Takes a JSON body — see openapi.json for its schema.

Entity search

GET /v1/entities/{entity_type}/{entity_id}/documentation

Read the documentation blocks on an entity, and what they reference

Scope read:docs · MCP toolgetEntityDocumentation

Returns the blocks in the SAME shape used to write them, which is what makes it possible to compare instead of replacing blindly. Call this before writing: the write surface replaces rather than merges. **Alongside `blocks` comes a `mentions` branch** — the entities this documentation references, resolved to `{ entity_type, entity_id, count, name, exists }`, so a reference does not cost a lookup each. It is a separate branch rather than a field inside each block so that reading the text alone costs nothing extra; the raw per-block `mentions` stays, and is the only thing that says which block references what. **`exists` is the field to read first.** `false` means the reference is dangling — deleted, never there, or belonging to another design system — and `name` is `null`. Without the flag a null name reads as a failed lookup rather than as a broken reference, and the two need opposite responses. The branch is always present and is `[]` when there is nothing to resolve: a branch that appears only when it has data is indistinguishable from one that does not exist. **`entity_id` accepts a doc page slug.** With `entity_type=doc-page` the readable slug works wherever the uuid does. Renaming the page title does NOT touch it — the slug is not a field of the edit action. **Changing it is a separate, deliberate operation that does not exist yet**; the day it does, a stored slug can stop resolving and you get a 404 naming it, never a different page. The uuid stays canonical, and the slug is unique **per project**, not globally.

Name In What it does
entity_type required path
entity_id required path

PUT /v1/entities/{entity_type}/{entity_id}/documentation

⚠️ Replace ALL documentation on an entity

Scope write:docs · MCP toolwriteDocumentationBlocks

DESTRUCTIVE. Deletes every existing block on the entity and writes the ones in the body. It does not merge and it does not ask. **`PUT` because that is exactly what it does** — replace the full contents of a resource. Returns `deleted` so the caller can report afterwards what it replaced. `updatedBy` is null for anything written through the API, because an API key carries no user identity today. Read the same path with `GET` first unless you already know the entity is empty. **`entity_id` accepts a doc page slug.** With `entity_type=doc-page` the readable slug works wherever the uuid does. Renaming the page title does NOT touch it — the slug is not a field of the edit action. **Changing it is a separate, deliberate operation that does not exist yet**; the day it does, a stored slug can stop resolving and you get a 404 naming it, never a different page. The uuid stays canonical, and the slug is unique **per project**, not globally.

Name In What it does
entity_type required path
entity_id required path

POST /v1/entities/{entity_type}/{entity_id}/documentation

Add blocks without destroying the existing ones

Scope write:docs · MCP toolwriteDocumentationBlocks

Appends after what is already there. Nothing is deleted. **Retrying duplicates** — there is no matching, so two identical calls leave two copies. `POST` is not idempotent and neither is this; on a timeout, check with `GET` on the same path before retrying. **`entity_id` accepts a doc page slug.** With `entity_type=doc-page` the readable slug works wherever the uuid does. Renaming the page title does NOT touch it — the slug is not a field of the edit action. **Changing it is a separate, deliberate operation that does not exist yet**; the day it does, a stored slug can stop resolving and you get a 404 naming it, never a different page. The uuid stays canonical, and the slug is unique **per project**, not globally.

Name In What it does
entity_type required path
entity_id required path

Takes a JSON body — see openapi.json for its schema.

DELETE /v1/entities/{entity_type}/{entity_id}/documentation/{block_id}

Hard-delete one block

Scope write:docs · MCP toolwriteDocumentationBlocks

No trash and no undo — unlike the vault UI, nothing is recoverable. **`DELETE`, and under the entity, and both halves are safety rather than style.** A `POST` that destroys tells no HTTP tooling — proxy, log, retry policy — that it is destructive. And hanging it off the entity turns "delete this id" into "delete this id, and I assert it belongs to this entity", which the server can reject: a block belonging to another entity is not deleted. **`entity_id` accepts a doc page slug.** With `entity_type=doc-page` the readable slug works wherever the uuid does. Renaming the page title does NOT touch it — the slug is not a field of the edit action. **Changing it is a separate, deliberate operation that does not exist yet**; the day it does, a stored slug can stop resolving and you get a 404 naming it, never a different page. The uuid stays canonical, and the slug is unique **per project**, not globally.

Name In What it does
entity_type required path
entity_id required path
block_id required path

POST /v1/entities/{entity_type}/{entity_id}/documentation:batchDelete

Hard-delete several blocks atomically

Scope write:docs · MCP toolwriteDocumentationBlocks

Exists alongside the singular `DELETE` because **this surface is all-or-nothing per request**: deleting N blocks with N calls loses that guarantee, and a failure halfway leaves the entity in a state nobody asked for. Returns how many rows actually went, which is NOT always how many ids were passed: an id already gone, or belonging to another entity, deletes nothing. **`entity_id` accepts a doc page slug.** With `entity_type=doc-page` the readable slug works wherever the uuid does. Renaming the page title does NOT touch it — the slug is not a field of the edit action. **Changing it is a separate, deliberate operation that does not exist yet**; the day it does, a stored slug can stop resolving and you get a 404 naming it, never a different page. The uuid stays canonical, and the slug is unique **per project**, not globally.

Name In What it does
entity_type required path
entity_id required path

Takes a JSON body — see openapi.json for its schema.

GET /v1/entities/{entity_type}/{entity_id}/graph

Read the graph around one entity

Scope read:library · MCP toolgetEntityGraph

Which component uses which component, binds which token, applies which style — and the `metadata` that makes each edge actionable (`property`, `variants`, `nodeNames` on a token binding). This is the **inverse** of `getEntities`: it answers what breaks if you change something. **The graph always hangs off one node**, which is why it is in the path: an earlier version took optional ids and could be talked into scanning the project. Both ends carry `name` where it resolved; `null` means the id did not resolve, not that the entity is unnamed. **`truncated: true` means you got a slice — pass `next_cursor` back as `cursor` to walk the rest.**

Name In What it does
entity_type required path
entity_id required path
edge_types query `USES_COMPONENT` | `BINDS_VARIABLE` | `USES_STYLE` | `STYLE_BINDS_VAR` | `ALIASES_VARIABLE`. Repeatable or comma-separated. **Narrows the result; it does not select the node.**
direction query `outbound` = what this uses · `inbound` = what uses THIS, the impact question · `both` (default). The default is `both` on purpose: with one node and no direction stated, returning one side would hide half the graph without saying so.
limit query 1..500, default 100. Above 500 truncates.
cursor query From a previous `next_cursor`. Pass it back verbatim; do not construct one. The page order is stable, so paging twice yields the same edges.

GET /v1/entities/{entity_type}/{entity_id}/related

Walk the graph around one entity

Scope read:library · MCP toolfindEntities

Everything reachable from one entity, with **how many hops away it is and the route it was reached by**. `hops` is a number and `via` is the path — one entry per edge crossed, naming the edge type, the direction, and the node it passed through. A "direct / indirect" label cannot tell you whether a token turned up because this component binds it or because a style it uses binds it; `via` can. It is the traversal counterpart of `GET /v1/entities:match`: that one takes a term and narrows by ENTITY type, this one takes an id and narrows by EDGE type. It also differs from `…/graph`, which returns raw EDGES with their `metadata` for one hop — use that to find *where* in a component a binding lives, and this to get the set of things connected to something. ⚠️ **`depth` defaults to 1 and the graph is dense** (thousands of edges over a few hundred nodes), so a second hop multiplies the answer rather than adding to it. Every response carries `more`: `edges_to_expand` is an exact count of the edges the next level would follow, and `est_tokens` is a CEILING derived from it — it assumes every edge reaches something new, and on measured walks only 27-43% did. `more: null` means the ceiling was reached or the walk is already complete. `truncated: true` means the node ceiling was hit and `related` is a floor.

Name In What it does
entity_type required path
entity_id required path
depth query 1..3, default 1. Above 3 it is REJECTED, not truncated: a silently lowered depth would read as the complete closure. Do not raise it speculatively — `more` counts what the next level costs before you pay.
edge_types query `USES_COMPONENT` | `BINDS_VARIABLE` | `USES_STYLE` | `STYLE_BINDS_VAR` | `ALIASES_VARIABLE`. Repeatable or comma-separated. Narrows which relationships are followed; it does not select the starting node. The cheapest way to make a deep walk affordable.
direction query `outbound` = what this uses · `inbound` = what uses THIS, the impact question · `both` (default). It applies to every hop, so `outbound` at depth 2 is "what this uses, and what those use" — not a mixed walk.
limit query Entities per page, 1..200, default 50. It bounds what is RETURNED, not what is walked: lowering it does not make a depth-2 walk cheaper on the server, only in your context.
cursor query From a previous `next_cursor`. Pass it back verbatim. Keep every other argument identical while paging — the walk is recomputed per call, so the page order only holds for the same question.

documentation:validate

POST /v1/documentation:validate

Validate blocks without writing anything

Scope read:docs · MCP toolwriteDocumentationBlocks

Runs the same validation the writer runs, so a block that passes here cannot be rejected at write time for its shape. **Writes nothing.** With `entityType` and `entityId` it also checks that each shape accepts that kind of entity and that the entity exists in this project. **It also checks the entities a block REFERENCES, not only its shape.** The `alternatives` of a `use-cases` item must be `{ entity_type, entity_id }` objects whose `entity_id` is a real uuid in this project; a bare name, a made-up id, or an id from another project is rejected with the exact field path (`items[0].alternatives[0].entity_id`) rather than passing and then being dropped when the block is stored. Requires `read:docs` and not `write:docs` on purpose: demanding write access to validate pushes a caller to test by writing, which is what this exists to avoid.

Takes a JSON body — see openapi.json for its schema.

tokens:describe

GET /v1/tokens:describe

Map the token collections, their modes, and what each layer costs

Scope read:library · MCP tooldescribeTokens

The entry point for anything about design tokens. Every collection with its modes, its token count, and **the estimated cost of the next two layers for that collection** (`cost.list_tokens`, `cost.token_values`, each with `count`, `est_tokens` and the exact `how` call). A token does not have one value — it has one per MODE, and mode names are chosen per collection with no common axis (`SDS Light`, `Value`, `Mode 1`, `Default`), so this is where a caller learns which modes exist. Returns no token names and no values: `GET /v1/tokens` serves names, `POST /v1/tokens:values` serves values. `est_tokens` is an estimate from measured per-record sizes, not an exact count.

tokens

GET /v1/tokens

List token ids and names

Scope read:library · MCP toollistEntities

The token index — `id`, `name`, `resolved_type`, `collection`. **No values.** **Without a filter this does NOT return every token**: it returns the first page plus the `/v1/tokens:describe` map under `unfiltered`, because a complete index costs on the order of 16,000 model tokens and is almost never what was wanted. **Page order is by internal id — stable, and not alphabetical.** Token names are not unique within a project, so a name-ordered cursor would drop rows at a page boundary; `name_prefix` is how to get a name-coherent slice. Tokens deleted in Figma are excluded.

Name In What it does
collection query A collection id (UUID) from `/v1/tokens:describe`. The strongest filter and usually the right one. Takes the id, never the collection name. An id from another project is a 404, identical to one that does not exist.
type query `COLOR` | `FLOAT` | `STRING` | `BOOLEAN` — the complete set, there are no others. Most design systems are overwhelmingly `COLOR`, so this narrows little on its own; pair it with `collection`.
name_prefix query Keep only tokens whose name STARTS WITH this string, case-insensitively. Names are path-like (`Background/Brand/Default`), so a prefix takes a branch. **Prefix, not substring and not a pattern**: `Brand` does not match `Background/Brand/Default`, and `%` / `_` are literal. To match anywhere in a name, use `GET /v1/entities:search`. Spelled `namePrefix` on the MCP tool.
scope query Reverse scope search: which tokens can be applied to this property. `scope=CORNER_RADIUS` returns the tokens usable as a corner radius. **`scope=NONE` returns the tokens with NO scope**, which is a deliberate state and not missing data — a designer hid them from Figma's property pickers, typically a primitives layer. They cannot be applied to a node, and they are the ones most often used as ALIAS targets. An unknown scope is a 400, not an empty page: the two are indistinguishable to a caller and only one is worth another call. The catalogue lives on the MCP tool — `listEntities({ of: 'token-scopes' })`; REST has no endpoint for it, and the 400 lists what it expected.
include_scopes query Attach each token's scope state: `"ALL"`, `"NONE"`, or the list of scope names. Off by default because it is per-token, not per-collection, so it grows with the page. Spelled `includeScopes` on the MCP tool.
cursor query Opaque cursor from a previous `pagination.next_cursor`. Pass it back verbatim; do not construct one. Keep the other filters identical while paging.
limit query 1..500, default 100. Above 500 is served as 500, not rejected; below 1 is a 400. Raising it is not a substitute for filtering.

tokens:values

POST /v1/tokens:values

Resolve token values per mode, with the alias chain walked

Scope read:library · no MCP equivalent

The resolved value of each token **in each of its modes**, plus `via` — the chain of aliases walked to reach it. This is what turns a spec binding like `{"$token": "Space/400"}` into a number. **A value is `null` only when `unresolved` says why.** On `ambiguous_target_mode` the alias lands in a multi-mode collection with no matching mode, and `unresolved.candidates` carries the value under each of them — the answer is a set, not a failure. `alias_depth.reached` reports whether a chain was cut at the hop ceiling, so a truncated chain never looks like an unset value. **Colours come back twice on purpose:** `value` is hex rounded to 8 bits per channel and `rgba` is exactly what Figma stored, as floats — use `rgba` when precision matters. `POST` rather than `GET` because `ids` carries up to 50 uuids, the same reason `/v1/components/specs:batchGet` is a POST. It reads and writes nothing.

Takes a JSON body — see openapi.json for its schema.

styles:describe

GET /v1/styles:describe

Map the style types, their counts, and what each layer costs

Scope read:library · MCP tooldescribeStyles

The entry point for anything about styles — typography, fills, shadows, grids. All four style types, how many live styles each holds, and **the estimated cost of the next two layers for that type** (`cost.list_styles`, `cost.style_values`, each with `count`, `est_tokens` and the exact `how` call). **A type with `count: 0` has no styles in this project, and its `note` says the gap is in what the sync captured from Figma rather than in this API.** `EFFECT` is empty in every project measured so far, so a component shadow is not recoverable — reading this first is how a caller avoids proving that by hand. `totals.with_figma_style_id` reports how many styles carry the raw Figma key; a shortfall does NOT make those styles unreachable by key, because `/v1/styles:values` also matches the bare hex. Returns no style names and no properties: `GET /v1/styles` serves names, `POST /v1/styles:values` serves properties. `est_tokens` is an estimate from measured per-record sizes, not an exact count.

styles

GET /v1/styles

List style ids, names and their raw Figma keys

Scope read:library · MCP toollistEntities

The style index — `id`, `name`, `type`, and `figma_style_id`, the raw `S:…,` key a component spec uses to reference the style. **No properties.** The key is in the index so a caller can travel either direction: spec → style, or style → the specs that use it. It is `null` on some rows, which does not make those styles unreachable by key — `POST /v1/styles:values` also matches the bare hex against `remote_id`. **Page order is by internal id — stable, and not alphabetical.** Style names are unique within a project today but nothing enforces it, so a name-ordered cursor would start dropping rows the first time two styles shared a name; `name_prefix` is how to get a name-coherent slice. Without a filter the response also carries the per-type breakdown under `unfiltered`, so an empty result for a type is visibly the data. Styles deleted in Figma are excluded.

Name In What it does
type query `TEXT` | `PAINT` | `EFFECT` | `GRID` — the complete set, there are no others. Check `/v1/styles:describe` first: a type with `count: 0` returns an empty list no matter what else you pass, and `EFFECT` is empty in every project measured.
name_prefix query Keep only styles whose name STARTS WITH this string, case-insensitively. Names are path-like (`Aurora/Headline/H1`), so a prefix takes a branch. **Prefix, not substring and not a pattern**: `Headline` does not match `Aurora/Headline/H1`, and `%` / `_` are literal. To match anywhere in a name, use `GET /v1/entities:search`. Spelled `namePrefix` on the MCP tool.
cursor query Opaque cursor from a previous `pagination.next_cursor`. Pass it back verbatim; do not construct one. Keep the other filters identical while paging. Most design systems fit in one page, so this is rarely needed.
limit query 1..500, default 100. Above 500 is served as 500, not rejected; below 1 is a 400.

styles:values

POST /v1/styles:values

Resolve style properties, by id, by Figma key, or by type

Scope read:library · no MCP equivalent

The full properties of a style — what it is actually worth. This is what turns `Aurora/Label/L3 Weak` from a name into a font, a size and a line height. **The fast path from a component spec:** a binding shaped like `{"$token": "S:3cddd5cb…,"}` is a STYLE, not a variable. Send that string in `figma_style_ids` — no lookup first, and `/v1/tokens:values` will never resolve it. **`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 — the set varies with what the design file contained, and absent ones are not filled in. **`bound_variables` lists the properties driven by a token** rather than by the literal; where one is present the literal is the value as of the last sync, and `token_id` resolves through `/v1/tokens:values` for the live one. `POST` rather than `GET` because each input array carries up to 50 values and a Figma key is 43 characters — the same reason `/v1/tokens:values` is a POST. It reads and writes nothing.

Takes a JSON body — see openapi.json for its schema.

components:driftSummary

GET /v1/components:driftSummary

Get the drift summary

Scope read:library · MCP toolcheckHealth

Every component whose spec predates the minimal supported schema version. **If `truncated` is true, `drifting_count` is a floor, not a total.**