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.

58 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 identity and their derived signals. Each row carries `figma_key`, the Figma id that persists across files: use it to find the component in the library and to instantiate it from any other file. **One row per component, not per Figma file:** `id` is the component's canonical id, and `representations` lists every file it lives in, primary first, each with its `figma_id` (node id), `figma_file_key` and `sync_status`. Walk the full set by passing `pagination.next_cursor` back as `cursor`. Scores are only comparable within the same `search_mode_used`. `name` is the component's own name (`"Services"`) and `path` its Figma folder, `null` until the component re-syncs. ⚠️ **Since v1.310.0 `name` and `path` can differ from Figma's:** when a person names the component in Grails, that is its name everywhere, and `representation_name` / `representation_path` carry what Figma calls it (`null` when the two agree). **`lives_in` says where the component lives:** `design`, one entry per Figma file (`file` with its key and name, `node_id`, `url`, `has_spec`, `status`), and `code`, one per code source (`source`, `platform`, `package`, `repo`, `file`, `export`, `has_spec`). An empty list means it is not there; `null` means that side could not be read, never that it is absent. **`pairing` says how far its design ↔ code pairing is** (v1.372.0): per pair `{ design, code, linked, open: { design, code } }`, the parts linked and the parts of each side no one linked or marked OK; `open` is `null` when a side has no spec, so there is nothing to compare. `[]` means no pair; `null` means it could not be read. The links themselves are `GET /v1/components/{id}/pairing`. **Since v1.312.0 a component that lives only in code is listed too:** `side: "code"`, `representation_id` is its code row, its Figma fields are `null` (no node, depth, type or sync state), `has_spec` says whether it has a code spec and `has_drift` is `null`. Every Figma-row component says `side: "design"`. A Figma filter (sync state, publish state, depth, type, drift) leaves code-only components out, because they have no value for it. A component deleted in Figma that still lives in code is listed too, through its code row: a deletion is not a presence. Each component is listed once, through a live Figma row, else an active code row, else a deleted Figma row. **There is no `total` in the response payload.** 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 requests currently fall back to lexical search; the actual mode used is returned 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; everything else is only what you name. Omitting `fields` returns the default set, which includes `figma_key` — the Figma id that persists across files — and `path`. `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: `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 AUDIT filter. `true` | `false`. Matches when the Figma file moved since the last sync (`sync_status <> "synced"`) OR the stored spec predates the minimum supported schema version. The second is platform debt, not a property of the component. `has_drift` is NOT in the default payload — request it with `fields` to read it per row. To judge one component, read `sync_status`, which is in the default payload.
lives_in query `design` (only in Figma), `code` (only in code) or `both`, by the component's canon.
pairing query `open`: a design ↔ code pair has parts no one linked or marked OK. `complete`: every pair is done. A pair with no spec on a side has nothing to compare and is neither.
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` and `has_spec`. `name` is its own name and `path` its Figma folder (`null` until it re-syncs). **`has_drift` is not in the default payload** — it is an audit signal, requestable through `fields`. A component deleted in Figma is still readable by id — check `sync_status`, which is the per-component signal and says HOW the file moved. `{id}` is a Grails id or a Figma reference, url-encoded: a node id (`1234:5678`), `<file key>/<node id>`, or a component or variant key; a variant's answers its set. A reference two files share is a 404 `GRAILS_COMPONENT_NOT_FOUND` with `details.matched` (each with its `file_key`); one in a project whose specs hold variants with that kind of id unrecorded, a 404 with `details.reason: "not-indexed"` and `sets_without_ids`.

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

⚠️ **A property serves at most 20 suggested values** (its `anyOf` and `$extensions['com.figma'].preferredValues`, cut in the same order). When it has more, `props[name].candidates` says `shown`, `total`, at most what the next page costs (`est_tokens`) and where it is: `GET /v1/components/{id}/props/{prop}/candidates`. **Returns the ABSTRACT, not the whole spec**: `title`, `props`, `anatomy`, `anatomy_scope`, `metadata`, `_grails` and — for a variant set — `variantConfigurations`, every combination that exists WITHOUT its elements. ⚠️ **`anatomy` is set-level, which `anatomy_scope: "set"` states in the response itself.** Filtering by configuration and then reading `anatomy` gives the SAME tree for every configuration. The configuration you asked for is in `variants[].elements`. ⚠️ **An element key is not always the layer name**: layers that share a name get distinct keys (`Button`, `Button2`), and the designer's name is in `anatomy[key].$extensions['com.figma'].name` when it differs. A key is stable across variants but not a long-lived id. The abstract is returned by default; `?include=variants` includes every variant element tree. `{id}` takes the same Figma references as `GET /v1/components/{id}`; a variant's serves that variant. **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 subset. `more.token_values.count` counts the WHOLE document, and `?include=tokenValues` resolves every one. **`spec.invalidPropCombinations`** (named `invalidVariantCombinations` before specs-schema 0.35; every stored spec carries the new name) is derived as `cartesian − (variants[] + default)`: the combinations with no element tree of their own. Read it as that, never as designer intent — Figma offers no way to mark a combination invalid. Some stored specs were derived before the pipeline stopped listing the default configuration, so it may still appear here although it exists; compare against `spec.default.configuration`. **`spec._grails.chi` is the Component Health Index:** `score` is 0–100 (higher is healthier) = `100 − (po + dc + vg + tg + ta_penalty)`, and `status` buckets it (`optimal` ≥ 95, `good` ≥ 60, `monitor` ≥ 30, else `poor`). `po` (Property Overload, weight 20), `dc` (Design Coverage, weight 15), `vg` (Variant Grid, weight 10: variant combinations past 32) and `tg` (Toggles, weight 15: booleans and slots past 5) 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`/`vg`/`tg` are 0 by construction. It is the CHI computed when this version was stored: when the formula changes, the component's `chi_score` is recalculated and this copy is not, so the two can differ. **Read `formulaVersion` before the factors.** Formula 4 evaluates factors `po`, `dc`, `vg`, `tg` and `ta`. A spec stored with formula 3 (`formulaVersion: 3`) or earlier carries legacy factor `as` instead of `vg` and `tg`. **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. **`pairing_map`** (v1.372.0) translates the spec to its counterpart: per counterpart, the `props` linked (`{ design, code, values: [{ design, code }] }`) and the `elements` linked (`{ design, code }`), by key. `[]` means nothing is linked yet; `null` means it could not be read. What is still open is `GET /v1/components/{id}/pairing`.

Name In What it does
id required path
side query `design` | `code` (since v1.312.0). Which spec: the Figma one or the code one. Omit it for the one the component has: design when it lives in Figma (as before), code when it lives only in code. The answer always says `side`. A side the component does not live on is a 404 that names it. The code spec answers `{ component_id, side: "code", source, file, export, spec, spec_format, chi, coverage, synced_at, git }` and is served whole: `include` and `variant` apply to the design spec only, and passing them with `side=code` is a 400.
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}/props/{prop}/candidates

