Recreate a Figma design (Design file or FigJam board) as a native, multi-layer, fully-editable Abyssale design via the abyssale_preprod MCP...
Goal: take a Figma node, extract its structure/styles via the Figma MCP, and rebuild it as native Abyssale layers (text/shape/image), decomposed element-by-element rather than flattened into one screenshot β unless the source itself is already a single flattened image (see "When NOT to decompose" below).
This uses the community "Framelink" MCP (figma-developer-mcp, GitHub GLips/Figma-Context-MCP).
Verified by reading its source (src/services/figma.ts): it is a pure passthrough to the
official Figma REST API (https://api.figma.com/v1) β no extra logic, no extra endpoints.
mcp__figma__get_figma_data(fileKey, nodeId?, depth?) β calls GET /files/:key or
GET /files/:key/nodes?ids=.... Returns a simplified but style-complete tree:
fills/colors (resolved through a GLOBAL_VARS lookup table), strokes, effects, opacity,
borderRadius, typography (fontFamily/fontWeight/fontSize/lineHeight/letterSpacing/align),
auto-layout (mode/gap/padding/sizing), and the NODES tree (children in z-order),
plus COMPONENTS/COMPONENT_SETS for instances.mcp__figma__download_figma_images(fileKey, nodes, localPath, pngScale) β calls
GET /images/:key?ids=.... Downloads REAL image files (png/svg) locally.
imageRef (from a node's fills[].imageRef) is required for image-fill rectangles;
omit it for vector/SVG nodes (auto-detected). pngScale controls real resolution.Install (if not already connected): claude mcp add figma -e FIGMA_API_KEY=<token> -- npx -y figma-developer-mcp --stdio.
Token needs the "File content: Read" scope (Figma β Settings β Personal access tokens).
Wrong scope (e.g. only file_dev_resources) β 403 Invalid scope(s)... requires file_content:read.
A newly-added MCP server needs a session restart before its tools show up via ToolSearch.
| Type | URL pattern | Status |
|---|---|---|
| Design file | /design/... |
β supported, tested |
| FigJam board | /board/... |
β supported, tested (2 full designs built) |
| Figma Buzz | /buzz/... |
β confirmed unsupported β Figma's own REST API returns 400 "File type not supported by this endpoint" on GET /files/:key. Not an MCP bug (verified in source: no file-type filtering happens in the MCP, the 400 comes straight from api.figma.com). No workaround exists through this API. |
| Figma Slides | .deck |
β οΈ likely unsupported β per Figma community forum, Slides nodes only exist in the proprietary Kiwi binary format (fig-deck), not exposed via REST API. Not personally tested. |
| Figma Sites / Make | β | not tested, no info found |
If a URL has no node-id, it points at the file root β call get_figma_data(fileKey, depth: 2)
first to list top-level frames/pages, then re-call with the right nodeId.
Error looks like:
Error fetching file: Figma API rate limit hit (429). Retry after <seconds>. Your Figma seat type
(Viewer or Collaborator) has a lower API rate limit. Your starter plan has limited API access.
Verified exact number from Figma's own rate-limits doc (developers.figma.com/docs/rest-api/rate-limits/):
on a Starter plan with a Viewer/Collaborator seat, Tier 1 endpoints are capped at 20 requests per
MONTH. Tier 1 = exactly the endpoints this pipeline depends on: GET file, GET file/nodes,
GET image β i.e. every get_figma_data and download_figma_images call. This is a monthly
quota, not a recurring short-term rate limit β don't describe a retry delay of several days as
"the rate limit is 4-6 days," that's just how far into the current monthly cycle you happen to be
before it resets. Correction logged 2026-09-01 after stating this imprecisely to the user.
Practical consequence: 20 Tier-1 calls/month is very easy to exhaust β a single moderately
complex design rebuild can use 5-10 calls (one get_figma_data + several download_figma_images
for each asset/reference render). Budget calls accordingly: batch multiple nodes into one
download_figma_images call when possible (it accepts an array), and avoid extra reference-image
downloads once the layout is already understood from a prior render in the same file.
No dashboard in the Figma web UI shows live quota usage (verified via docs) β the only UI pointer
Figma gives is an X-Figma-Upgrade-Link response header pointing to /pricing or /settings when
the limit is hit. If this happens: report the exact error + retry delay to the user, do not
loop/retry, and ask whether to wait, upgrade the plan/seat, or use a different token.
get_figma_data outputGLOBAL_VARS: a dedup table of shared layout_*, fill_*, style_* values referenced by
key from nodes below (saves tokens on repeated styles). Resolve these before mapping.NODES: the actual tree, indented, in z-order. Node line format:
[TYPE] "name" #id layout={...} fills=... strokes=... textStyle=... text="...".
Types seen: FRAME, TEXT, RECTANGLE, ELLIPSE, INSTANCE, IMAGE-SVG, SHAPE_WITH_TEXT, CONNECTOR, GROUP, SLOT.locationRelativeToParent: {x, y} or implicit in
auto-layout). To get absolute canvas position for the Abyssale payload, sum offsets down
the tree (recursive walk from the root frame, accumulating x/y).sizing: {horizontal: hug, vertical: hug} or no dimensions at all)
have NO explicit width/height in the data β you must compute it yourself (child bbox + padding,
or from a download_figma_images pixel size Γ· pngScale). Known bug from a past session:
forgetting this for 2 nodes (bubble_blue, name_pill) silently dropped them from the rebuild
because a hand-written tree-walker skipped emitting layers when w/h was None. Never leave a
hug-sized element's box uncomputed.fills/strokes (via GLOBAL_VARS) β no manual
Figma-variable resolution needed on your end.| Figma | Abyssale |
|---|---|
TEXT |
text layer β payload = the text, color, font (see Β§5), font_weight, font_size, line_height (%, Figma lineHeight: 1.4em β 140), text_align, vertical_align |
RECTANGLE/ELLIPSE (no image fill) |
shape β background_color, radius (from borderRadius), stroke: {color, width} (nested, not flat), optional shadow |
RECTANGLE with fills[].type == "IMAGE" |
image β download via download_figma_images using that fill's imageRef, fitting_type: "cover" (Abyssale only accepts cover/fill, never contain) |
IMAGE-SVG |
image β download via download_figma_images (no imageRef needed, vector auto-detected) |
RECTANGLE/ELLIPSE/VECTOR used as an organic/irregular vector shape (blobs, wavy decorative lines, dot patterns, thin accent strokes) |
image, same as IMAGE-SVG β see Β§6b, this is not a native shape even though the Figma type looks like RECTANGLE/ELLIPSE |
SHAPE_WITH_TEXT (e.g. sticky notes) |
split into 2 layers: a shape (bg/radius) + a text (centered, using the node's name or its text content) |
CONNECTOR |
approximate as a thin shape (2-4px wide rect) along the bounding box β exact bezier/elbow routing is not worth reproducing natively; disclosed simplification, consistently used across sessions |
Complex INSTANCE (toolbars, keycaps, deeply nested UI chrome) |
judgment call: either fully decompose (preferred, per "no grouping" default) or flatten to one downloaded image if genuinely decorative chrome not worth decomposing β always disclose this compromise explicitly to the user, don't decide silently |
Pure transparent layout FRAME with no fill/stroke of its own |
skip β it's just an auto-layout container, not a visible layer |
#RRGGBBAA (8-char hex with alpha). No 10-char hex.
If alpha is already embedded, don't append an extra FF.list_fonts to find the closest
match (Figma project fonts like "Kaftan Serif - Trial" or "Nanum Pen" are almost never
available in Abyssale β substitute the nearest available font and mention the substitution).line_height: Abyssale takes a percentage; Figma gives em (e.g. 1.4em β 140, 1em β 100).If get_figma_data on the target node returns literally a single RECTANGLE/node with one
image fill and no children, the source design was already delivered as one flattened image β
there is nothing to decompose. Import it as a single image layer at native pixel size
(get exact dimensions via download_figma_images). This is not a violation of "no grouping" β
that rule is about not artificially flattening multiple distinct elements, not about
inventing layers that don't exist in the source.
image, never native shapeAbyssale's native shape layer only supports rectangle/ellipse primitives (background_color,
radius, stroke) β it cannot reproduce an arbitrary bezier path. So any Figma element that is
visually an irregular vector β organic blobs, decorative wavy lines, a column of small dots grouped
as one vector, a thin diagonal accent line β must be downloaded as a real image
(download_figma_images, no imageRef needed for pure vectors) and placed as its own image layer,
sized/positioned from the Figma node's own bounding box.
This is still full decomposition, not grouping β each irregular shape stays its own distinct, individually-positioned layer; only its internal representation is a raster image instead of a native primitive. Only collapse multiple different vector elements into one image if they were already a single Figma node to begin with.
Validated end-to-end on a real design (Test Yass β teal/orange poster with 2 organic blobs, a
wavy line, a dot-pattern column, and a thin diagonal accent line, all reproduced as separate
image layers at their native Figma bounding box, alongside 3 native shape/photo layers and
6 native text layers β 14 layers total, visual match confirmed against the Figma render).
Circular photo crops: an ELLIPSE node with an IMAGE fill (a Figma "photo in a circle" pattern)
maps to one image layer with mask_name: "circle" (Β§7) at the ellipse's own square-ish bounding
box β don't split it into a separate ellipse-mask shape, mask_name does the clipping natively.
When the root FRAME has no explicit dimensions in get_figma_data (seen when the frame's own
sizing is {"mode": "none", "sizing": {}} with nothing else) β don't guess the canvas size from the
children's bounding box (background/decorative elements often bleed off-canvas, which throws off a
bbox guess). Instead, download_figma_images the root frame node itself at pngScale: 1 β Figma
always rasterizes a frame at its own true defined size, so the returned pixel dimensions are exact.
This same rendered PNG also doubles as a visual reference to check your element mapping against.
Finding fonts efficiently: list_fonts has ~1180 entries β always pass the name filter
(case-insensitive substring match) instead of paging through the full list, e.g.
list_fonts(name: "Open Sans") or list_fonts(name: "Slab") to find a substitute for an
unavailable Figma font.
import_design_from_json schema gotchas (learned the hard way)layout.<format>.x/y/width/height must be integers β round everything, wrong_type error otherwise.#RRGGBB/#RRGGBBAA hex only (or gradient/cmyk objects) β never a 10-char hex string.stroke on shape/button is nested: {"color": ..., "width": ...}, not flat stroke_color/stroke_width.padding on button is nested {"vertical": ..., "horizontal": ...} β and it is not
optional in practice: padding drives the button's actual rendered size, not
layout.width/height. A button with no padding set renders far smaller than its
declared width/height β confirmed side by side: the same layout box (100Γ44),
one button with no padding rendered barely bigger than its own text, the other with
padding: {"vertical": 14, "horizontal": 20} rendered at the intended size. Always
set an explicit padding on every button layer; treat the declared width/height
as a rough positioning hint, not a guaranteed final size β a button that sits close to
a canvas edge or another element may need re-checking after a real render, since its
actual rendered width isn't precisely predictable in advance.rotation (shape/button) must be 0β360, not signed degrees.scale in animation tweens must stay 0β100.keyframes array.video layer type rejects fitting_type/alignment (unlike image).fitting_type on image only accepts "cover" or "fill" β never "contain".mask_name: "circle" is available on image layers for circular crops.radius works on image layers too (not just shape) β confirmed via validate_only, e.g. radius: 284 on a 568Γ745 image produces a pill/stadium shape (rounded rect where radius β half the shorter side), not just a plain circle mask. Use this for any Figma RECTANGLE with an image fill and a large borderRadius.rotation and shadow both work on text layers, not just shape/button (undocumented previously, confirmed via validate_only + a real render). rotation is clockwise, 0β360, and pivots near the top-left corner of the declared layout box β confirmed empirically: a box {x:100,y:100,w:200,h:40} with rotation: 90 rendered as a vertical strip reading bottom-to-top, anchored near the box's original top-left, not its center. For a Figma node whose reported bounding box is already the rotated box (e.g. a vertical text label with w:42,h:530), swap back to the pre-rotation horizontal dimensions (w:530,h:42) and keep the same x,y origin before applying rotation: 90 β don't feed it the already-rotated box.background_color (e.g. {"type": "radial", "colors": [...]}) fails with wrong_type: Not a valid string β confirmed via validate_only. background_color (and color) must always be a plain hex string. For a Figma GRADIENT_LINEAR/GRADIENT_RADIAL fill (frame background or shape fill), there's no native reproduction β regenerate the gradient as a local PNG (e.g. with Python/Pillow, replicating the CSS linear-gradient()/radial-gradient() given in fills[].gradient) and use it as a background image layer instead. Worth the effort only when the gradient is visually prominent (a full background); for a small/subtle gradient accent, a flat average-color approximation is an acceptable disclosed shortcut.Figma files sometimes carry leftover/duplicate assets positioned thousands of pixels outside the
frame (seen: x: -8916 on an 1080-wide canvas) β remnants of a component library or an old layout
pass. These never render and are not part of the visible design. Check every node's absolute
position against the canvas bounds; anything with zero overlap with the canvas rectangle should
be dropped from the payload entirely. This is not a "no grouping" violation β an invisible node
isn't a visual element to preserve.
Don't infer rotation/skew from coordinates alone β download the target node (or its parent frame)
and Read it first. A real case: a repeating background pattern of 16 text tiles with staggered
x/y offsets looked like it could be a diagonal rotated tiling from the coordinates alone, but the
rendered reference showed it was actually a plain axis-aligned staggered/brick grid (no rotation at
all, rotation field wasn't even present on those nodes) β one reference-image check avoided
wasting a rotation guess on 16 layers.
Same principle as complex INSTANCE chrome (Β§4): decompose into the distinct real layers rather
than one flattened image β typically bezel/frame vector (image), artwork/screen content
(image, imageRef from the inner content rectangle), and if present a top overlay frame (image,
drawn last/on top β handles rounded-corner clipping/notch cutout that the bare artwork would
otherwise bleed past). Skip a plain white/solid "mask" base layer that sits directly behind
already-opaque cover-fit artwork of the exact same box β it can never be visible, so it isn't a
decomposition shortcut to include it, just dead weight.
https://app-preprod.abyssale.com/designs/<uuid> is a JS-rendered SPA behind auth β WebFetch on
it only ever returns a static loading-shell message ("Abyssale is loading your experience..."), never
the actual design content. Don't rely on it to verify a design. Always use
generate_static_banner + download + Read (Β§8 step 9) for visual QA instead.
fileKey + nodeId from the Figma URL (node-id=942-2723 β nodeId "942:2723", dashesβcolons).get_figma_data(fileKey, nodeId). If node has no node-id in URL, first call with depth: 2 to find the right top-level frame.download_figma_images(fileKey, nodes: [{nodeId, imageRef?, fileName}], localPath: "src/assets/figma", pngScale).import_design_from_json payload (target.project_uuid, formats, layers).import_design_from_json(validate_only: true) β fix any schema errors (see Β§7), repeat until clean.import_design_from_json(validate_only: false, wait_for_result: true) β returns WAITING_FOR_VALIDATION with one upload_command (curl) per image layer. Each has a unique policy + signature β never reuse one file's signature for another file. Execute each curl command verbatim, only substituting <LOCAL_FILE_PATH>.check_design_import_status(import_id) until status: "DONE" β result.uuid is the new Abyssale design id. (Usually 1-2 polls with a few seconds' wait is enough; don't poll indefinitely.)generate_static_banner(design_id, format_name) β download the rendered JPEG and Read it to visually QA against the Figma source before reporting back.