Every operation the API exposes, with the scope it needs and the MCP tool that does the same job.
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.
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. |
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.