List the values a property suggests

Scope read:library · MCP toollistEntities

The values the designer suggested for one property of one component, typically the components a slot accepts, **a page at a time in the designer's order**. The spec serves only the first 20 of each property and marks the rest in `props[name].candidates` (`shown`, `total`, `est_tokens`, and this call): one slot can suggest more than a thousand components. This door starts at the first suggestion, so its first page repeats the ones the spec showed. `{prop}` is url-encoded and takes either spelling, the exact Figma name (`↳ Icon`) or its canonical form (`icon`): split at every run of characters other than letters and digits (any script), the first word's first letter lowered and each other word's raised, nothing else changed (`Show Center Content` → `showCenterContent`); the response echoes `prop` with the exact name. A canonical form two properties share is a `404` with both in `details.ambiguous`. Each entry of `data` is a whole component (`id`, `type`, `name`, `path`, `is_asset`, `sync_status`, `node_id`), ready to instantiate. **Read `resolution` and `unresolved_keys` before believing a short page**: `resolution.suggested` counts the whole list, `in_page` and `resolved` this page, and `unresolved_keys` names the page's suggestions this project holds no component for — they live in libraries it has not synced, and retrying will not find them here. A cursor is refused with `GRAILS_INVALID_CURSOR` (400) when the component was synced again since it was issued: the new spec is another list, and walking it from an old position would skip or repeat suggestions without a sign. Restart without a cursor.

Name In What it does
id required path
prop required path
limit query Suggestions per page. Defaults to 50, at most 200. Above the maximum it is a 400, not clamped: a clamped page would read as the whole list.
cursor query `pagination.next_cursor` from the previous page.

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. Each entry is `{ position, component_id, name, path, component_depth, is_leaf, grails_sync_status }` — `name` the component's own and `path` its folder. 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. Preserving it keeps the dependency chain intact. `component_depth` may be `null` when the component has not been ranked, not `0`: leaves are depth 1. The order does not depend on this field: it is computed from 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 closure size. Retrying the same id will not help: it is a limit, not a transient failure.

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.

GET /v1/components/{id}/pairing

Which part of a Figma component is which part of its code component

Scope read:library · MCP toolgetComponentPairing

The links between a Figma component and its code component: which element, prop and prop value of one is which of the other. `{id}` is the Figma component, the code component or the entity. → `{ canon_id, pairs, pair, choose_pair }`: `pairs` lists every Figma × code pair of the entity (one per Figma file); `pair` is the only one, or the one asked with `design` and `code`; several and none asked is `pair: null` and `choose_pair: true`, never the first. `pair.links[]`: `{ id, part, design, design_value, design_label, code, code_value, code_label, method, decided_by, created_at }`. `design` and `code` are each side's KEY (what a write takes); the `_label` is the name that side shows. `method` is `auto` when Grails linked it because the match was certain, `manual` when a person or an agent did. Grails links a part automatically only when it is new: what it could not match stays unlinked until someone links it. Links belong to the component, not to a variant. `pair.ok_without_counterpart[]` lists the parts marked OK in the app, read only. `pair.expect` is the latest spec of each side, to send back as the write's `expect`. `pair.reconcile_failed: true`: the links may be behind the specs until the next sync succeeds. `pair.state` is what the Both view shows, computed by the same functions: `design` and `code`, each with `props` and `elements` (`{ key, label, kind, values?, state, decision_id }`, `state` `linked`, `ok` or `open`); `summary` (`connected`, `matching`, `drifting`, `mismatched`, `uncompared`, `only_design`, `only_code`); and `links`, by link id: `wire` (`token`, `match`, `token-value`, `mixed`, `partial`, `mismatch`, `uncompared`; `null`: no wire here, because the code element is not part of this code configuration or one of the two parts is no longer in its spec), a prop link's `values` settled per side, an element link's `differs` and `properties[]` (`{ property, label, design, code, same, wire, token_drift }`). Each side is `{ value, tokens, unresolved?, in_figma? }`: `value` as text, `tokens` the names it came from. `value` is `null` when the side sets the property only through a token whose value Grails could not read (usually one not synced); `tokens` then names it, `unresolved` is `true` (since v1.378.0; absent when the side has a value), `same` is `null` and `wire` is `partial`, or `token` when both tokens are one canon (since v1.376.3). A token Grails has not synced VARIABLE whose spec carries the value Figma resolved it to (a component synced since v1.377.0) has that `value`, no `unresolved`, `in_figma: true`, and is compared by it (since v1.378.1): 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. `uncompared` is an element link with nothing to compare: no property set on both sides, none of those with a value known on both sides (`differs: 0`), or the Figma element not shown in this variant (no `differs` then). It is never a match, and `summary.uncompared` counts it. A link with some properties compared and others not is `partial` whatever the compared ones say (what was not compared may agree), counted in `summary.drifting`; `differs` counts the compared ones that differ. Elements are drawn in one Figma variant, one code variant and one mode per collection: `compared_in` says which, and `can_ask` what the four view parameters can name. A name the pair does not have is a `422` whose `missing` lists the valid ones. `state` is `null` while a side has no spec. Requires **`read:library`**.

Name In What it does
id required path
design query With `code`: the pair to read, its Figma component (`pairs[].design.id`).
code query With `design`: the pair to read, its code component (`pairs[].code.id`).
configuration query A JSON object: the Figma variant, one value per axis of `can_ask.design_axes`. Default: the set's default.
code_configuration query A JSON object: the code variant, by `can_ask.code_axes`.
modes query A JSON object: a mode per collection of `can_ask.modes`; one left out shows its default.
booleans query A JSON object: boolean props of `can_ask.booleans`, `true` or `false`.

POST /v1/components/{id}/pairing

Link or unlink parts of a Figma component and its code component

Scope write:pairing · MCP toolwriteComponentPairing

