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 derived signals. Walk the full set by passing `pagination.next_cursor` back as `cursor`. Scores are only comparable within the same `search_mode_used`. **There is no `total`.** The filters are applied inside the database function, including the derived `has_docs`, `has_spec` and `has_drift`, so a count computed outside it would answer a different question than the page it accompanies. To count: if `pagination.has_more` is `false` on the first page, `data.length` is the exact total for those filters at no extra cost; otherwise walk with `fields=id&limit=100`, which is one call per 100 components and a few bytes each; and for the drift subset, `GET /v1/components:driftSummary` returns `drifting_count` in one call (read its `truncated` flag — it says when that number is a floor).
| Name | In | What it does |
q | query | Search term matched against component names. An empty `q=` is treated as absent, not as an empty filter. |
search_mode | query | `exact` | `semantic` | `hybrid`. Default `exact`. **`semantic` and `hybrid` are accepted and do not fail today** — embeddings are not installed, so they are served lexically and the response reports what was actually used in `search_mode_used`. Read that field rather than assuming. |
limit | query | 1..100, default 20. Above 100 truncates silently; below 1 is a 400. |
cursor | query | Opaque cursor from a previous page — pass `pagination.next_cursor` back verbatim. Do not construct one. |
fields | query | Restrict returned columns. Repeatable or comma-separated. `id` is always present. `score` and `match_reason` only exist on a search. |
type | query | `COMPONENT` | `COMPONENT_SET`. A set is the variant container, and its variants are not rows of their own — they live in the set's spec. See `parent_id`. |
parent_id | query | The id of a COMPONENT_SET, to return its variants. Returns an empty page for every set: a variant is not a component here — it has no row and no id of its own. An empty result says the set has no variants *as separate entities*, not that it has no variants. Read them from `GET /v1/components/{id}/spec`, under `spec.variants[]`. |
sync_status | query | `synced` | `outdated` | `sync_pending` | `syncing` | `deleted_in_figma` | `schema_upgrade_required`. Repeatable or comma-separated. **`deleted_in_figma` is excluded unless you ask for it** — the default page hides it. |
publish_status | query | `CURRENT` | `CHANGED` | `UNPUBLISHED`. Repeatable or comma-separated. Whether the Figma component has unpublished changes. |
has_docs | query | `true` | `false`. Only components that already have documentation, or only those without. Anything else is a 400 — never a silent `false`. |
has_spec | query | `true` | `false`. Whether a spec has been extracted. A component with no spec cannot answer `/spec`. |
has_drift | query | `true` | `false`. Whether the stored spec predates the minimum supported schema version. |
component_depth | query | Exact depth(s) in the composition tree. Repeatable or comma-separated. `0` is an atom — nothing else in the system composes into it. |
component_depth_gte | query | Minimum depth, inclusive. Combines with `component_depth_lte` to make a range. |
component_depth_lte | query | Maximum depth, inclusive. |
sort_by | query | `name` | `created_at` | `component_depth` | `variant_count` | `chi_score`. Default `name`. **`last_synced_at` is filterable but deliberately not sortable**: every sync rewrites it on every row, so a cursor minted before one points into a reordered set. |
sort_dir | query | `asc` | `desc`. Default `asc`. |
GET /v1/components/{id}
Get one component
Scope read:library · MCP toolgetComponent
One component with `chi_status`, `has_docs`, `has_spec` and `has_drift`. A component deleted in Figma is still readable by id — check `sync_status`.
| Name | In | What it does |
id required | path | |
GET /v1/components/{id}/spec
Get a component spec — the abstract by default, layers on request
Scope read:library · MCP toolgetComponentSpec
**Returns the ABSTRACT, not the whole spec**: `title`, `props`, `anatomy`, `metadata`, `_grails` and — for a variant set — `variantConfigurations`, every combination that exists WITHOUT its elements. **This is a declared break.** It used to return the complete document, always; `?include=variants` returns exactly that again. No data was removed — the element trees are now opt-in, because a single call to this endpoint was measured at 80–90% of everything an agent read. **Read `more`:** it prices every layer this response left out, in model tokens, with the exact call that fetches it. ⚠️ **Do not count the `$token` names visible in the abstract and assume that is all of them.** Bindings live in `anatomy` AND inside the element trees, so the abstract shows a plausible subset — commonly under half. `more.token_values.count` counts the WHOLE document, and `?include=tokenValues` resolves every one. ⚠️ **`spec.invalidVariantCombinations` includes the set's DEFAULT configuration, which does exist.** It is derived as `cartesian − variants[]`, and the default configuration is stored outside `variants[]`, so it lands in the subtraction on every variant set measured (302 of 302 in production). Read it as "combinations with no element tree of their own", never as designer intent — Figma offers no way to mark a combination invalid. **`spec._grails.chi` is the Component Health Index:** `score` is 0–100 (higher is healthier) = `100 − (po + dc + as + ta_penalty)`, and `status` buckets it (`optimal` ≥ 95, `good` ≥ 60, `monitor` ≥ 30, else `poor`). `po` (Property Overload, weight 20), `dc` (Design Coverage, weight 15) and `as` (API Surface, weight 25) are PENALTIES — higher is worse, 0 is perfect. `ta` (Token Adherence, weight 40) is an object whose `ta.score` is the percentage of style values bound to a token or Figma style, where higher IS better. `hasProps: false` means the component exposes no Figma properties, so `po`/`dc`/`as` are 0 by construction. **Read `spec_schema_version` before interpreting `spec`:** more than one schema version is normally live and they share top-level keys, so the version is the only way to know which contract the document follows. `null` when it declares none.
| Name | In | What it does |
id required | path | |
include | query | `default` | `variants` | `tokenValues` — the complete set, there are no others. Repeatable or comma-separated. Omit it for the abstract, which is usually right. `default` adds the default variant's element tree. `variants` adds EVERY element tree, the N variants plus the default one — this is the pre-layering response, and the most expensive thing this API returns for one component; check `more.variants.est_tokens` first. `tokenValues` adds the resolved value of every token the component binds. Do NOT use `variants` to reach a single variant — that is `variant`, and it costs a fraction. |
variant | query | One variant, named by its configuration, as a url-encoded JSON object: `variant={"Size":"Medium","State":"Hover"}`. Copy one out of `variantConfigurations` in the abstract; property names and values match case-insensitively. **A configuration and not an index on purpose** — an index is a position in `spec.variants[]` and nothing keeps that array ordered between syncs, so a remembered index silently moves. A partial configuration is allowed when it selects exactly one variant; when it matches several the response lists them instead of choosing. Not valid on a component without variants, and not valid together with `include=variants`. |
GET /v1/components/{id}/build-order
Get a component build order
Scope read:library · MCP toolgetComponentBuildOrder
Every component it depends on, transitively, ordered so that everything an entry needs appears before it — and the component you asked for LAST. Computed per call, so ask for one component at a time.
**`status` is one of two:** `computed` (it has dependencies, ordered below) or `trivial` (it depends on nothing, so the order is the component alone). `ordered_components` is never `null` and never `[]` — a component that exists always has an order, at minimum itself; one that does not is a 404.
A third status, `not_computed`, was removed on 2026-09-05. It meant "the sync has not ordered this yet" and the sync no longer orders anything, so nothing can be behind. A client branching on it can drop that branch.
Each entry is `{ position, component_id, name, component_depth, is_leaf, grails_sync_status }`. Dependencies Figma has but Grails does not are excluded on purpose: an unsynced library component cannot be built from what this project knows, and listing it would put an unbuildable step in a build order.
**A component deleted in Figma IS in the list**, in place, with `grails_sync_status: "deleted_in_figma"` — read that field before building. It used to be filtered out, and the filter did two things nobody wanted: a deleted component vanished from its OWN order, and a deleted component in the middle of a chain was skipped while its dependencies were promoted into its parent's list, asserting a dependency the parent never had.
`component_depth` may be `null`, meaning the sync has not ranked that component. It is not `0` — production leaves are depth 1, so 0 would read as "an atom". The ORDER does not depend on this field: it is computed from the dependency edges.
**A very large dependency graph can time out with `GRAILS_TIMEOUT` (504).** The order is computed per request and its cost grows with the square of the closure size — measured, 1001 components take 3.2 s against a ceiling of 8 s. Retrying the same id will not help: it is a limit, not a transient failure. No real component comes close today; the largest closure in production is 26.
| Name | In | What it does |
id required | path | |
POST /v1/components/specs:batchGet
Get several component specs
Scope read:library · no MCP equivalent
Up to 50 specs in one call. **Check `missing`** — the requested ids that returned nothing — and never match `data` against your request by position. Each entry is `{ id, reason }`, and the two reasons need opposite fixes: `no_spec` means the component is in this project and only its spec is absent (re-sync from the Figma plugin, or filter with `has_spec=true`), while `not_found` means no component with that id is in this project, so retrying will not help. Another project's id reads as `not_found`, the same as one that never existed.
Takes a JSON body — see openapi.json for its schema.
documentation
GET /v1/documentation/guide
How documentation works here — start here
Scope read:docs · MCP tooldescribeDocumentationBlockTypes
The order of steps for writing documentation, and which skill covers each part. An index, not a manual: it points at the skills rather than restating them. Exists as a callable surface and not only as documentation because an MCP client always enumerates tools but may never enumerate skills.
GET /v1/documentation/block-types
What documentation shapes exist, and where each can live
Scope read:docs · MCP tooldescribeDocumentationBlockTypes
The block types that can be written, each with the entity kinds it accepts. **Read `allowedOwners` before drafting**: without it you can propose a block on an entity that rejects it, and the rejection is not anticipable from the schema. `allowedOwners: null` means no restriction — it is NOT an empty list, which would mean the opposite. Types absent from this list are not writable. Every `example` is a complete block that can be posted to `/v1/documentation:validate` unchanged, and any id inside one is a real entity of THIS project: the `use-cases` example points its `alternatives` at an actual component, so the shape of a reference (`{ entity_type, entity_id }`, with a uuid resolved through `/v1/entities:match`) is visible instead of having to be guessed from an empty array.
| Name | In | What it does |
section | query | Omit for the vocabulary. Name a section to get the authoring guide instead: `criteria` — which shape a piece of content belongs in, and why; `shapes` — the exact JSON of each shape, field by field. The sections are announced in `more.authoring_guide` with what each costs, and are not attached by default: together they are around 4,850 model tokens, and a caller that already knows which shape it wants should not pay for them. |
GET /v1/documentation/pages
The pages of this project, and the folders they reveal
Scope read:docs · MCP toollistDocumentationPages
Documentation **pages** are standalone documents the team writes and organises — title, URL and folder. Different from `GET /v1/entities/{entity_type}/{entity_id}/documentation`, which returns what was written ABOUT a component or a token.
⚠️ **The only surface that AGGREGATES the folders.** `listEntities` with `type=doc-page` and the match endpoint carry `path` on every row too, but neither collects the distinct set, and both take `read:library`. For a key holding only `read:docs`, this is the only one at all.
A folder is not an entity: it IS the value of a page `path`, and it lives exactly as long as some page carries it.
`paths` is derived from the rows RETURNED, so when `has_more` is true it is partial — moving a page into a folder you could not see would create a second one carrying the same name.
| Name | In | What it does |
path | query | Only pages in this exact folder. Not a prefix: a folder is a whole string and there is no nesting for a prefix to mean anything against. |
limit | query | 1..100, default 20. Above 100 truncates rather than failing; below 1 is a 400. |
cursor | query | The `pagination.next_cursor` of a previous response, passed back verbatim. Do not construct one. |
POST /v1/documentation/pages
Create a documentation page
Scope write:docs · MCP toolcreateDocumentationPage
The first write this API makes to `doc_pages`. Until now only BLOCKS could be written, into pages somebody had created by hand in the web app — so an agent with something new to document had nowhere to put it.
⚠️ **The `slug` in the response may not be the one you asked for.** It is unique per project: if the one derived from `title` is taken, the server tries another and returns what it settled on. **Read it, do not assume it** — a caller that builds a URL from the title will build one that does not exist. If you pass an explicit `slug` and it collides, this answers `409 GRAILS_SLUG_TAKEN` and changes nothing: handing back a different URL than the one requested is worse than failing.
⚠️ **Once created, the slug cannot be changed.** No `PATCH` accepts it, and that is not an oversight: it is a separate, deliberate operation that does not exist yet.
`path` is the FOLDER, and a folder is not an entity — it **is** this string. Passing one that does not exist creates it; leaving it out leaves the page loose. There is no folder catalogue to query: the folders of a project are derived from the `path` of its pages, which is what `listEntities({ type: "doc-page" })` is for.
Takes a JSON body — see openapi.json for its schema.
GET /v1/documentation/pages/{page}
One page, whole: its identity and its blocks
Scope read:docs · MCP toolgetDocumentationPage
Returns the page — title, slug, folder, position — **with** every documentation block on it. That is what separates it from the entity documentation endpoint, which returns blocks alone: a page has an identity of its own, and an entity being documented does not.
Takes the page uuid **or its slug**. ⚠️ A slug is readable and unique within the project but **not permanent**: deleting a page frees its slug, so one stored earlier can later resolve to a different page with no error at all. The uuid is the stable reference.
| Name | In | What it does |
page required | path | |
PATCH /v1/documentation/pages/{page}
Rename a page, move it, or edit its abstract or icon
Scope write:docs · MCP toolupdateDocumentationPage
**Renaming and moving are the same request**: moving a page *is* writing its `path`. A separate move endpoint would be a second door to one column.
⚠️ **An absent field and a `null` one are different instructions**, and JSON expresses both. A field you leave out is left alone; `"path": null` takes the page OUT of its folder; `"path": "Foundations"` moves it there, creating that folder if no page carried it yet. There is no folder entity — a folder IS this string, and it exists exactly as long as some page carries it.
An empty body is refused rather than executed: rewriting the row with its own values would move `updated_at` and record an edit nobody made.
⚠️ **`slug` is not accepted.** Changing a page URL breaks every link already shared, so it is a separate, deliberate operation — one that does not exist yet.
Renaming a page or editing its abstract re-indexes it for semantic search; moving it or changing its icon does not, because neither reaches the indexed text.
| Name | In | What it does |
page required | path | |
DELETE /v1/documentation/pages/{page}
Delete a page, its blocks and its files
Scope write:docs · MCP tooldeleteDocumentationPage
⚠️ **Hard delete. There is no trash and no undo.** It removes three things: the page row, every documentation block on it, and the stored objects those blocks referenced.
⚠️ **It requires the page uuid and REFUSES a slug**, unlike every read on this surface. Deleting a page frees its slug — nothing reserves it — so a slug stored earlier may already point at a different page. On a read that is a wrong answer you can notice; here it would destroy somebody else's page irreversibly, and the response would look exactly like the intended one.
The page also leaves the semantic index, so it stops answering searches for text that no longer exists anywhere.
| Name | In | What it does |
page required | path | |
POST /v1/documentation/images
Upload an image in one call
Scope write:docs · MCP tooluploadDocumentationImage
Stores an image and returns the `r2_key` a documentation block points at. **There is no confirm step**, and that makes this path safer than the presigned one rather than less: the bytes arrive here, so they are measured here, and there is no window between signing and measuring.
Give exactly one of `data` (the image as base64) or `sourceUrl` (an https URL to fetch it from). Sending both, or neither, is refused rather than resolved by precedence — picking one silently would make the answer depend on an order the caller cannot see.
⚠️ **`sourceUrl` accepts a short allow-list of hosts and nothing else.** Redirects are not followed, a host that resolves into a private network is refused, and the download stops at the size ceiling while reading rather than trusting `Content-Length`. The refusal names the file where the list lives, because the expected cause is that a host changed and not that you got it wrong.
⚠️ **The bytes are verified against the `contentType` you declare.** For the URL path that means the response's own `Content-Type` decides nothing. For base64 it means one wrong character is refused as corrupt instead of stored — which is the expected failure when a model re-emits tens of thousands of characters by hand.
The image is not visible to anyone until a block references it: follow with a `PUT`/`POST` of an `image` block whose `content.r2_key` is what this returned.
Takes a JSON body — see openapi.json for its schema.
POST /v1/documentation/uploads:presign
Reserve storage and get an upload URL
Scope write:docs · MCP toolprepareDocumentationUpload
Step one of the upload cycle, for files and for images too large to send inline. Returns `upload_url` and `r2_key`; the client then `PUT`s the bytes straight to the URL with the same `Content-Type`, and finishes with `POST /v1/documentation/uploads:confirm`.
⚠️ **Without that third step the object is collected and the reservation released** — the upload simply never happened. The URL is valid for 15 minutes; the reservation outlives it, so an abandoned upload still has something to be charged against until the nightly collector clears it.
⚠️ **The quota is reserved BEFORE the URL is signed**, because after the signature there is no further point of control: the client uploads directly to storage and the bytes never pass through this API. A refusal is only possible here.
`sizeBytes` is what you expect to send. It is checked against the plan now and against the real object at confirm, because **a presigned URL signs the key and the content type but NOT the size** — so what you declare is a promise. For files the cap is a plan property; for images it is a limit of format and does not change with the plan.
Takes a JSON body — see openapi.json for its schema.
POST /v1/documentation/uploads:confirm
Measure the upload and settle the reservation
Scope write:docs · MCP toolcompleteDocumentationUpload
Closes the cycle: measures the stored object and settles the reservation against what actually landed. Returns the MEASURED `size_bytes`, which may differ from what was declared at presign.
⚠️ **For a file, the text inside it is extracted afterwards — and that is ours, not yours.** It does not qualify this answer: the upload is complete either way. An extraction can end as `no-text` (a scanned document: it FINISHED and there was no text), `unsupported` or `failed`, and none of those means the file is missing.
To see how it went, read the block: a `file` block carries `extraction.status`, how many characters came out and whether it was truncated. The extracted text itself is never served — it has one consumer, the search index, and it would add up to 30,000 characters per block to every read.
"Does not exist" and "is not yours" answer identically, which is deliberate: telling them apart would leak which keys exist in another tenant.
Takes a JSON body — see openapi.json for its schema.
POST /v1/documentation/paths:rename
Rename a folder across every page that carries it
Scope write:docs · MCP toolrenameDocumentationPath
A folder is not an entity — it **is** the literal value of a page's `path`, and it exists exactly as long as some page carries it. Renaming one therefore rewrites that value on every page at once.
⚠️ **This writes rows you did not name**, which is why the response says how many it touched: a silent success on a rename that matched nothing looks identical to one that moved twelve pages, and you cannot see the rows.
**If `to` already exists, the two folders merge.** `updated: 0` is a valid answer, not an error — a folder that "does not exist" is a string no page carries.
Takes a JSON body — see openapi.json for its schema.
POST /v1/documentation/paths:clear
Unfile every page in a folder
Scope write:docs · MCP toolclearDocumentationPath
**Nothing is deleted.** The pages survive and become loose; only their `path` goes to `null`. The verb is `clear` for that reason — calling it delete would make you hesitate over the safe operation, and stop you hesitating over `DELETE /v1/documentation/pages/{page}`, which really does destroy.
Reports how many pages it unfiled, for the same reason the rename does.
Takes a JSON body — see openapi.json for its schema.
Entity search
GET /v1/entities/{entity_type}/{entity_id}/documentation
Read the documentation blocks on an entity, and what they reference
Scope read:docs · MCP toolgetEntityDocumentation
Returns the blocks in the SAME shape used to write them, which is what makes it possible to compare instead of replacing blindly. Call this before writing: the write surface replaces rather than merges. **Alongside `blocks` comes a `mentions` branch** — the entities this documentation references, resolved to `{ entity_type, entity_id, count, name, exists }`, so a reference does not cost a lookup each. It is a separate branch rather than a field inside each block so that reading the text alone costs nothing extra; the raw per-block `mentions` stays, and is the only thing that says which block references what. **`exists` is the field to read first.** `false` means the reference is dangling — deleted, never there, or belonging to another design system — and `name` is `null`. Without the flag a null name reads as a failed lookup rather than as a broken reference, and the two need opposite responses. The branch is always present and is `[]` when there is nothing to resolve: a branch that appears only when it has data is indistinguishable from one that does not exist.
**`entity_id` accepts a doc page slug.** With `entity_type=doc-page` the readable slug works wherever the uuid does. Renaming the page title does NOT touch it — the slug is not a field of the edit action. **Changing it is a separate, deliberate operation that does not exist yet**; the day it does, a stored slug can stop resolving and you get a 404 naming it, never a different page. The uuid stays canonical, and the slug is unique **per project**, not globally.
| Name | In | What it does |
entity_type required | path | |
entity_id required | path | |
PUT /v1/entities/{entity_type}/{entity_id}/documentation
⚠️ Replace ALL documentation on an entity
Scope write:docs · MCP toolwriteDocumentationBlocks
DESTRUCTIVE. Deletes every existing block on the entity and writes the ones in the body. It does not merge and it does not ask. **`PUT` because that is exactly what it does** — replace the full contents of a resource. Returns `deleted` so the caller can report afterwards what it replaced. `updatedBy` is null for anything written through the API, because an API key carries no user identity today. Read the same path with `GET` first unless you already know the entity is empty.
**`entity_id` accepts a doc page slug.** With `entity_type=doc-page` the readable slug works wherever the uuid does. Renaming the page title does NOT touch it — the slug is not a field of the edit action. **Changing it is a separate, deliberate operation that does not exist yet**; the day it does, a stored slug can stop resolving and you get a 404 naming it, never a different page. The uuid stays canonical, and the slug is unique **per project**, not globally.
| Name | In | What it does |
entity_type required | path | |
entity_id required | path | |
POST /v1/entities/{entity_type}/{entity_id}/documentation
Add blocks without destroying the existing ones
Scope write:docs · MCP toolwriteDocumentationBlocks
Appends after what is already there. Nothing is deleted. **Retrying duplicates** — there is no matching, so two identical calls leave two copies. `POST` is not idempotent and neither is this; on a timeout, check with `GET` on the same path before retrying.
**`entity_id` accepts a doc page slug.** With `entity_type=doc-page` the readable slug works wherever the uuid does. Renaming the page title does NOT touch it — the slug is not a field of the edit action. **Changing it is a separate, deliberate operation that does not exist yet**; the day it does, a stored slug can stop resolving and you get a 404 naming it, never a different page. The uuid stays canonical, and the slug is unique **per project**, not globally.
| Name | In | What it does |
entity_type required | path | |
entity_id required | path | |
Takes a JSON body — see openapi.json for its schema.
DELETE /v1/entities/{entity_type}/{entity_id}/documentation/{block_id}
Hard-delete one block
Scope write:docs · MCP toolwriteDocumentationBlocks
No trash and no undo — unlike the vault UI, nothing is recoverable. **`DELETE`, and under the entity, and both halves are safety rather than style.** A `POST` that destroys tells no HTTP tooling — proxy, log, retry policy — that it is destructive. And hanging it off the entity turns "delete this id" into "delete this id, and I assert it belongs to this entity", which the server can reject: a block belonging to another entity is not deleted.
**`entity_id` accepts a doc page slug.** With `entity_type=doc-page` the readable slug works wherever the uuid does. Renaming the page title does NOT touch it — the slug is not a field of the edit action. **Changing it is a separate, deliberate operation that does not exist yet**; the day it does, a stored slug can stop resolving and you get a 404 naming it, never a different page. The uuid stays canonical, and the slug is unique **per project**, not globally.
| Name | In | What it does |
entity_type required | path | |
entity_id required | path | |
block_id required | path | |
POST /v1/entities/{entity_type}/{entity_id}/documentation:batchDelete
Hard-delete several blocks atomically
Scope write:docs · MCP toolwriteDocumentationBlocks
Exists alongside the singular `DELETE` because **this surface is all-or-nothing per request**: deleting N blocks with N calls loses that guarantee, and a failure halfway leaves the entity in a state nobody asked for. Returns how many rows actually went, which is NOT always how many ids were passed: an id already gone, or belonging to another entity, deletes nothing.
**`entity_id` accepts a doc page slug.** With `entity_type=doc-page` the readable slug works wherever the uuid does. Renaming the page title does NOT touch it — the slug is not a field of the edit action. **Changing it is a separate, deliberate operation that does not exist yet**; the day it does, a stored slug can stop resolving and you get a 404 naming it, never a different page. The uuid stays canonical, and the slug is unique **per project**, not globally.
| Name | In | What it does |
entity_type required | path | |
entity_id required | path | |
Takes a JSON body — see openapi.json for its schema.
GET /v1/entities/{entity_type}/{entity_id}/graph
Read the graph around one entity
Scope read:library · MCP toolgetEntityGraph
Which component uses which component, binds which token, applies which style — and the `metadata` that makes each edge actionable (`property`, `variants`, `nodeNames` on a token binding). This is the **inverse** of `getEntities`: it answers what breaks if you change something. **The graph always hangs off one node**, which is why it is in the path: an earlier version took optional ids and could be talked into scanning the project. Both ends carry `name` where it resolved; `null` means the id did not resolve, not that the entity is unnamed. **`truncated: true` means you got a slice — pass `next_cursor` back as `cursor` to walk the rest.**
| Name | In | What it does |
entity_type required | path | |
entity_id required | path | |
edge_types | query | `USES_COMPONENT` | `BINDS_VARIABLE` | `USES_STYLE` | `STYLE_BINDS_VAR` | `ALIASES_VARIABLE`. Repeatable or comma-separated. **Narrows the result; it does not select the node.** |
direction | query | `outbound` = what this uses · `inbound` = what uses THIS, the impact question · `both` (default). The default is `both` on purpose: with one node and no direction stated, returning one side would hide half the graph without saying so. |
limit | query | 1..500, default 100. Above 500 truncates. |
cursor | query | From a previous `next_cursor`. Pass it back verbatim; do not construct one. The page order is stable, so paging twice yields the same edges. |
GET /v1/entities/{entity_type}/{entity_id}/related
Walk the graph around one entity
Scope read:library · MCP toolfindEntities
Everything reachable from one entity, with **how many hops away it is and the route it was reached by**. `hops` is a number and `via` is the path — one entry per edge crossed, naming the edge type, the direction, and the node it passed through. A "direct / indirect" label cannot tell you whether a token turned up because this component binds it or because a style it uses binds it; `via` can. It is the traversal counterpart of `GET /v1/entities:match`: that one takes a term and narrows by ENTITY type, this one takes an id and narrows by EDGE type. It also differs from `…/graph`, which returns raw EDGES with their `metadata` for one hop — use that to find *where* in a component a binding lives, and this to get the set of things connected to something. ⚠️ **`depth` defaults to 1 and the graph is dense** (thousands of edges over a few hundred nodes), so a second hop multiplies the answer rather than adding to it. Every response carries `more`: `edges_to_expand` is an exact count of the edges the next level would follow, and `est_tokens` is a CEILING derived from it — it assumes every edge reaches something new, and on measured walks only 27-43% did. `more: null` means the ceiling was reached or the walk is already complete. `truncated: true` means the node ceiling was hit and `related` is a floor.
| Name | In | What it does |
entity_type required | path | |
entity_id required | path | |
depth | query | 1..3, default 1. Above 3 it is REJECTED, not truncated: a silently lowered depth would read as the complete closure. Do not raise it speculatively — `more` counts what the next level costs before you pay. |
edge_types | query | `USES_COMPONENT` | `BINDS_VARIABLE` | `USES_STYLE` | `STYLE_BINDS_VAR` | `ALIASES_VARIABLE`. Repeatable or comma-separated. Narrows which relationships are followed; it does not select the starting node. The cheapest way to make a deep walk affordable. |
direction | query | `outbound` = what this uses · `inbound` = what uses THIS, the impact question · `both` (default). It applies to every hop, so `outbound` at depth 2 is "what this uses, and what those use" — not a mixed walk. |
limit | query | Entities per page, 1..200, default 50. It bounds what is RETURNED, not what is walked: lowering it does not make a depth-2 walk cheaper on the server, only in your context. |
cursor | query | From a previous `next_cursor`. Pass it back verbatim. Keep every other argument identical while paging — the walk is recomputed per call, so the page order only holds for the same question. |