Figma Bridge
Figma has no public write API. This skill uses a local bridge: named operations over HTTP, executed inside Figma by a plugin, through the Figma Plugin API. No OAuth, no tokens.
Repo: https://github.com/iggypcnfsky/figma-bridge — AGENTS.md, docs/ops-reference.md.
Preconditions — check before anything else
figma is on PATH, so never search the filesystem for the repo:
figma doctor # repo, server, connected windows, img-forge + FAL key in one shot
figma clients # id, file, page of every connected Figma window
figma rules # project constraints: type floor, grid, frame size, version naming
Read figma rules before designing. It carries the non-obvious limits — e.g. "Poppins 20/25 is the
floor" — that no amount of reading the canvas will tell you. Treat "confirmed": false as an
assumption to verify with the user, and {} as "ask, do not invent".
If the command is missing, use node "$(figma where)/server.mjs" only after asking; do not find the
repo, and do not guess paths.
pluginConnected: false→ stop. Ask the user to runPlugins → Development → Figma Bridgein the window of the file they want touched. Retrying does not help.503from/runmeans the same thing.504 PLUGIN_GONEmeans they closed the plugin window.409means several Figma windows are connected and none was chosen. Pick the file the user named (--file <NAME>) or a client id (--client <ID>); never retry blindly, or you may edit the wrong file.
The loop
- Orient.
./bin/figma status→ file name, current page, selection, all pages. - Read.
selection 2,node <id> 2,find {"name":"Header","allPages":true}. Never guess anodeId— Figma ids look like4022:2and are not derivable. - Plan. Decide parent, size, order. New nodes go to the current page unless
parentIdis given. - Write.
./bin/figma run '[{...},{...}]'. - Verify.
./bin/figma export <id> out/check.png PNG 2and actually look at the image.
Commands
./bin/figma op '{"op":"status"}'
./bin/figma run '[{"op":"createFrame","name":"Card","width":320,"height":200,"fill":"#0B0B0F",
"props":{"cornerRadius":16,"layoutMode":"VERTICAL","paddingTop":24,"paddingLeft":24,"itemSpacing":12}}]'
./bin/figma run '[{"op":"createText","parentId":"12:34","characters":"Title","fontSize":24,"color":"#FFFFFF"}]'
./bin/figma op '{"op":"varFill","nodeId":"12:34","variableName":"color/bg/surface"}'
./bin/figma export 12:34 out/card.png PNG 2
./bin/figma image 12:34 out/cover.png FILL # local image file → fill (no cloud upload)
Ops: ping status pages variables styles fonts inventory · selection node find · createFrame createRect createEllipse createText createPage createInstance · update clone reparent remove goto group ungroup select · grids setGrids align distribute effects · applyStyle varFill · export imageFill · exec.
Full parameters: docs/ops-reference.md.
Design-work shortcuts worth knowing:
- grid means GRID: when the user says "set a grid", they want the square layout grid, not columns:
{"op":"setGrids","nodeId":"…","grid":true}(40 px cells, 40% by default) or"grid":40. Usecolumns/rowsonly when they say columns, a 12-col grid, or give gutter/margin values. Never fake a grid with rectangles;applyStylewithkind:"grid"applies a saved grid style. - rows of items: build them, then
{"op":"align","nodeIds":[…],"mode":"TOP"}and{"op":"distribute","nodeIds":[…],"axis":"HORIZONTAL","gap":24}. - depth:
{"op":"effects","nodeId":"…","shadow":{"y":8,"blur":24,"color":"#00000033"}}or{"op":"effects","nodeId":"…","blur":{"type":"BACKGROUND","radius":20}}. - gradients:
{"op":"gradient","nodeId":"…","kind":"linear","angle":135,"stops":[[0,"#8B76FF"],[1,"#0E0E12"]]}— angle 0 is left → right, 90 is top → bottom. Never fake a gradient with stacked rectangles. - design system:
createInstancewith aCOMPONENTid, theninstancePropsfor variants (read the names first) andinstanceTextto change copy without detaching;applyStylefor styles. Do not redraw what the library already ships, and do not detach an instance to "fix" its text. - spacing checks:
{"op":"measure","a":"1:2","b":"1:3"}returns gaps per side and centre distance; use it instead of exporting a screenshot to judge alignment. - styles:
saveStylecreates or updates atext/paint/effect/gridstyle by name, andapplyStyleapplies it. Create the token once, apply it everywhere. - responsive:
sizingsetsHUG/FILL/FIXEDper axis plus min/max;constraintscovers non-auto-layout children (STRETCH/MIN/MAX/CENTER/SCALE). Read both fromnodebefore changing them —sizingreturnsskippedwith Figma's reason when a property does not apply.
Images
Need artwork? Generate it with the sibling img-forge CLI
(https://github.com/iggypcnfsky/img-forge), then place it — generation stays outside
Figma so the API key never enters plugin code and a slow render cannot block the canvas:
cd ../img-forge
./img gen --prompt "abstract gradient cover, purple to black" --preset 16:9@2k --out out/cover.png
./img edit --prompt "make it night with neon accents" --ref out/cover.png # or edit an existing asset
cd ../figma-bridge
./bin/figma image 12:34 ../img-forge/out/cover.png FILL
FILL crops to the node, FIT letterboxes, TILE repeats, CROP respects the existing crop.
To edit what the user selected, export it first (./bin/figma export <nodeId> out/sel.png PNG 2) and
pass that file as --ref.
The plugin window also has a Generate / Edit panel for humans (model, size presets, cost, insert).
Do not drive the panel programmatically — use the CLI; it exposes more (mask, variants, dry run).
Presets saved in that panel live in figma-bridge/presets.json.
Building on an existing draft
A draft that "isn't finished" still encodes decisions: what sits beside what, what is grouped, the order of elements, the spacing rhythm. Read it before designing, then keep it.
{"op":"inventory"}— the frames, their sizes and typography stats.{"op":"structure","nodeId":"<frame>"}— children grouped into rows with gaps and areadingOrder; that is the grouping the author chose.{"op":"node",…,"depth":2}gives the same tree with more depth.- Note the pattern (a left margin that repeats, a title that always sits above three columns) and keep it. Change what the request needs, leave the rest where it is.
- Report the deviations you did make — "moved the footer up by 40", not just "improved the layout".
Never normalise a draft silently. If consistency would require moving things the user placed, say so and ask; a tidy-looking slide that lost their groupings is a regression, not an improvement.
Rules
- Read before write. Two calls minimum: inspect, then mutate.
- One batch = one thought. An op cannot reference a previous op's result, so if you need the
new node's
id, split into two calls. - Respect the arrangement you inherit: sibling order, proximity and grouping are intent. Do not re-flow a draft to look tidier unless asked; ask first when consistency would move their elements.
- Never touch existing user layers (
update,remove,reparent) without explicit instruction. Prefix your own creations:Bridge / …. - A rework goes right beside the original, as close as possible. Asked to redo a
slide/screen/section: duplicate it, call
{"op":"placeNear","nodeId":"<new id>","move":true,"gap":80,"name":"Bridge / v2 — <name>"}so the copy lands on the first free side instead of a guessed coordinate, and leave the original untouched. If the tool reportsfree: false, ask where it should go. Replace elements in place only when the user explicitly asks for that. gotochanges the user's view — only on request.execis a last resort. It works (Figma's sandbox allowsnew Function) and bypasses all parameter validation. Return JSON-serializable values only.- Clean up test artifacts, or ask whether to keep them.
Pitfalls
- Use
localhost, never127.0.0.1: the plugin manifest allowlists onlyhttp://localhost:3847(devAllowedDomains); Figma rejects IPs. Server listens on both127.0.0.1and::1. figma.currentPageis the page open in that Figma window — not a saved pointer. Switching tabs changes what you see on the next call.- Positions are relative to the parent, and an omitted
parentIdmeans the current page. - Typography questions (which fonts, sizes, counts) are one call:
{"op":"fonts"}; add"allPages":truefor the whole file. Do not walk frames with your own script. - A slide deck overview is one call too:
{"op":"inventory"}returns every frame with geometry,minFontSize, fonts, grid count and fills — enough to pick "frames 11–19" or find text below a minimum size. - Never index a field the reference does not list.
summarycarriesfont,fontSize,lineHeightandletterSpacingfor TEXT; everything else comes fromexecwith the real API name. Mixed styles are the string"MIXED", so guard with.get()and fallbacks. - A rectangle cannot have children; use a frame when text or icons must sit inside a shape.
- No local variables →
variablesreturnscollections: []andvarFillfails. Check first. - Text needs a font:
createTextloads it vialoadFontAsync; if the family is missing, useInter. exportcaps at 8 MB — lowerscaleinstead of retrying.- Reading children of a non-current page needs
loadAllPagesAsync; the bridge retries once automatically, but preferfindwithallPages: true. - After editing plugin files (
code.js,ui.html) the user must re-run the plugin; changes tomanifest.jsonneed a re-import.