Runs `operations` on ONE pair, in order, all or nothing: `{ op: "link", part, design, code, design_value?, code_value? }` or `{ op: "unlink", decision_id }`. `part` is `element`, `prop` or `prop_value`; `design` and `code` are each side's KEY as the read gives it, never the name shown. A `prop_value` link names its two props in `design`/`code` and its values in `design_value`/`code_value`, and needs its two props linked. A part has one link per side: to link it elsewhere, unlink it first (in the same call, before). Linking a prop also links its values whose names match; unlinking a prop removes its value links. This never unmatches the two components. → `{ dry_run, results }`, one per operation: `{ op: "link", link, brought }` (`brought`: the values the prop link linked) or `{ op: "unlink", removed }`. The links are written as the key's owner; there is no undo but linking again what `removed` returned. `dry_run: true` answers the same and writes nothing. `expect` (the read's `pair.expect`): a side synced since is `409 GRAILS_PAIRING_CHANGED`, nothing written. A failing operation is a 422, 404 or 409 with `details.field` = `operations[N]`, and nothing of the call is written. `design` must be a Figma component of `{id}`'s entity. Requires **`write:pairing`**, which does not grant reading.

Name In What it does
id required path

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.invalidPropCombinations` is ignored here: it is derived from the same fact this check reads, which configurations have an element tree, 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 · MCP toolfindEntities

Search across eight kinds of entity documentation can be attached to. Returns ids, so a writer can point at real entities instead of inventing uuids. The type list is the `entityType` enum the documentation endpoints accept, but for `pattern`: a pattern is documented the same way and found with `GET /v1/patterns`. **Five engines run and the result says which matched.** `semantic` (vector similarity over indexed documentation), `text` (full-text over that same documentation), `name` (a literal case-insensitive substring of the name, the only engine that covers the whole catalogue), `graph` (what is connected to those hits) and `declared` (below). 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. A component or doc-page match carries `path`, its folder: it tells apart two components with the same `name`. `matched_by: ["declared"]` is the one that is not a measurement: it means a PERSON asserted the connection. Weigh it high for relevance and low for impact — an opinion is not a dependency — and it never travels mixed into `graph`. ⚠️ **Read `coverage` before interpreting an empty result.** Coverage is reported per engine: an engine with `ran: false` did not execute, whereas an engine that ran and found nothing reports 0 hits. **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` indicates match clarity: `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. The response carries a `Server-Timing` header (since v1.319.1): each engine and later stage with its time, its calls and its database queries (`desc="1 call / 3 queries"`), then `serialize` and `total`: times and counts only.

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".
lives_in query Components only: keep those that live only in Figma (`design`), only in code (`code`) or in `both`. It narrows the ranked results, so a page can hold fewer than `limit`; `GET /v1/components` filters exactly.
pairing query Components only: keep those with design ↔ code parts left to pair (`open`) or fully paired (`complete`); each kept component carries its `pairing`. Narrows the ranked results, as `lives_in` does; with either filter a match keeps its rank and `truncated.total` counts the candidates before the filter (`counted_before_filter: true`).
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

Creates a standalone documentation page in the project. ⚠️ **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.** Page slugs are immutable after creation. `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.** Page slugs cannot be modified after creation. 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 specifies permitted external hosts when a disallowed URL is supplied. ⚠️ **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: an abandoned upload stays charged against the quota until expired reservations are cleared. ⚠️ **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 }` — a reference, so no `path` — 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. **Slugs are immutable once created.** 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. The response carries `dropped_mentions` (always present, `[]` when none): each `{{type:id}}` that names nothing in this project, as `{ block, type, id }`. It is never recorded, and its text stays in the block, where it reads as a missing entity. **`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. **Slugs are immutable once created.** 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. The response carries `dropped_mentions` (always present, `[]` when none): each `{{type:id}}` that names nothing in this project, as `{ block, type, id }`. It is never recorded, and its text stays in the block, where it reads as a missing entity. **`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. **Slugs are immutable once created.** 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. **Slugs are immutable once created.** 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. **Slugs are immutable once created.** 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**, specified in the URL path. Both ends carry `name` where it resolved; `null` means the id did not resolve, not that the entity is unnamed. An end is a reference and carries no `path`. **Ends are entities, not Figma files:** `id` is the canonical id at each end, and the path may name any representation of the node. When one row folds several stored edges (the same pair seen in two files) or an end is not its entity's own row, it carries `edge_count` and `origins` (each `{ edge_id, source_id, target_id, metadata }` with the representation ids), and `metadata` is `null` when it folds more than one. **`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 Measured verbs: `USES_COMPONENT` | `BINDS_VARIABLE` | `USES_STYLE` | `STYLE_BINDS_VAR` | `ALIASES_VARIABLE`, from code `CODE_USES_COMPONENT` | `CODE_USES_TOKEN` | `CODE_ALIASES_TOKEN`, from patterns `PATTERN_USES_COMPONENT` | `PATTERN_CONTAINS_PATTERN`. Asserted verbs: `relates-to` | `see-also` | `pairs-with` | `excludes` | `alternative-to` | `replaces` | `refines` | `same-as`, which need `provenance`. Repeatable or comma-separated. **Narrows the result; it does not select the node.**
provenance query `derived` (default) — what the sync MEASURED off the Figma file; this is the one that answers what breaks if you change something. `declared` — what a person ASSERTED about two entities. `any` — both. Every edge in the response carries `provenance`.
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 route — 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. A component or doc-page result carries `path`, its folder; a pattern result its `status` (`draft` or `published`); `origin` and each `via` step are references and carry only a name. `entity_type` is `component`, `token`, `style`, `token-collection` or `pattern`. 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. `more: null` means the ceiling was reached or the walk is already complete. `truncated: true` means the node ceiling (1000 entities) was hit and `related` is a floor; every edge of a level is read, however many (since v1.320.1). The response carries a `Server-Timing` header (since v1.319.1): `walk` (the one call that walks every hop, since v1.320.1), `hydrate`, `own_names`, each with its calls and its database queries, then `serialize` and `total`: times and counts only.

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` | `PATTERN_USES_COMPONENT` | `PATTERN_CONTAINS_PATTERN`. 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. **This walk follows MEASURED relationships only** — it reports structural distance, so a relationship a person asserted is not a hop. Ask for those through the graph endpoint with `provenance`. A pattern's edges (`PATTERN_*`) are followed: a component reaches the patterns that use it, a pattern what it uses and the patterns that contain it, each pattern with its `status`. A code sync's (`CODE_*`) are not, and naming one is a 400: a component's code relationships are on its graph.
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.

GET /v1/entities/{entity_type}/{entity_id}/blast-radius

What a change to one entity would reach

Scope read:library · MCP toolgetBlastRadius

"If I change this, what do I affect?" (since v1.317.0). Everything that USES a component, token, style or pattern, directly or not: aliases of a token, styles and components that bind it, components that use those styles or components, in Figma and in code, and the patterns that use those components or contain those patterns. One row per entity, by its canonical id: a component in Figma and in code is one row with `sides: ["design", "code"]`, and a variant counts for its set. A pattern has `sides: []` and its `status` (`draft` or `published`): drafts are answered too. A deleted entity is not an answer. Each row has `depth` (0 = a direct use), `via` (`direct`, or what it uses on its way: `alias`, `style`, `component`, `pattern`), `through` (the entity it uses one level closer, `null` at depth 0) and `sides`. `counts` covers the WHOLE answer, every page; `next_cursor`, at the root, walks the rest. It differs from `…/related`, the neighbourhood (every relation, both ways, at most 3 hops): this one follows only the relations of use, inbound, up to 16 levels, in one walk. `504 GRAILS_TIMEOUT` means the walk did not finish within the time budget: ask a lower `max_depth`. The response carries a `Server-Timing` header with the time of each step (since v1.319.0: `origin`, `rpc`, the one call that walks, folds and names, `serialize`, `total`, which counts the database queries since v1.319.1): times and counts only.

Name In What it does
entity_type required path
entity_id required path
max_depth query 0..16, default 0: **direct uses only**. 16 reaches everything. The walk is bounded by entities × levels, so a deep one stays cheap.
types query `component` | `token` | `style`. Repeatable or comma-separated. Keeps only those kinds in the answer (and in `counts`); the walk still goes through every kind, so a component reached through a style is still found.
limit query Entities per page, 1..200, default 50.
cursor query From a previous `next_cursor`. Pass it back verbatim, with every other argument identical: the walk is recomputed per call.

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. **Nothing sent is dropped in silence:** an entry that is not an object, a missing or repeated id, a reference to a row not in the block, or a value of the wrong type is an error at the path as sent (`items[0].id`), listed before the shape's own errors, at most 50 per block (then one entry whose `values.total` says how many). A closed-list value that has a default (`stance`, a guideline's `level`) is still read as it. 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. The response carries `dropped_mentions` (always present, `[]` when none): each `{{type:id}}` that names nothing in this project, as `{ block, type, id }`. It is never recorded, and its text stays in the block, where it reads as a missing entity.

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

GET /v1/tokens:export

Export the whole token system as DTCG documents

Scope read:library · MCP tooldescribeTokenExport

The design system as **DTCG** (Design Tokens Community Group format), ready to feed a build. Returns `{ files, resolver, report }`: `files` maps a file path to a DTCG document, `resolver` is the DTCG resolver document describing how the modes combine, and `report` says what the export could not carry. **Read `report.findings` before trusting the bundle.** Each entry has a `consequence`: `lost` (not in the export at all), `approximated` (present, but not exactly as Figma holds it) or `info`. **An empty list means nothing was left out** — that promise is why the report travels with the documents instead of being logged on the server, where a caller with no UI would never see it. **Never exported: anything deleted in Figma**, with no option to include it. **Excluded by default: variables hidden from publishing** — pass `include_hidden=true` for those. When they are excluded the omission is declared as a `lost` finding, so leaving the flag off never costs you silently; when you include them nothing is omitted and no such finding appears. The response carries an `ETag` over the documents. Send it back as `If-None-Match` to get a `304` instead of the body — and that `304` is also the reliable way to confirm this bundle is the one the MCP tool `describeTokenExport` described. ⚠️ **Do not compare the two stamps as strings.** A CDN fronts this API and rewrites the validator to its weak form, so this header reads `W/"abc"` where the tool returned `"abc"` — the same stamp, spelled differently. Drop an optional `W/` prefix before comparing, or use the `304`. **Some tokens carry no `$type`, by design.** DTCG has no type for a boolean, or for text that is not a font family, so a Figma BOOLEAN variable and a STRING variable that is not a font family arrive as a token with **no `$type`**, a string `$value` (`"true"` or `"false"`, or the text itself) and the real value in `$extensions["design.grails.value"]`, which is `{ "type": "boolean" | "string", "value": <the boolean, or the text> }`. A tool that does not know the extension reads the `$value` text and loses only the type; Grails reads the extension. `report.findings` has no entry for these tokens, 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"]`, which is `{ "color": …, "opacity": "{path}" }`. `color` is a DTCG colour or a `{path}`. The opacity is a reference to the opacity token **as it is, and for a Figma opacity variable that is 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 (a `WIDTH_HEIGHT` scope still makes it a `px` dimension). The extension is left out, and the applied colour stays, when a token it names is not in the export in that mode; `report.findings` says how many. Size, so you can plan: ~139 KB of JSON for the largest design system we host. Requires a plan with token export over the API; without it the answer is `GRAILS_AUTH_PLAN_REQUIRED` (403), which no retry and no scope change will fix.

