Reading responses
Several responses carry a field whose job is to tell you how to read the rest of the response. Skipping them does not produce an error — it produces a plausible wrong answer. This page is the list, and why each one exists.
est_tokens — present sometimes, and its absence is information
Where: getEntities, and every response carrying a more.
more always tells you how many entities are in the next layer. It tells you what that layer
costs only sometimes, and the difference is deliberate.
count— always exact, always present. It is a number we already had.est_tokens— only where the bytes were already in memory and are exactly the bytes that would be sent. There it is a measurement of a real payload, not a per-record constant.
When est_tokens is missing, the layer is not free. It means its size is not knowable without
fetching the data — and measuring it would cost the same as fetching it. We measured that: the
COUNT(*) that used to price an unfetched layer read 20 buffers, against 18 for the SELECT that
would have fetched the same rows. An estimate there pays the full price of the call it claims to
save you.
So the rule for a caller: decide on count, and treat est_tokens as a bonus where it appears.
An earlier version of this API answered with an est_tokens on every layer, derived from a
per-record constant. Those constants overstated real sizes by 41% and 89% when finally measured
against production — which is enough to make an agent skip a layer that would have fit comfortably.
included and more — before assuming you got everything
Where: getEntities.
The tool returns the abstract of each entity, not its detail: props and bound tokens are
withheld and asked for through include. So the response has to say two things a caller cannot infer
from the data itself.
included— which detail layers actually arrived, after defaults. It is per entity, on each row ofdata, because whether a layer could be served is a property of that row and not of the call.[]is the abstract alone.more— the layers this response left out: how many entities each covers, and the exact call that fetches it, withest_tokenswhere the size is already known.
more never comes back empty. Even when every layer has been fetched it still carries
_abstract, whose how reads "already in this response" and whose est_tokens prices what you
are already holding. “Nothing left to ask for” looks like a more with _abstract and nothing
else, not like {}.
more is the difference between progressive disclosure and pagination. A caller that cannot
budget before asking is not choosing, it is guessing — and it guesses large, which is the behaviour
this whole shape exists to stop.
spec_schema_version — before reading a spec
Where: every response of getComponentSpec.
More than one spec schema version is normally live in the same design system at the same time, because a spec is rewritten only when its component is re-synced. Components nobody has touched keep the shape they were captured with.
The two shapes serialise with identical top-level keys. The differences are nested. So you cannot tell them apart by inspection — only by reading the version.
The failure mode is specific and nasty: the older shape is usually a small minority of the corpus. Code that ignores the version passes every test written against a handful of components, ships, and meets the other shape in production — rarely, and without a reproducible case.
The response travels as a pair for exactly this reason: spec and spec_schema_version sit beside
each other at the top level, so you cannot hold the document without also holding what to read it
against.
const { spec, spec_schema_version } = await getComponentSpec({ componentId });if (spec_schema_version !== EXPECTED) { // Handle it, or skip it, or tell the user. Do not just parse and hope.}getComponentSpec answers one component per call, so within a single response there is only one
version. Calling it for several components — one call each, since it does not batch — can still land
different versions across those calls: nothing keeps two components synced against the same schema.
Reread it every time rather than caching it from the first response.
null means the spec does not declare a version.
status — before reading a build order
Where: the top level of getComponentBuildOrder({ componentId }).
status | Meaning | ordered_components |
|---|---|---|
computed | It has dependencies | The list, the component itself last |
trivial | It depends on nothing | Just the component itself |
ordered_components is never null and never []. A component that exists always has an
order — at minimum itself — and one that does not is a 404. So there is no “empty” reading to
disambiguate: if you got a list, it is the answer.
trivial is still worth branching on even though the list is derivable: it tells you in one field
that there is nothing to build first, without you having to count.
unresolved and requested — before matching a batch to your request
Where: getEntities.
ids is always plural, so one id and fifty are the same call — and data contains only the ids
that resolved. If you send 50 and receive 48, the two absent ones are not marked in place: the array
is simply shorter.
Never zip data against your request by position. One absent id shifts every entry after it,
and you end up attributing one component’s spec to another. Two fields make that unnecessary:
requested, on every entity, echoes the exact string you sent for it. Since a Grails id and a Figma node id are interchangeable inids, this is also how you map a resolved uuid back to the node id you started from.unresolvedlists what did not resolve, as{ requested, kind, reason }.
The two reason values need different fixes:
reason | What it means | What to do |
|---|---|---|
not-found | Nothing in this design system carries that id. | Look the id up again with listEntities or findEntities. |
ambiguous | More than one entity type carries it, and we do not guess. The response names which. | Re-ask for the type you meant. Colours, styles and collections share one namespace of 40-character Figma keys, so this is real. |
A component that exists but has no extracted spec is not in unresolved. It comes back in
data, with its abstract, and the layer you asked for simply missing from included — see that
section above. The two are separate on purpose: one is “this id is not a thing here”, the other is
“this thing has not been captured deeply enough yet”, and they call for opposite actions.
An id belonging to another design system reads as not-found, exactly like one that never
existed — see Not an oracle below. That collapse is the only distinction this surface refuses to
make; the spec/no-spec split is drawn inside your own project, where
listEntities({ type: "component" }) already reports every component’s has_spec for free.
coverage — before concluding nothing matched
Where: findEntities({ query }), which answers in the same shape.
An empty result is only interpretable next to coverage, and this is the field that carries the
difference between the two things it can mean.
coverage is per engine — semantic, text, name — and it is always present. Each carries
indexed (how many entities of the requested types that engine can see), embeddable (how many
exist — the same denominator for all three, which is what makes them comparable), ran, and, when
ran is false, a note saying why.
An engine with ran: false did not look. That is a completely different fact from an engine
that looked and found nothing, and the two call for opposite actions: one means index more, the
other means search differently. The semantic index only holds entities that already have
documentation, so today it is nearly empty and most searches are answered by the name engine
alone — which matched_by states on every match.
A type you asked for that is absent from groups matched nothing;
filters_applied.entity_types echoes what was actually searched, so “no flows matched” and “flows
were not searched” stay distinguishable.
shape.verdict — before taking the first row
Where: findEntities({ query }). It is per group, on both.
There is no confidence in this API, and score is not one: it is a fused reciprocal-rank sum,
comparable within one response and meaningless across responses. A cosine is not one either — it
measures an angle.
What is actionable is whether the top match stands out, and that is shape.verdict, per group:
one_stands_out— it beat the second on evidence carried in the same response:surfaces(how many engines found it), the name-match tier, or a cosine lead of at least 0.05.flat_ambiguous— none of that held, so the query was ambiguous. Ask a narrower question instead of picking the first row.
The rule is stated so you can re-derive it: a verdict you cannot check is one you cannot trust when it disagrees with your own reading.
mentions[].exists — before treating a reference as live
Where: getEntityDocumentation({ entityType, entityId }), and getDocumentationPage({ page })
for a doc page — the same branch travels with the blocks either way.
The response carries a mentions branch: every entity this documentation references, resolved to a
name and a type so you do not have to look each one up. exists says whether the reference still
points at something.
{ "entity_type": "token", "entity_id": "0f8b1e2a-…", "count": 1, "name": null, "exists": false }false means dangling — the entity was deleted, never existed, or belongs to another design system.
Those three are deliberately not told apart; see Not an oracle below.
Do not read a null name as “the lookup failed”. It is the opposite: the lookup ran and found
nothing. Without the flag those two would look identical, and they need opposite responses — one is
a retry, the other is a finding to report: documentation pointing at something that is gone.
The branch is present on every response, [] when there is nothing to resolve. That is not
cosmetic: this field was in the data for months as an unnamed per-block value, and a branch that
appears only when populated is indistinguishable from one that does not exist.
truncated — before reporting a count as a total
Where: checkHealth(), with no arguments — that is, inside its summary.
The walk stops after a fixed number of pages. When it does, summary.truncated is true and
drifting_count is a floor, not a total.
A capped count published as a total is one of the easiest mistakes to make and one of the hardest to notice, because the number looks perfectly reasonable. The flag is the only thing that separates “this is the answer” from “this is as far as I got”.
search_mode_used — before comparing scores
Where: listEntities({ type: "component", q, searchMode }).
The mode you request and the mode that served the query are not necessarily the same. semantic and
hybrid are accepted today and served lexically; the response says what actually ran.
Scores are only comparable within the same search_mode_used. Different strategies rank on
different scales, and a score from one is meaningless against a score from another.
filters_applied — to check the server understood you
Where: listEntities, and findEntities({ relatedTo }).
An echo of the filters that took effect, after defaults were resolved — not a repeat of what you sent. If you passed something the server ignored or normalised, this is where you see it.
Not an oracle: what a 404 does and does not tell you
An id that belongs to another team returns exactly what a nonexistent id returns. Same status, same code, same message.
This is deliberate. If the two differed, anyone with a key could map other teams’ component ids by probing and reading the difference.
This does not apply to the spec/no-spec split in getEntities. That one tells you whether a
component exists in your project without a spec — a distinction you can already make with
listEntities({ type: "component", hasSpec: false }), and one that stops short at the tenant
boundary: another team’s id lands in unresolved as not-found, indistinguishable from a
nonexistent one.
Practical consequence: a 404 on an id you are sure exists usually means you are using the key for a different project. Check that before assuming the entity is gone.
last_synced_at — how fresh any of this is
GRAILS answers from what the last sync captured. Nothing here reaches into Figma at query time.
So every response is exactly as current as your last sync, and last_synced_at on a component is how
you find out. If a designer changed something five minutes ago and nobody has synced, the API will
happily and correctly tell you about the old version.