Name In What it does
include_hidden query Set to `true` to include the variables a designer marked "hidden from publishing" in Figma. Default `false`: that flag marks variables internal and excluded from publishing. Also accepts `1`, `yes`, or the bare flag. When excluded, the omission is declared in `report.findings` as a `lost` entry; when included, nothing is omitted, so no such finding appears.
If-None-Match header The `ETag` of an export you already hold. If it still matches, the answer is `304` with no body. Weak tags (`W/"…"`), comma-separated lists and `*` are all accepted per RFC 9110.

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. **One row per token, not per Figma file:** `id` is the token's canonical id, and `representations` lists every row it stands for, primary first, each with its `collection_id` and `figma_file_key`; `representation_id` names the row shown when it is not the canon's own. `name` is the token's address and never changes; a name a person set in Grails comes as `display_name` / `display_path`. `lives_in` says where it lives: `design` (Figma file, variable id, collection) and `code` (source, collection, path); `[]` = not there, `null` = unknown. **Page order is by canonical 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.** `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`. **One row per style, not per Figma file:** `id` is the style's canonical id, and `representations` lists every row it stands for, primary first, each with its `figma_style_id` and `figma_file_key`; `representation_id` names the row shown when it is not the canon's own. **Page order is by canonical 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.
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

How many components drift, and against which minimum schema version. **Counts only — it does not return the components.** To enumerate them, `GET /v1/components?has_drift=true`, which paginates and takes `fields`. **If `truncated` is true, `drifting_count` is a floor, not a total.**

Code Sync

POST /v1/code/syncs

Open a Code Sync

Scope write:code · no MCP equivalent

Starts an upload of a codebase's components. **Grails Code Sync is a cycle of three calls — this one, then `POST /v1/code/syncs/{sync_id}/components` once per batch, then `POST /v1/code/syncs/{sync_id}/finalize` — and nothing is final until the last one.** It is called by the `grails-cli` CLI, not by an agent. **Returns `201` with `{ sync_id, source_id, known }`.** `known` maps each component key the source already holds to its stored spec hash (`sha256:…`). **Send a component's `spec` only when its `hash` differs from `known[key]`, or when the key is not in `known`**; for a component whose hash matches, send the item without a spec. A key is also left out of `known` when its stored spec was scored with another Component Health Index formula, which is how the server re-derives it when the spec comes again (`rederived` in the counts). **`declared_keys` is how the server proves a sync complete, and it is required.** List every component key the scan found, even those whose spec you will not send. At finalize the server compares it with what the batches delivered and with what the source already holds: a declared key never sent is `missing`, and a component the source holds that is not declared is what a complete sync marks `deleted_in_code`. Without it a scan run from the wrong directory could delete everything it did not look at. At most 5000 keys. **`source.repo_url` must carry no credentials, no query and no fragment**, because every member of the project, viewers included, can read it. A GitLab CI remote (`https://gitlab-ci-token:<token>@gitlab.com/…`) or a `?private_token=…` is refused with `GRAILS_VALIDATION_FAILED` naming `source.repo_url`; strip them before submitting. **Only one sync of a source can be open at a time**: opening it while another is open is `409 GRAILS_CODE_SYNC_IN_PROGRESS`. A sync that nobody closes expires two hours after its last batch (or after it was opened, if it never got one) and stops blocking the source. The source is found or created by `source.name`. **The project is the key's and the author is the key's owner**; neither travels in the body, and a body that names a `project_id` (or any field outside this contract) is refused. Checks run in this order: scope, plan, size, then the body (the cap is 2097152 bytes (2 MiB)).

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

POST /v1/code/syncs/{sync_id}/components

Send a batch of components to an open Code Sync

Scope write:code · no MCP equivalent

Stores up to 50 components, with a body of at most 2097152 bytes (2 MiB). Call it as many times as the scan needs; **retrying a batch is safe** — a component already stored by this sync is not counted twice. **Returns `{ added, changed, unchanged, rederived }`** for this batch: new components, components whose spec changed, components re-sent with the same hash and no `spec`, and components re-sent with the same hash AND a `spec`, whose Component Health Index was derived again in place (what the server asks for when its formula moved on). **Send the `spec` only when the `hash` differs from `known[key]`** (the map `POST /v1/code/syncs` returned), or when the key is not in `known`. Leaving it out when it was needed is `422 GRAILS_CODE_SPEC_REQUIRED`, with `details.key`; sending it needlessly costs nothing but bytes. **The hash is `sha256:` + hex(sha256(canonical JSON of the spec)), and the server recomputes it.** Canonical JSON is the spec serialized with the keys of every object sorted recursively and arrays in their own order. A hash that is not the spec's is `422 GRAILS_CODE_HASH_MISMATCH` with `details.key`; it is refused rather than stored because a wrong hash would make every later sync skip the component. The check runs only on items that carry a `spec`. **`chi` is NOT accepted, anywhere.** The server computes the Component Health Index from the spec's design props and the coverage; a body that carries a `chi` — or a `unresolved[].detail` (it quotes code), an `other_props[].tsType`, or any field this contract does not list — is refused with `GRAILS_VALIDATION_FAILED`. Every field is on an allow-list. **A key must be one of the sync's `declared_keys`**: another is `422 GRAILS_CODE_KEY_NOT_DECLARED` with `details.key`. A rejected item rejects the whole batch, and nothing in it is stored. A sync that expired or was already finalized answers `409 GRAILS_CODE_SYNC_NOT_OPEN`: open a new one. Every accepted batch pushes the expiry two hours ahead. More than 50 components is `400 GRAILS_BATCH_LIMIT_EXCEEDED`.

Name In What it does
sync_id required path

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

POST /v1/code/syncs/{sync_id}/finalize

Close a Code Sync and read its result

Scope write:code · no MCP equivalent

Closes the sync and returns what it did. **Idempotent: finalizing a sync that is already `completed` or `incomplete` answers the same body again** instead of failing, so a client whose response was lost can simply retry. (A sync that expired unfinished is `409 GRAILS_CODE_SYNC_NOT_OPEN`.) **Returns `{ sync_id, status, seen, missing, deleted, added, changed, unchanged, rederived, vault_url }`**, plus `suspicious_deletions` when the floor below tripped. `seen` is the components the batches delivered; `missing` the declared keys no batch delivered; `deleted` the components marked `deleted_in_code`; the four counters that follow are the totals of all the batches (absent when none was sent). `vault_url` is where the components appear. When `usage` was sent, `usage` answers `{ edges, unresolved_components, unresolved_tokens }`, or `{ recorded: false }` when it could not be recorded (reported on our side; the sync itself is closed either way). **`status` is `completed` only when all three hold: you sent `complete: true`, every declared key arrived in a batch (`missing` is 0), and the deletion floor did not trip.** Anything else is `incomplete`, which is not an error: the components that did arrive are stored, and nothing is deleted. The server proves completeness from `declared_keys`; your flag alone is not enough. **The deletion floor.** A complete sync marks `deleted_in_code` the components the source holds that are not in `declared_keys`. But when that would be at least 5 components AND more than 20% of the active ones, it is far more likely a scan of the wrong folder than a refactor, so **nothing is deleted**, the sync is `incomplete`, and `suspicious_deletions` says how many it refused to delete. A deleted component is not lost: a later sync that declares and sends it brings it back. Read this field before assuming a missing component was removed. Send `complete: false` whenever the scan did not reach every file it meant to; an incomplete sync never deletes. Body capped at 2097152 bytes (2 MiB) like the others.

Name In What it does
sync_id required path

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

POST /v1/code/token-syncs

Upload a code source's design tokens

Scope write:code · no MCP equivalent

Records a snapshot of a codebase's design tokens. **One request carries the whole token list**: there is no open or finalize. **The body carries exactly one of `tokens` and `dtcg`.** `tokens` is the Grails CLI's model; `dtcg` is a team's own DTCG 2025.10 files and resolver, from whatever tool produced them. Either way Grails stores its own re-serialization (format files, a Resolver, and one entry per token and mode), not the bytes sent. **A DTCG upload is read without fetching anything:** a `$ref` names one of the uploaded files, never a URL or a server path. A collection is a token path's first segment; its modes come from the resolver's modifiers (none: one mode, `default`; two or more: one mode per combination, named like `light·brand-a`). **A token's `design.grails.*` extensions are checked, not only kept.** A form Grails reads that is malformed answers `422` with a finding `extension_invalid` and the JSON pointer of the place (a `design.grails.value` of `boolean` whose value is `"yes"`, an `extraProps` `textCase` of `"shout"`); a `{path}` reference inside one that names no token is `alias_unknown`. What Grails does not know inside a form (a key, a value `type`) is kept as written and comes back in `findings` as an `info` entry. A token with a valid `design.grails.value` is stored with that `type` and `value`, and its `$value` text, which is only the fallback another tool reads, is never followed as a reference. **Returns `201` with `{ sync_id, snapshot_id, source_id, reused, token_count, mode_count, entries, unresolved, findings, diff, token_changes }`.** `reused: true` means the content was already stored for this source: the sync points at that snapshot and `entries` is 0. `findings` is the exporter's report (values it could not express, for instance). `diff` compares with the source's previous snapshot: `{ added, changed, removed, unchanged, truncated, paths: { added, changed, removed } }`, each path written `path` or `path@mode`. A source with code tokens and no snapshot yet counts each active token the upload lacks as `removed`, by `path`: the sync marks it deleted in code. **The counts are complete; each list stops at 100 paths and `truncated` says so.** To see it without storing, call `POST /v1/code/token-syncs:preview`. **`token_changes` says what the sync did with the changes people asked for in Grails** (`GET /v1/code/token-changes`), compared with what the repository now holds, on every sync (a `reused` one too): `{ checked, applied, target_removed, open, recorded: true }`. `applied` changes were marked applied (the repository holds the proposed value, an alias counted by what it resolves to, with the tolerance the match uses for colours and dimensions); `target_removed` were closed because their token is gone; `open` stay open, **including the ones whose token holds ANOTHER value: Grails keeps proposing its value and checks again on every sync**, and records what code holds. `truncated: true` appears when more changes were waiting than one sync compares (`checked` is that cap) and the rest waits for the next sync. **A comparison that failed never fails the sync**: it answers `{ recorded: false }` and the changes stay open until the next one. **Limits, checked before any work:** at most 500 collections, 50 modes per collection and 20000 tokens × modes (`413 GRAILS_PAYLOAD_TOO_LARGE`). An alias must name a token of the same upload, and an alias chain may not loop or run deeper than 64 links (`422 GRAILS_VALIDATION_FAILED`, `details.field: "tokens"`). The source is found or created by `source.name`, the same source component Code Sync uses. The project is the key's and the author is the key's owner. Checks run in this order: scope, plan, size (4194304 bytes), then the body.

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

POST /v1/code/token-syncs:preview

Preview a code token sync without storing it

Scope write:code · no MCP equivalent

The dry run of `POST /v1/code/token-syncs`: **the same body, the same checks, and nothing written** (no sync, no snapshot, no source, no activity). The server builds the same snapshot and compares it with the source's latest one. Returns `200` with `{ dry_run: true, source_exists, unchanged, token_count, mode_count, unresolved, findings, diff, token_changes }`. `source_exists: false` means the source has never synced tokens, so everything would be added. With no snapshot, a source's active code tokens that the upload lacks are `removed`, as the sync would mark them. `diff` has the shape the write returns: complete counts, lists of at most 100 paths, and `truncated`. **`token_changes` says what a real sync would do with the changes people asked for in Grails**, with the very verdict the real sync uses, against THIS upload: `{ checked, would_apply: { count, ids }, would_target_removed: { count, ids }, stay_open: { count, ids }, available: true }`. `would_apply` are the open changes whose value the upload holds (an alias counted by what it resolves to, with the match's tolerance); `would_target_removed`, those whose token the upload lacks; `stay_open`, the rest, including the ones whose token holds another value. The counts are exact; each list of ids is cut at 200 and `truncated: true` says so. **It writes nothing**: no resolve, no `current_value`, no lock. A comparison that failed never fails the dry run: `token_changes` is `{ available: false }`.

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

POST /v1/code/syncs:preview

Preview a component Code Sync without opening one

Scope write:code · no MCP equivalent

The dry run of a component sync: send the keys and spec hashes a sync would declare; **nothing is opened or written**. Returns `200` with `{ dry_run: true, source_exists, added, changed, unchanged, reactivated, would_delete, truncated, keys }`, and `suspicious_deletions` when the deletion floor would stop the real sync. **`would_delete` applies the same floor as finalize**: when a complete sync would delete at least 5 components AND more than 20% of the active ones, the real sync deletes nothing and finalizes `incomplete`, so the preview answers `would_delete: 0` and `suspicious_deletions` with the count. An incomplete sync (`complete: false`) never deletes. `reactivated` counts keys that were deleted in code and come back. Each key list in `keys` stops at 100 and `truncated` says so. At most 5000 items; the cap is 2097152 bytes (2 MiB).

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

GET /v1/code/sources

List the project's code sources and their last sync

Scope write:code · no MCP equivalent

The sources this project has synced, ordered by name, each with its most recent sync: `{ sources: [ { id, name, platform, package_name, repo_url, last_sync, last_token_sync, open_changes } ] }`. `last_sync` is `null` for a source that was never synced, and otherwise carries, among others, `id`, `status`, `git_commit`, `git_branch`, `trigger`, `started_at`, `finished_at` and `result` (the counts the finalize returned). It is what `grails-cli status` prints. **`last_token_sync`** is the source's latest code token sync, or `null`: `{ id, snapshot_id, git_commit, git_branch, trigger, created_at, result }`. **`open_changes`** is how many changes people asked for in Grails wait for the source (the open items of its open code request, what `GET /v1/code/token-changes` lists): `0` when none, `null` when the count could not be read (never a made-up zero). **`last_sync` is the latest sync of ANY status**, not only a finished one: `open` is an upload in flight (`finished_at` is null) or one that expired and no later open has closed yet, `abandoned` one that expired and was closed by the next open, `completed` one the server proved complete, and `incomplete` one that stored its components and deleted nothing. Requires `write:code` like the other three Code Sync operations, even though it only reads, and the same plan switch: a team without Code Sync gets 403 here too.

GET /v1/code/token-match

Match a code source's tokens against the Figma ones

Scope write:code · MCP toolgetCodeTokenMatch

Which design token each of the source's code tokens is, and whether their values agree: the source's latest code token snapshot against the latest Figma token snapshot of each file of the project. **Read-only and computed on request**: nothing is stored. `status` is `matched`, or says which side has no snapshot yet (`no-code-snapshot`, `no-figma-snapshot`) instead of a match in which everything is unpaired. A source this project does not hold is a 404. **Pairing**: the whole path as words, container words dropped (`color`, `base`, `raw`, `theme`, a framework name…); the category of a dimension (radius, spacing, size, type, stroke, weight) is part of the key, so two categories never pair; the same words first, then one name being the other's tail; a one-word tail only as the single candidate left. Each pair is `same`, `differs`, `unaligned` (no mode in common) or **`conflict`** (code tokens that claim one design token with different verdicts; never resolved). Values are compared per mode, aliases followed; an unnamed mode stands for the other side's `light`/`default` one. **`thresholds`** is always in the answer: colours are the same under `color_delta_e` (ΔE2000, **default 1.5**), dimensions within `dimension_px`, alphas within `alpha`. **The source's naming rules are applied first** (`GET /v1/code/token-rules`), and `rules_applied` says how many tokens each one renamed, an unused rule included. `thresholds.color_delta_e_from` says where the colour threshold came from: `request` (this call's `color_delta_e`), `project` (`PUT /v1/code/token-match/settings`) or `default`. What pairs by no name is in `value_only` (code tokens with a design token of the same value), `code_only` and `design_only`. **`suggestions`** are naming rules (`{ from, to, pairs, examples }`) read from value pairs whose names differ in one word. They are never applied. `totals` are complete; each list stops at 200 and `truncated` says so. Several Figma files are one design: a path two files share is taken from the file synced last, and `shadowed_paths` counts them. Requires `write:code` and the Code Sync plan, like the other Code Sync operations. Its MCP twin, `getCodeTokenMatch`, reads with `read:library`.

Name In What it does
source required query Required. The name of a code source of this project, as `GET /v1/code/sources` lists it.
color_delta_e query ΔE2000 under which two colours are the same. Greater than 0, at most 100. Default 1.5. Echoed in `thresholds.color_delta_e`.

GET /v1/code/token-changes

List the token changes people queued for a code source

Scope write:code · no MCP equivalent

The changes people queued in Grails for this source's tokens and not yet carried into the repository: `{ source: { id, name }, changes: [ { id, kind, target, label, path, canonical_id, collection, mode, type, before, after, author, updated_at, request_id } ], truncated, limit }`. **Grails never writes to the repository**: the CLI lists these as DTCG (before → after) and the team edits its own source. **The next code token sync compares each one with what the repository then holds and marks it applied** (`token_changes` in the answer of `POST /v1/code/token-syncs`); one the team will not carry is closed with `POST /v1/code/token-changes/{change_id}/discard` and a reason (or many at once with `POST /v1/code/token-changes:discard`, whose scopes the filters below share). `kind` is `value`, `create` or `delete`; `target` is `token`, `token-collection` or `mode`. `label` is what a person reads, frozen when the change was queued: the token's path, or the name a create gives. `path` is the DTCG path when the change names a code token, and `null` when it names an entity that the code does not have yet (then `canonical_id` names it). `collection` is the collection's name. `mode` is the mode's name for a value change (`default` for a token of a single unnamed mode) and a mode create's own name. `type` is the token's DTCG type (`color`, `dimension`, `typography`…): the live token's, or the one a create states; `null` for a collection or a mode, and for a token with none. `after` is the DTCG value asked for, as stored. **`before` is the value the repository holds now, resolved** (an alias in code is the colour it resolves to, not the text `{color.base}`); only when the repository has no value there (the token is gone, or the mode) does it fall back to what was queued, which is the text as written. A create has no `before`. `author` is `null` when nobody known queued it. `request_id` is the Grails change request the change belongs to (one open request per source); `null` only while a deploy is in flight and the database cannot name it yet, never an empty string. **Filters, applied by the database before the page:** `collection` (the name `collection` shows) and `request_id` (a request of this source and project; another's, or none, is the one `404 GRAILS_CODE_TOKEN_CHANGE_NOT_FOUND`). Only the source's open request, only changes with no outcome. Ordered by path, then mode. **`truncated: true` means there are more than `limit`**. Requires `write:code` and the Code Sync plan, like the other Code Sync operations.

Name In What it does
source required query Required. The name of a code source of this project, as `GET /v1/code/sources` lists it.
limit query 1..1000, default 500.
collection query Only the changes of the collection with this name, 1 to 1000 characters.
request_id query Only the changes of this change request. It must belong to the source and project of the key: any other id, or one that is not an id, is a 404.

POST /v1/code/token-changes:discard

Close many token changes from code, in one transaction, with a reason

Scope write:code · no MCP equivalent

Closes a SET of open changes as **discarded, from code**: a whole source (`scope: { all: true }`), one collection of it (`{ all: true, collection }`), one change request (`{ request_id }`) or a list of ids (`{ ids }`). **All of them or none**: one RPC, one transaction, under the same project lock the sync's resolve takes. Every change is KEPT with who (the API key's owner), how (`resolved_via: "code"`), when and the same **required** `reason`. A request whose last open change closes closes too (`applied` when a sync applied any of its changes, else `discarded`), by the resolve's own rule. **`dry_run: true` says which changes WOULD go and writes nothing**, takes no lock and needs no reason: `{ dry_run: true, source, count, ids, changes: [ ... ] }`, each change as `GET /v1/code/token-changes` shows it (id, target, label, type, before, after). There is no confirmation to give: a script runs the dry run, then the real call. **Returns `200` with `{ dry_run: false, source, count, ids, requests_closed, resolved_via: "code", resolved_by, reason }`.** `ids` are the changes that went, the ones that go with the selected ones included (a collection create takes its modes and tokens, a token create its values); `resolved_by` is the key's owner or `null`. Nothing to discard is not an error: `count: 0`. **The cap is 1000** changes, counting what goes with the selected ones: more is `422 GRAILS_CODE_TOKEN_CHANGE_TOO_MANY` (narrow with a collection or a request) and nothing is discarded. **Tenancy:** the project is the key's. A change or a request that is not an open code change of THIS project and `source` (it does not exist, is another project's or source's, is on the Figma side, or is not an id) refuses the whole call with the one `404 GRAILS_CODE_TOKEN_CHANGE_NOT_FOUND`; one already applied or discarded, `409 GRAILS_CODE_TOKEN_CHANGE_CLOSED`; one value of a token being created selected without its token, `409 GRAILS_CODE_TOKEN_CHANGE_PART_OF_CREATE`. Checks run in this order: scope, plan, size (131072 bytes), then the body.

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

POST /v1/code/token-changes/{change_id}/discard

Close a token change from code, with a reason

Scope write:code · no MCP equivalent

Closes one open change as **discarded, from code**: the team will not carry it into the repository. **The change is KEPT** (the vault's own discard deletes it): it records who (the API key's owner, the only identity the API can verify), how (`resolved_via: "code"`), when, and the reason, which is **required** and shown to the people who asked for the change. **Returns `200` with `{ id, outcome: "discarded", resolved_via: "code", resolved_by, reason, discarded_with, request_closed }`.** `resolved_by` is the key's owner, or `null` for a key that has none (nobody is invented). `discarded_with` lists the other changes that went with it: discarding a collection create takes its mode and token creates, a mode create the values written in that mode, and a token create its values. **One value of a token being created cannot be discarded alone** (`409 GRAILS_CODE_TOKEN_CHANGE_PART_OF_CREATE`): discard the token. `request_closed` is true when nothing was left open in the source's request, so it closed (`applied` when a sync applied any of its changes, else `discarded`). **The id comes from `GET /v1/code/token-changes`.** A change that is not an open code change of the key's project (it does not exist, it was removed, it is another project's or another source's, it is on the Figma side, or the id is not a UUID) is **one** `404 GRAILS_CODE_TOKEN_CHANGE_NOT_FOUND`, never a 403: nothing says which ids exist elsewhere. A change already applied or discarded is `409 GRAILS_CODE_TOKEN_CHANGE_CLOSED`, and keeps its first outcome and reason. Checks run in this order: scope, plan, size (8192 bytes), the id, then the body. The project and the author are the key's; nothing in the body names either.

Name In What it does
change_id required path

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

GET /v1/code/token-rules

List a code source's token naming rules and the project's match threshold

Scope write:code · MCP toolgetCodeTokenMatch

The naming rules the code ↔ Figma match applies to this source before pairing, by word: `{ source, rules: [ { from, to, updated_at } ], max_rules, threshold: { color_delta_e, is_default } }`. A rule renames a CODE word to the word Figma uses (`gray` → `neutral`), for this source only. Rules live on the server only: they are never read from the repository. Requires `write:code`, the CLI's key, like the match; editing needs `write:tokens`.

Name In What it does
source required query Required. The name of a code source of this project.

PUT /v1/code/token-rules

Set a token naming rule of a code source

Scope write:tokens · MCP toolwriteTokenMatchRules

Creates the rule, or moves its target. **Idempotent**: the same rule twice is one rule (`changed: false`); accepting a suggestion of `GET /v1/code/token-match` is exactly this call. → `{ source, rule: { from, to }, created, changed, previous_to? }`. Both words are one word of letters and digits, stored lowercase, and different. A new word is refused once the source holds 500 rules (a size guard). The author is the key's owner. Requires **`write:tokens`** and the Code Sync plan.

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

DELETE /v1/code/token-rules

Delete a token naming rule of a code source

Scope write:tokens · MCP toolwriteTokenMatchRules

**Deletes** the source's rule for that code word. **Idempotent**: deleting one that is not there answers `deleted: 0`, never an error. → `{ source, from, deleted }`. Requires **`write:tokens`** and the Code Sync plan.

Name In What it does
source required query Required. The name of a code source of this project.
from required query Required. The code word whose rule is deleted.

PUT /v1/code/token-match/settings

Set the project's colour threshold for the token match

Scope write:tokens · MCP toolwriteTokenMatchRules

The ΔE2000 under which `GET /v1/code/token-match` calls two colours the same, for this project. → `{ threshold: { color_delta_e, is_default: false } }`. A project with none uses 1.5. A request's own `color_delta_e` still wins for that request, and the answer says where its threshold came from. Requires **`write:tokens`** and the Code Sync plan.

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

patterns

GET /v1/patterns

The patterns of this project

Scope read:patterns · MCP toolfindPatterns

A pattern is a reusable composition of components (an empty state, a PIN step, an account home) with screens of typed sections and rules of its own. The most recently changed come first (`order` changes it), and **with no filter every pattern is listed**. → `{ data: [{ id, canonical_id, name, slug, status, kind, steps, shape, updated_at, updated_by, updated_via }], pagination }`, without the spec: read one whole with `GET /v1/patterns/{pattern}`. `kind` is `section` (one section), `screen` (several sections) or `flow` (several screens); `steps` counts the screens and `shape` the sections of each; `updated_by` is `{ id, name }` (never an email; `name` is null when the profile is gone); `updated_via` is `app` or `api`. `include` adds, each at its own cost: `intent` `{ problem, domain, platforms }`, `created` `{ by, at }`, `review` `{ state, issues }` (computed against the design system now), `uses` `{ components, patterns }` (`[{ canon_id, name }]`, the heaviest). Every filter is applied before the page is cut. Requires **`read:patterns`**. A key minted before patterns existed does not have it.

Name In What it does
q query A piece of the name, case-insensitive.
status query `draft` or `published`. Anything else is a 422.
kind query `section`, `screen` or `flow`. Anything else is a 422.
component query A component (row id, Figma node id or key), or up to 20 comma-separated: the patterns that use them. An id that matches several components (one per Figma file) answers `404 GRAILS_COMPONENT_NOT_FOUND` with `details.matched`; one the project does not have is a 404.
uses_mode query With several components: `any` (default, the patterns that use one of them) or `all` (the patterns that use every one). Spelled `usesMode` on the MCP tool.
domain query Keep the patterns with this domain: an exact, case-sensitive match.
platform query `web`, `ios` or `android`. Anything else is a 422.
created_by query Up to 20 comma-separated people's ids (uuids, as `updated_by.id` answers them): the patterns any of them created. Spelled `createdBy` on the MCP tool.
updated_by query Up to 20 comma-separated people's ids: the patterns any of them changed last. Spelled `updatedBy` on the MCP tool.
order query `updated_desc` (default), `name_asc` or `created_desc`; `name_asc` ties by slug, the others by id. A cursor belongs to its order: sent with another, it is a 400.
include query Comma-separated: `intent`, `created`, `review`, `uses`.
limit query 1..100, default 20. Above 100 truncates; below 1 is a 400.
cursor query The `pagination.next_cursor` of a previous response, passed back verbatim, with the same `order`; one that does not decode, or one of another order, is a 400.

POST /v1/patterns

Create a pattern

Scope write:patterns · MCP toolwritePattern

Creates a pattern, a `draft` unless `status` says `published`. → **201** `{ id, canonical_id, slug, created: true, previous_status: null, status, edges, updated_at }`; `edges` counts the graph edges derived from the spec (pattern → component, pattern → pattern). The project is the key's, the author the key's owner, and the key is recorded on the row; none of them come from the body. The spec's shape is the `writePattern` MCP tool's description. Every problem in it comes back at once as `422 GRAILS_VALIDATION_FAILED` with `details.errors[{ path, message }]`; a `specVersion: 1` spec is a 422 with `details: { field: "spec.specVersion", received: 1, expected: 2, errors }`, `errors` listing every other problem the spec has; a `canonId` that is not this project's, or of the wrong kind, is a 422 with `details.reason`, `details.canon_id` and `details.paths`. An explicit `slug` in use is `409 GRAILS_SLUG_TAKEN`; one derived from the name is retried. Requires **`write:patterns`**, which does not grant reading.

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

GET /v1/patterns/{pattern}

One pattern, whole: its spec and whether it still fits

Scope read:patterns · MCP toolgetPattern

By id or slug. → `{ pattern: { id, canonical_id, name, slug, status, spec, created_by, updated_by, updated_via_api_key, created_at, updated_at }, review: { state, issues } }`. `review` is computed on read against the design system as it is now: `ok`, or `needs-review` when a component the spec names was deleted, or lost a prop or value a constraint asks for, each issue with its path into the spec. The spec is always `specVersion: 2`. A pattern of another project answers 404, like one that does not exist. Requires **`read:patterns`**.

Name In What it does
pattern required path

PUT /v1/patterns/{pattern_id}

Replace a pattern: its name, status and whole spec

Scope write:patterns · MCP toolwritePattern

Takes the uuid, never the slug: a replace aimed at the wrong pattern would look exactly like success. ⚠️ The spec is replaced WHOLE: what is left out is removed. A `slug` or `status` left out keeps the one the pattern has. → `{ id, canonical_id, slug, created: false, previous_status, status, edges, updated_at }`; a pattern of another project is a 404. Same validation answers as `POST /v1/patterns`. With `expected_updated_at` (the `updated_at` you read, from `GET` or a previous write), a pattern saved since by someone else is `409 GRAILS_PATTERN_CHANGED` and nothing is written; `details.current_updated_at` says when it was saved, in ISO 8601 to the millisecond: sent back as `expected_updated_at`, it guards the next replace. In `expected_updated_at`, microseconds as the API answers them and milliseconds as a JavaScript `Date` gives them both match. Without it, the replace overwrites whatever is there. Requires **`write:patterns`**.

Name In What it does
pattern_id required path

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