Figma Local Bridge
by Miart
README.md
# Figma Local Bridge
**Local MCP server for Figma Desktop.** Read and edit the open Figma file from Claude Code, OpenCode, Cursor, or any MCP client — including ChatGPT and Gemini clients that support local MCP — **no Figma Dev/Full seat, no API key in local mode, no telemetry.** Local mode stays on your machine; an optional, explicitly enabled read-only adapter can also read files over Figma's REST API. Unofficial project, not affiliated with Figma, Inc.
## What is it?
Figma Local Bridge is an open-source **Model Context Protocol (MCP) server for Figma Desktop**. It connects an AI client to a small Figma plugin over a local WebSocket, so the model can inspect, screenshot, build, audit, compare, checkpoint, and edit the file that is open in the Figma desktop app — without a Dev/Full seat. Local mode needs no cloud API and no API key; an optional read-only REST adapter can be enabled for files that are not open.
It is a practical alternative when the official Figma MCP server is unavailable, when the file is only open locally, or when an agent needs guarded writes, visual regression checks, design-token sync, or sprite/atlas export.
### At a glance
| Fact | Value |
|---|---|
| Version / source | `0.35.1` · [github.com/Miart/figma-local-bridge](https://github.com/Miart/figma-local-bridge) |
| What it is | Local stdio MCP server + Figma Desktop plugin + one WebSocket daemon |
| Client support | Any MCP stdio client; examples: Claude Code, OpenCode, Cursor, and ChatGPT/Gemini clients with local MCP support |
| Figma requirement | Local mode: Figma Desktop with the included development plugin (no Dev Mode seat). Optional cloud tools: a Figma PAT/OAuth token with file access |
| Transport | `127.0.0.1` WebSocket; no telemetry. Figma REST only when explicitly opted in |
| Files at once | Up to 16 open Figma files, routed by `fileKey` |
| Tool surface | 17 parity + 33 local = **50 tools**; +5 optional cloud tools = **55 when enabled** |
| Writes | Local writes guarded: dry run, `planId`, fingerprints, stable keys, one undo transaction, rollback, verification. The cloud adapter never edits Figma; `figma_cloud_download_image` writes local image files only |
| Verification | `figma_audit`, `figma_compare`, `figma_review`, `figma_checkpoint`, `figma_design_system`, `figma_tokens`, `figma_motion_*`, journal/ops |
| Runtime | Node.js 20+ |
| Installer | macOS 0.35.1: double-click `Install.command` in the release zip; one Figma plugin import and one token paste remain manual |
| License | MIT |
### Local by default, optional read-only cloud
`figma_ping` returns a `capabilities` object with the effective local mode (`read-only`, `write`, or `write+destructive`) and an explicit cloud boundary. The default is off:
```json
{
"local": { "plugin": "Figma Desktop", "mode": "write" },
"cloud": {
"available": false,
"configured": false,
"mode": null,
"tools": []
}
}
```
Set **both** an opt-in switch and one credential to enable the read-only REST adapter:
```sh
FIGMA_CLOUD=1
FIGMA_CLOUD_TOKEN=... # personal access token
# or: FIGMA_CLOUD_OAUTH_TOKEN=... # OAuth access token
```
Then `figma_ping` reports `available: true`, `mode: "read-only"`, the five tools and the reads they support (`file`, `nodes`, `images`). The adapter calls only documented GET endpoints — `/v1/me`, `/v1/files/:key`, `/v1/files/:key/nodes`, `/v1/images/:key` — and never edits a file. A token alone does not enable it: this avoids surprising cloud traffic when an editor already has a Figma token in its environment.
Figma-hosted file creation, AI generation, generative plugins, shaders and Weave remain unavailable in both modes. They are not proxied and are never simulated; use the official remote Figma MCP or the Figma UI for those. Tools the official server marks remote-only — `download_assets`, `get_libraries`, `whoami`, `search_design_system` — have local counterparts here, answered from the open file rather than the cloud.
### Common tasks
| Ask in natural language | Start with |
|---|---|
| "What Figma file is open? Show me the selection." | `figma_ping`, `figma_get_selection`, `figma_whoami` |
| "Inspect this frame / find its node id." | `figma_get_metadata`, `figma_inspect`, `figma_index` |
| "Take a screenshot or export a PNG/SVG." | `figma_get_screenshot`, `figma_download_assets` |
| "Build or update this UI from a keyed spec." | `figma_build`, `figma_patch` |
| "Make many small edits in one transaction." | `figma_batch_edit` |
| "Apply or remove an animation style, keyframes, or timeline duration." | `figma_motion_styles`, `figma_motion_inspect`, `figma_motion_apply_style`, `figma_motion_remove_style`, `figma_motion_set_keyframes`, `figma_motion_remove_keyframes`, `figma_motion_set_duration` |
| "Audit the design system and suggest tokens." | `figma_audit` (`suggest:true` by default) |
| "Compare the render with a mockup." | `figma_compare` |
| "Save a baseline, edit, then tell me what changed." | `figma_review` (`save` → edit → `compare`) |
| "Export, diff, or import design tokens." | `figma_tokens` |
| "Swap a component or build a variant matrix." | `figma_swap_component`, `figma_variant_set` |
| "Save/restore a subtree without an undo stack." | `figma_checkpoint` |
| "Export a sprite atlas, 9-slice, states, or web handoff." | `figma_export_ui_atlas`, `figma_export_9slice`, `figma_export_ui_states`, `figma_export_web_handoff` |
| "Search the design system or file." | `figma_search`, `figma_search_design_system`, `figma_design_system` |
| "Run a raw Plugin API snippet." | `figma_use` (destructive opt-in + scope) |
| "A write timed out — did it run?" | `figma_ops`, `figma_cancel`, `figma_journal` |
| "Read a file that is not open in Figma." | `figma_cloud_get_file`, `figma_cloud_get_nodes` (opt-in) |
| "Give me render URLs for a remote Figma node." | `figma_cloud_get_image` (opt-in) |
| "Download remote Figma renders to disk." | `figma_cloud_download_image` (opt-in) |
| "Check whether my Figma REST token works." | `figma_cloud_ping` (opt-in) |
### Example requests
```text
Audit the selected frame, list only errors, then fix the contrast findings without touching spacing.
Save a figma_review baseline of "Checkout / Card", apply the patch, and compare the result.
Export the Theme variables, change the dark background token, diff, and apply with the plan.
Build a 4x4 sprite atlas from the selected frame and verify the manifest rects against the PNG.
```
### FAQ
**Does Figma Local Bridge need a Figma Dev or Full seat?** No. It uses a custom plugin with `editorType: ["figma", "figjam"]`, not the `devid` manifest used by Dev Mode.
**Does it use the Figma cloud API?** Only if you enable the optional read-only adapter. Local mode is a plugin → daemon on `127.0.0.1` → stdio MCP server, with no cloud calls. `FIGMA_CLOUD=1` plus a PAT/OAuth token adds five tools for files that are not open; it never writes to Figma and never proxies Figma's hosted AI.
**Can Claude, ChatGPT, or Gemini use it?** Any client that can launch a local stdio MCP server can. The repository includes an OpenCode-style config; Claude Code and Cursor use the same `command` + `args`. ChatGPT/Gemini clients need local MCP support and the same server command.
**Is it the official Figma MCP server?** No. It is an independent MIT project and is not affiliated with Figma, Inc. It mirrors 17 official tools, adds 33 local ones, and has five optional cloud tools (Figma read-only).
**Can the agent edit the file?** Yes, through guarded tools. Every mutating family supports a dry run and a `planId`/fingerprint boundary; `figma_use` is the raw escape hatch and needs explicit destructive access plus a scope.
**Can it prove an edit is safe?** No tool can prove intent. It can prove what changed: `figma_review` reports new/resolved audit findings and pixel/structural deltas; `figma_checkpoint` rebuilds and verifies a subtree; `figma_compare` reports where pixels differ.
**Where are screenshots and reviews stored?** Screenshots are returned as MCP images or written where you ask. `figma_review` baselines live locally under `~/.local/share/opencode/figma-bridge-reviews/<fileKey>/` (override with `FIGMA_BRIDGE_REVIEWS_DIR`).
## Why this exists
Figma's official MCP server (Dev Mode, `127.0.0.1:3845`) requires a Dev/Full seat on Professional+. This project ships its own Figma plugin instead: the manifest uses `editorType: ["figma", "figjam"]` rather than `devid`, so it runs in an ordinary editor window. Local mode stays on the machine — a WebSocket on `127.0.0.1`, no telemetry, no API key. The optional REST adapter is off unless you set `FIGMA_CLOUD=1` and a token, and it is read-only.
## Architecture
```
Figma Desktop ──WebSocket──▶ daemon (port 8787, one per machine)
▲
│ WebSocket client
┌─────────────────────┼─────────────────────┐
MCP process MCP process MCP process
(workspace A) (workspace B) (workspace C)
▲
│ stdio
OpenCode / Claude / Cursor
```
The daemon exists for three concrete reasons:
- the Figma plugin pairs with **one socket per open file**, and Figma's manifest CSP can only allowlist **fixed** origins, so the port cannot be ephemeral;
- MCP clients spawn one server process **per workspace** — on a fixed port, they would collide;
- one token, one plugin import: the second Figma window costs nothing extra.
**One daemon = one port = one token = one plugin import — for up to 16 open files at once.**
## Repo layout
| Path | What |
|---|---|
| `server/src/` | stdio MCP server, daemon (WebSocket hub), link state, tool implementations |
| `plugin/` | Figma plugin: `code.ts` bundled to `code.js` by esbuild, UI panel, dispatcher, scope guard |
| `install/` | macOS installer, uninstaller, double-click wrappers and the pure config/plist helpers |
| `scripts/build-release.mjs` | builds the self-contained macOS zip (`npm run build:release`) |
| `skills/figma-local-bridge/SKILL.md` | the installable agent skill: a short playbook with frontmatter, safe to copy into `~/.claude/skills/figma-local-bridge/` |
| `skills/figma-local-bridge/GUIDE.md` | the full operating guide, served both as MCP `instructions` and as the `figma-local://guide/skill` resource |
| `scripts/mascot.py` | the ASCII octopus the panel ships: the art lives on the panel's own 21x10 grid, and its JS block is emitted from here, never hand-edited |
| `scripts/mcp-smoke.mjs` | `npm run smoke`: build smoke test with no Figma and no daemon |
| `llms.txt` | short plain-text project summary |
| `test/` | unit tests (`node --test`) and e2e tests against a real daemon / WebSocket / MCP client |
| `scripts/check-privacy.mjs` | local gate for home paths, real e-mail addresses, credentials, HAR captures and company/project names |
### Repository privacy
This is a public repository; names from private client projects must not enter
it, even as “just an example”. `npm run check:privacy` scans every tracked file
and matches a configured term across spaces, hyphens, underscores and camelCase,
so an identifier such as `projectName` is caught as well as the prose form. A
finding reports the file and line, never the term itself. The optional
pre-commit hook scans the exact staged bytes, so a secret or a project name
cannot slip in after the last manual check:
```sh
npm run check:privacy # tracked files
npm run hooks:install # git config core.hooksPath .githooks
```
The denylist is intentionally **outside** the repository: put one client or
project name per line in `~/.config/figma-local-bridge/privacy-denylist.txt`
(override with `PRIVACY_DENYLIST_FILE` or pass comma-separated terms in
`PRIVACY_DENYLIST`). A scanner that contained the names would itself leak them.
Home paths, real e-mail addresses, private keys and common token shapes are
always checked; test placeholders such as `example.com`, `/Users/example` and
`/Users/me` are allowlisted. `.gitignore` also excludes local secrets, HARs and
`.privacy-denylist`.
| `spike/` | the first prototype, kept for history |
## Easy install (macOS, 0.35.1)
The release zip is self-contained: it already contains `server/dist`, `plugin/code.js` and the production `node_modules`, so the user does not run `npm install` or `npm run build`.
1. Build the zip from a checkout with `npm run build:release` (or use a release artifact attached by the maintainer). The archive is `release/figma-local-bridge-<version>-macos.zip`.
2. Unzip it and double-click **`Install.command`**. If macOS Gatekeeper blocks an unsigned download, use **right-click → Open** once. The installer is unsigned in `0.35.1`; signing and notarization are a later step.
3. The installer checks Node.js 20+, copies the app to `~/Library/Application Support/Figma Local Bridge/app`, installs a per-user LaunchAgent (`com.miart.figma-local-bridge`), backs up and updates the MCP configs it finds for OpenCode, Claude Code, Claude Desktop and Cursor, and copies the daemon token to the clipboard. It never prints the token.
4. In Figma, do the two things Figma exposes no API for: **Plugins → Development → Import plugin from manifest…** and choose `~/Library/Application Support/Figma Local Bridge/app/plugin/manifest.json`; then run **Figma Local Bridge** and paste the token once.
5. Restart the MCP client. `figma_ping` should answer.
The installer does not create a Figma account, install a Figma development plugin, or bypass Gatekeeper. To remove the app, LaunchAgent and this bridge's MCP entries, double-click **`Uninstall.command`** (config backups and daemon data are kept; add `--purge` from the command line to remove the token too).
`npm run setup` runs the installer from a built source checkout. The lower-level manual setup remains below.
## Quick start (from source)
Requirements: **Node.js 20+** (developed on v25) and the **Figma Desktop app**. Developed on macOS; the Node side is cross-platform.
```sh
git clone git@github.com:Miart/figma-local-bridge.git
cd figma-local-bridge
npm install
npm run build
npm run daemon # keep it running; log: /tmp/figma-bridge-daemon.log
```
1. **Import the plugin** (once): in Figma → *Plugins → Development → Import plugin from manifest…* → `<repo>/plugin/manifest.json`. (`npm run build` must have run first — `plugin/code.js` is generated and not committed.)
2. **Run the plugin**: *Plugins → Development → Figma Local Bridge*.
3. The panel asks for the daemon token — copy it from `~/.local/share/opencode/figma-bridge-daemon.json` and paste it **once**. It is kept in `figma.clientStorage`, and the plugin reconnects by itself on every launch. The port is fixed by `plugin/manifest.json` (`networkAccess.devAllowedDomains`) — the panel cannot change it.
4. **Point your MCP client** at `server/dist/index.js` (stdio). Example (opencode):
```jsonc
"figmaBridge": {
"type": "local",
"command": ["node", "/path/to/figma-local-bridge/server/dist/index.js"],
"environment": {
"FIGMA_BRIDGE_PORT": "8787",
"FIGMA_BRIDGE_WRITE": "1",
"FIGMA_BRIDGE_DESTRUCTIVE": "0"
}
}
```
Clients that use the common `mcpServers` shape (Claude Code/Desktop, Cursor, Gemini CLI and other local-MCP clients) can use the same server command:
```json
{
"mcpServers": {
"figma-local-bridge": {
"command": "node",
"args": ["/path/to/figma-local-bridge/server/dist/index.js"],
"env": {
"FIGMA_BRIDGE_PORT": "8787",
"FIGMA_BRIDGE_WRITE": "1",
"FIGMA_BRIDGE_DESTRUCTIVE": "0"
}
}
}
}
```
Without the plugin running, the MCP server still starts and every tool answers `FIGMA_BRIDGE_OFFLINE` — fast, with a precise reason, instead of hanging for 30–115 seconds.
### Configuration
| Variable | Default | Meaning |
|---|---|---|
| `FIGMA_BRIDGE_PORT` | from `plugin/manifest.json` (`8787`) | Daemon port |
| `FIGMA_BRIDGE_HOSTS` | `127.0.0.1,::1` | Interfaces to listen on |
| `FIGMA_BRIDGE_INFO_FILE` | `~/.local/share/opencode/figma-bridge-daemon.json` | State file: pid, port, `clientKey`, plugin token |
| `FIGMA_BRIDGE_TOKEN_FILE` | `~/.local/share/opencode/figma-bridge-daemon.secret` | Persistent plugin token (`0600`) |
| `FIGMA_BRIDGE_WRITE` | `1` | `0` — read-only: mutation tools are not even registered |
| `FIGMA_BRIDGE_DESTRUCTIVE` | `0` | `1` — allow `figma_use` (raw Plugin API snippets) and a real `figma_checkpoint` restore |
| `FIGMA_BRIDGE_CHECKPOINTS_DIR` | `~/.local/share/opencode/figma-bridge-checkpoints` | Where `figma_checkpoint` keeps its files, one directory per `fileKey` |
| `FIGMA_BRIDGE_REVIEWS_DIR` | `~/.local/share/opencode/figma-bridge-reviews` | Where `figma_review` keeps iteration baselines (PNG + JSON), one directory per `fileKey` |
| `FIGMA_BRIDGE_JOURNAL_DIR` | `~/.local/share/opencode/figma-bridge-journal` | Where the journal keeps `ops.jsonl` (plus two rotated segments) |
| `FIGMA_BRIDGE_JOURNAL` | `1` | `0` — stop recording new journal lines; what is already on disk stays readable |
| `FIGMA_CLOUD` | unset | `1` — enable the five optional cloud tools; `0`/unset leaves them out |
| `FIGMA_CLOUD_TOKEN` / `FIGMA_CLOUD_OAUTH_TOKEN` | unset | PAT (`X-Figma-Token`) or OAuth (`Authorization: Bearer`); set exactly one. Also requires `FIGMA_CLOUD=1` |
Don't run a second bridge instance: the port is fixed, so the second daemon exits with `EADDRINUSE` (see Troubleshooting).
### Several files at once
Run the plugin in each Figma file you want to drive — every panel connects to the same daemon, which holds one socket per file, keyed by `fileKey` (a file that was never saved gets a session key like `unsaved-a1b2c3d4`, which it keeps for as long as the document is open, panel reloads included). Up to **16 files**; a panel beyond that is refused with `4413` and says so.
- `figma_ping` lists the connected files (`fileKey` + name) — it is the tool to call when you do not know which files are open.
- Every local tool takes an optional `fileKey`. With **one** file connected it may be omitted; with **several**, a call without `fileKey` is **refused before anything is sent**, listing the candidates. Guessing here would edit the wrong file. The optional cloud tools use a required `fileKey` naming the REST file to read.
- The daemon routes by file and answers an unroutable request with an error immediately — never a timeout.
- One file disconnecting fails only its own in-flight calls; the others keep working. Re-opening the plugin in the same file replaces its old socket (old panel gets `4409`).
### What the plugin panel shows
| Row | Meaning |
|---|---|
| mascot | decorative only: an ASCII octopus whose mood follows the link — asleep while offline, shuffling while connecting, blinking when idle, winking when you click the canvas, upset after a failed call. It reacts (hop / shake) to what happened, and answers a tap on itself with a hop and a beaming face; the status line below always carries the same facts in words. `hide pet` / `show pet` in the footer switches it off for good (stored per user in `figma.clientStorage`) |
| window height | the panel measures what it rendered and asks Figma to fit the window to it, so hiding the mascot (~120px) or showing the error line (~20px) never leaves dead space behind. If Figma drops the resize, the panel re-asks on its own until the window matches the content |
| status | `connected` / `offline — retrying in Ns` / `token rejected` / `another panel is serving this file — press reconnect to take over` / `all 16 file slots are taken — retrying in Ns` |
| chips: `page`, selection | what is open and selected right now — pushed by the plugin, so it stays live even without a connection; hover a chip for the full list |
| chip: `mcp` | how many MCP sessions are attached (0 = no client is bound) |
| chip: calls | how many calls went through; turns red after an error |
| error line | the last call error (full text in the tooltip) |
## Tools
17 tools mirroring Figma's official server, plus 33 local ones — 50 in total. An optional read-only REST adapter adds five more when `FIGMA_CLOUD=1` and a token are set, for a surface of 55.
Every local tool also accepts an optional `fileKey` to choose among several open Figma files; with one file it can be omitted, with several a call that omits it is refused and lists the connected files (`figma_ping` shows the same list). Cloud tools instead take a required `fileKey` naming the REST file to read.
The server also ships an **operating guide** — `fileKey` routing, what `rev` and `replayed` mean, which failure may be retried, what `figma_use` rolls back. It travels as `instructions` on the MCP handshake *and* as the resource `figma-local://guide/skill`, so a client that ignores handshake instructions can still fetch it. Both are read at startup from `skills/figma-local-bridge/GUIDE.md`, which is the file to edit (a `server/dist` copied without the repo falls back to a short inline version). `skills/figma-local-bridge/SKILL.md` is a short, frontmatter-bearing agent skill for clients that load skills from a directory; copy that folder into `~/.claude/skills/figma-local-bridge/` (or the equivalent for your client).
**Parity with the official Figma MCP server:**
| Tool | What it does |
|---|---|
| `figma_whoami` | User, open file, pages, viewport |
| `figma_get_metadata` | Sparse XML skeleton of a node — cheap, use it to find ids |
| `figma_get_design_context` | Full design description of a node for implementing it in code |
| `figma_get_screenshot` | PNG / JPG / SVG of a node (the caption carries `rev`) |
| `figma_get_variable_defs` | Variables used in a subtree |
| `figma_get_libraries` | Variable collections available from libraries |
| `figma_search_design_system` | Search components, styles, variables |
| `figma_get_figjam` | FigJam board: stickies, shapes, connectors, sections |
| `figma_get_code_connect_map` / `figma_add_code_connect_map` | Local Code Connect mappings |
| `figma_download_assets` / `figma_upload_assets` | Export / import assets |
| `figma_set_page` | Switch the active page |
| `figma_set_selection` | Select nodes **and pan the viewport to them** — the only way to move the user's cursor |
| `figma_get_font_inventory` | Fonts actually used by the file, with per-text-node counts |
| `figma_list_file_components_for_code_connect` | Inventory of every component in the file |
| `figma_use` | Arbitrary Plugin API snippet under scope protection; the answer carries `rev`, `replayed`, `ms` and `nodes`. A third argument, `flb`, is a snippet standard library for this manifest's traps — `byId` (`getNodeById` throws in dynamic-page mode), `find`/`one`, `bbox` (absolute layout or render bounds), `clear`, `flatten` (detach every instance), `grid` (uniform atlas cells at absolute coordinates), `text` (loads the font first) — and its write helpers refuse an out-of-scope or other-page target *before* writing |
**Local additions:**
| Tool | What it does |
|---|---|
| `figma_ping` | Health check and capability report: verifies the plugin is paired, reports the active page and connected files (`fileKey` + name), and returns the local mode, whether the optional read-only REST adapter is enabled, Motion (Beta) availability and playhead, and the Figma-hosted features even that adapter does not proxy |
| `figma_get_selection` | Current selection, answered from memory — the plugin pushes selection changes, so this costs no round trip |
| `figma_get_layout_tree` | Absolute geometry of a whole subtree in one call: per node `absoluteTransform` (2×3 matrix), absolute layout and render bounds, clip flag. Document-absolute space — the one a PNG export uses — so a rebuild (atlas, sprite sheet) can be checked against a capture without one `get_node` per layer. Geometry and screenshot stay separate calls (`atomicWithScreenshot: false`): snapshot, then capture, then compare `rev` — and `figma_compare` for the pixels themselves |
| `figma_inspect` | Compact structure + semantic fingerprint; `compareWith` returns `EQUIVALENT` / `DIFFERENT` / `INCONCLUSIVE` *before* you mutate. `includeFingerprint:false` skips the whole-subtree snapshot when only structure is needed (a comparison still computes it) |
| `figma_build` | Declarative, keyed UI tree in one transaction. Validates the spec and resolves fonts, styles, variables and components **before** writing; every node gets a stable key in `pluginData`, so re-sending the same `buildId` updates instead of duplicating. `dryRun` defaults to true and returns a real plan plus a scope fingerprint; apply with `dryRun:false` and that `planId`/fingerprint. Carries rotation, per-corner radii, stroke cap/join/miter, masks, layout grids, layout inclusion flags, text case/decoration/paragraph spacing, text hyperlinks (URL, or NODE by id), text runs (ordered, non-overlapping character ranges with their own font, size, text style, fills, fill style, variable bindings, case, decoration, line height, letter spacing and hyperlink), a whole-text `textVariable` for base bindings, an exact `LINE` length, SVG leaves (a frame created from a source string, updated in place by a repeat build), PATTERN paints (a file-local source node id plus tile type, scaling factor, spacing and horizontal alignment), SHADER paints (a shader id plus optional property assignments keyed by definition id; an unimported fill shader is imported inside the write transaction), BOOLEAN_OPERATION nodes (two or more operands in spec order, `UNION` / `SUBTRACT` / `INTERSECT` / `EXCLUDE`; changing the operation updates the same node and the size is derived from the operands), and INSTANCE `overrides` addressed by layer name/key or a structural `path` (text, font, text style, text formatting, hyperlink, fills/strokes and their styles, effects and effect style, visibility, opacity, blend, rotation, corners, stroke weight, size and name). Refuses type changes, component swaps, unknown keys, unsupported containers, and implicit auto-layout positioning |
| `figma_patch` | Sparse update of an existing keyed tree: `create` / `update` / `delete` counts from a dry run, a scope fingerprint, and one undo transaction on apply. A missing key is refused unless `allowCreate`; a key under the wrong parent is refused rather than duplicated; `prune:true` deletes omitted keyed children at each patched level. An SVG leaf keeps its frame id and key when its source changes; the interior is replaced in place. A BOOLEAN_OPERATION patch may list only the operands it changes when `prune` is false; pruning a boolean requires at least two operands in the patch, and a sparse patch keeps the existing order and appends a new operand (send the complete ordered list with `prune:true` to enforce order) |
| `figma_batch_edit` | Many small edits on many targets in **one** transaction. Ops: `set_text` (including a text hyperlink; per-run styling is a `figma_build`/`figma_patch` feature), `set_name`/`rename`, `set_fill`, `set_stroke`, `resize`, `move`, `set_visible`, `set_opacity`, `set_rotation`, `set_layout`, `set_variant`, `set_constraints`, `duplicate`, `reparent`. Targets are `nodeId` or stable `key`; `duplicate` and `reparent` accept a destination `parentId` or `parentKey`, resolved on the current page. Every target, destination, font, style and variant property is resolved before the first write; `dryRun` defaults to true and returns per-target fingerprints **and a fingerprint for each non-page destination**, so a changed parent refuses the move. A `reparent.index` is the final child index and omitted appends; a same-parent reorder keeps `layoutPositioning`, an auto-layout destination uses `ABSOLUTE` when preserving the canvas position and `AUTO` when `preserveAbsolute:false`. A destination inside an `INSTANCE` or a `COMPONENT_SET` is refused; a destination inside a component definition is named as an edit to the definition. A failed op rolls the whole batch back unless `stopOnError:false`, which reports exactly which op failed and what succeeded |
| `figma_swap_component` | Change an INSTANCE's main component in one transaction without losing the overrides that still fit. The dry run (default) compares the old and new structures and returns, per target: a fingerprint of the instance and the target component, an exact or unique-name remapped path for every direct layer override, component properties mapped by display name and type, root-level instance overrides (custom name, resized geometry, opacity, paints/strokes/effects and style links, layout placement), every named loss and every remapped path. Apply needs the dry-run `planId` and `expectedFingerprints`; a changed target, a lossy swap without `allowLoss`, or a library component whose structure was never checked without `allowUnverified` is refused before the first write. A changed node type, missing layer, changed nested component, dropped/renamed property and `SLOT` property are reported, never applied to a guess. Variant properties are chosen by the target component, not replayed. A target inside another `INSTANCE` is refused; an apply is one undo step, and an unexpected loss rolls the whole transaction back. |
| `figma_variant_set` | Author a `COMPONENT_SET` as a property matrix in one transaction. `action:"combine"` turns standalone `COMPONENT`s into a new set and replaces their names with the requested `Property=Value` combination; `add` clones an existing variant with a new combination; `edit` renames one variant in place. The dry run (default) requires the same property names across the matrix and refuses a duplicate combination, a component already in a set, a component inside an `INSTANCE`, or a destination inside an `INSTANCE` / another set / a `COMPONENT` definition. It returns per-component fingerprints, a fingerprint of the set and, for a combine, the destination layout. Apply with the dry-run `planId` and `expectedFingerprints`; after writing it reads `variantProperties` and `variantGroupProperties` back from Figma and rolls the transaction back if the names were not parsed into the matrix. A combine destination inside a `COMPONENT` is refused because Figma will not reparent a component into a component definition; `add` and `edit` still operate on an existing set. A combine appends the set (move it afterwards with `figma_batch_edit` if needed) |
| `figma_audit` | Read-only design-system audit (`audit-v2`): duplicate/stable-key problems, nodes added to a built tree without a key, literal fills/strokes that are neither a paint style nor a variable, text without a text style, placeholder copy, detached instances, WCAG AA contrast against the effective background, and default layer names, plus opt-in off-scale spacing/radius, empty text and literal effects. Findings carry `nodeId`, key, name, type, path, severity and the value that caused them. With `suggest:true` (default) a hardcoded colour also names exact local paint-style and colour-variable candidates, unstyled text names exact local text-style candidates, and spacing/radius findings name the nearest on-scale value; nothing is bound or edited |
| `figma_index` | Compact flat index of a page/subtree in document order: id, stable key, name, type, path, depth, visibility, size, child count and the build root. Read-only, capped, and explicit when it truncated |
| `figma_search` | Find nodes by name, stable key or text without fetching the whole tree: substring/regex/glob, node-type and key-prefix filters, `hasKey`, hidden-layer control, match fields and text snippets. Read-only, capped, and explicit about truncated results |
| `figma_design_system` | One comparable snapshot of the local design system: paint/text/effect/grid styles, variable collections with modes and per-mode values, and components/component sets with their property definitions. `inventory` returns it; `export` writes JSON with a sha256; `diff` compares that file with the live file by **stable key** and reports added/removed/changed. Read-only — it never creates or edits a style, variable or component |
| `figma_tokens` | Design-token sync for **variables**. `export` writes a `figma-tokens/v1` document with every collection, mode, per-mode value, description and alias; `diff` compares that document with the live file; `import` (dry run by default) creates missing collections/modes/variables, updates values and descriptions, and reads every mode back after writing. A plain DTCG tree is accepted on import with `collection`/`mode`, including `color`, `number`, `dimension`, `string`, `boolean`, `fontFamily` and `fontWeight`; unsupported DTCG types are blocking findings, never silently dropped. The dry run returns a file-and-live-state fingerprint and the server returns a `planId` over the exact file bytes, target file and options; apply requires both, so a changed file or a changed document is refused before writing. `prune` is off by default, needs `allowDestructive:true` on apply, and deletes only variables in the named collections; an alias pointing at a pruned variable blocks the plan. Modes absent from the document are preserved for existing variables and filled from the default managed mode for new variables, and the plan says so. One undo transaction; if the read-back disagrees, the import is rolled back (or `ROLLBACK FAILED` is named). |
| `figma_motion_styles` | Read-only Figma Motion (Beta) catalogue: `figma.motion.figmaAnimationStyles()` plus local custom animation styles and the current playhead. Missing Motion support is `MOTION_UNAVAILABLE`, not an empty list. |
| `figma_motion_inspect` | Read-only Motion state of one node: applied animation styles, manual keyframe tracks, computed animations, timelines and the node-level methods this build exposes. Use its ids and track shapes as the inputs to the write tools. |
| `figma_motion_apply_style` | Apply a Figma or custom animation style. `dryRun` defaults to true and returns a `planId` over the operation and the node's live Motion state; apply reads the applied style id, duration, offset and props back and rolls back on a mismatch. Custom styles accept only `timelineOffset`. |
| `figma_motion_remove_style` | Remove one applied style by its applied instance id. Dry run by default; an absent id is a verified no-op. Apply verifies the id is gone and rolls back otherwise. |
| `figma_motion_set_keyframes` | Set one manual keyframe track: a property (`OPACITY`, `TRANSLATION_X`, `SCALE_XY`, …), a paint/stroke, or an indexed effect field. Positions are seconds, unique, and all values in a track share a type. Dry run by default; apply verifies every keyframe position, value and easing. |
| `figma_motion_remove_keyframes` | Remove the manual track on one field. Dry run by default and reports the current track; an absent track is a verified no-op. Apply verifies the field no longer has a manual track. |
| `figma_motion_set_duration` | Set a Motion timeline duration in seconds from a `timelineId` returned by `figma_motion_inspect`. Dry run by default; apply reads the same timeline back and rolls back if the duration differs or the timeline disappears. |
| `figma_export_build_spec` | The reverse of `figma_build`: a live subtree as a build spec. Existing keys are kept; unkeyed nodes get the key the build engine would derive. Style and variable bindings become `fillStyle`/`strokeStyle`/`textStyle`/`effectStyle`, `variable` or `textVariable`, with literal values as the fallback. URL text hyperlinks travel in the spec; a NODE link is exported with a file-local warning. Mixed text becomes a base style plus ordered `runs`, so mixed fonts, sizes, text/fill style links, text variable bindings, case, decoration, spacing and links survive a rebuild. An IMAGE or VIDEO paint carries its crop transform, tile scaling, rotation and filters (a video hash is file-local, like an image hash); a PATTERN paint carries its source node id, tile type, scaling factor, spacing and horizontal alignment (the source node is file-local); a SHADER paint carries its shader id and property assignments keyed by definition id, including variable aliases, which must resolve in the target file (the shader must be available in the target file and is imported on apply); every supported paint carries `visible` and `blendMode`, including an invisible paint that stays in the stack. Direct instance overrides become `overrides` entries with a structural path built from the main component's layer names (so a renamed layer still resolves) plus the fields a rebuild can express; an override field the build cannot carry is named. A BOOLEAN_OPERATION exports its operation and live operands (not a flattened SVG), with its derived size omitted. A node the build cannot express — an unsupported type, a frame created from SVG, a detached instance — is exported as an inline SVG render when one fits (`svgFallback`, on by default) that rebuilds as a flattened frame: appearance survives, but the original child layers, component identity, overrides and style/variable links do not, text becomes outlines, and every fallback is named. Slices are never flattened; a render that fails or exceeds its budget is listed under `unsupported`, and with `svgFallback:false` all such nodes are listed there instead. The reply also names its remaining losses — unsupported instance override fields, unrepresentable paint types, mixed values — and marks a capped tree `truncated` rather than returning a partial spec that looks complete. Read-only |
| `figma_compare` | Diff a live render against a PNG on disk — a mockup, an earlier export, or another node written out with `figma_download_assets`. The diff runs in the MCP server, so the document is never touched: it answers with the differing area, its bounding box **in the reference's own pixels** (top-left origin — the box can be cropped straight out of that PNG) and the largest clusters, and writes a heatmap PNG next to the reference. `tolerance` absorbs anti-aliasing; a size mismatch is reported rather than hidden, and a clean 2×/3×/4× ratio names the `scale` you meant |
| `figma_review` | The iteration harness for one subtree. `save` writes a local baseline — PNG, the full audit report and the semantic fingerprint — under a review id; `compare` re-renders, re-audits and re-inspects the live node, diffs the baseline pixels, and reports **new / resolved / changed** findings, severity deltas, size and fingerprint changes, a heatmap and a verdict: `unchanged`, `changed`, `improved` or `regressed` (a new error/warning or a size change). The audit rules, hidden-layer setting and scale are reused from the baseline, so a delta means the same thing across iterations; `latest`, `list` and `drop` manage the local files. Read-only for the document — only local review files are written. If the document changes while a capture is running, the call refuses instead of comparing two revisions, and a truncated audit says its delta may be incomplete |
| `figma_replace_repeated_with_component` | Replaces repeated identical nodes with instances of one master component, in a single transactional call. Refuses unless every target is provably equivalent; try `dryRun: true` first |
| `figma_checkpoint` | Undo, without an undo stack: `save` describes a subtree as data (one JSON file per file, under `~/.local/share/opencode/figma-bridge-checkpoints/<fileKey>/`), `show` diffs the live subtree against it field by field, `restore` replaces the target's children from it in **one** transaction — dry run by default — and then dumps the subtree again to *verify*: `verified: true` only when that second diff is empty. It re-links fill/stroke/effect/text style ids, node-level and run-level variable bindings and explicit variable modes when they still exist, reporting each one that does not; paints and fonts are still restored by value. Text runs come back with their own fonts, sizes, fills, text/fill style links, variable bindings, case, decoration, line height, letter spacing and hyperlinks; STAR and POLYGON are rebuilt natively with their point count (and STAR's inner radius) before the final size is applied; a BOOLEAN_OPERATION tree is rebuilt from its children with the recorded operation, including nested operations, and one with fewer than two rebuildable children is named and skipped; a SLICE comes back with its geometry and `exportSettings`; a COMPONENT_SET is rebuilt by recreating its variant COMPONENTs, combining them with `figma.combineAsVariants`, adding back its non-variant component properties by type and default value, restoring their descriptions, preferred values, slot settings and definition-level default-value variable bindings where the property type and a live variable allow it, and remapping child-layer `componentPropertyReferences` to the rebuilt property names; the `#id` suffix is Figma-assigned and cannot be restored, so the verification compares component-property definitions and references by display name. Direct instance overrides travel as structural paths plus a layer snapshot and are replayed after the main component is re-instantiated; a text override with mixed runs is replayed range by range (fonts, sizes, fills, text/fill style links, variable bindings, case, decoration, spacing and hyperlinks), not just at the base level, and a range link or binding the override removed is cleared before the snapshot is replayed. A path that no longer matches is named and skipped, and an override field this version cannot rebuild is named in `overridesUnsupported`. Refuses a truncated dump, a `GROUP`/`INSTANCE`/page target, and unsupported node types unless `allowPartial`; a save lists what a rebuild cannot carry (an INSTANCE's interior structure beyond its direct overrides, component and component-set identities/instances) so the losses travel with the data |
| `figma_ops` | What the plugin is doing per file: the call that is `running`, the writes `queued` behind it, and what finished `recently` — plus, for this session, the calls it is still waiting for and the ones it gave up on. With `opId` (a timeout error names it) it adds the answer recorded for that operation: that is how a call that timed out is settled without re-reading the document. Read-only, and answers even when the plugin is gone (with the local half and the reason) |
| `figma_cancel` | Stop an operation whose state is `queued` — a write whose caller has already given up, still waiting behind another write. Its caller is answered at once with `OP_CANCELLED` and `rev` does not move, because nothing of it ran; a retry of the same `opId` replays the cancellation instead of starting the call. `OP_RUNNING` (Figma runs plugin code to completion — nothing can interrupt it), `OP_DONE` (it already ran; the message names the revision) and `OP_UNKNOWN` are refusals, not failures. Read-only: cancelling only ever prevents a write |
| `figma_journal` | The durable half of `figma_ops`: the tail of a local append-only JSONL file — every call the bridge sent to a plugin, with method, target, revision and outcome, and the ones that did not end cleanly (`timeout`, `dropped`, `late`). Survives bridge and plugin restarts, which is exactly where `figma_ops` stops helping; filters by `fileKey` and `method`, and `unsettled: true` returns only the may-or-may-not trail. Read-only and entirely local; snippets, text and images are never recorded |
| `figma_export_ui_atlas` | A sprite sheet for a game build: **one** PNG plus a JSON manifest whose rects are in *that file's* pixels. The sheet frame's direct children are the sprites — nothing is repacked, the layout stays the designer's — and the manifest names what would make the sheet unsliceable: overlapping rects, sprites outside the image, drawing that bleeds out of its own cell, repeated names (keyed `coin#1`, `coin#2`), coordinates that had to be rounded, and a PNG whose size is not `round(sheet × scale)` (the rects follow the file, not the prediction). The file is re-read from disk before the answer, so the manifest cannot describe an image nobody wrote |
| `figma_export_9slice` | One sprite with its 9-slice insets: a PNG plus a descriptor — insets in the file's pixels, the stretchable middle, and CSS `border-image` values with slices in image pixels and widths in design pixels (which is what a `@2x` image needs). Pass `insets`, or leave a child layer named exactly `9slice` where the middle should stretch. Insets that leave no middle are refused with the numbers that caused it |
| `figma_export_ui_states` | The states of a button/component as one file per state, plus a manifest naming them. A `COMPONENT_SET` names its variants by property — the only one it declares, or the one you pass — and a frame names them by layer name; a set with several properties is refused until you say which one names the states. States whose **bytes are identical** are called out (a hover that draws exactly like normal is usually a variant that is not what its name says), and every file is re-read from disk: its size checked against the node, its hash recorded |
| `figma_export_web_handoff` | One folder for a web developer: `preview.png` (1x) plus `preview@Nx.png`, `spec.json` (flat, document order — every layer's box relative to the frame, text, paints, effects and layout fields), `tokens.json` and `tokens.css` (solid colours named by their paint styles when one value has one style, fonts, text classes, spacing), a `README.md` stating what the bundle is **not**, and `handoff.json` with hashes, counts and warnings. A colour bound to several styles, a gradient, an image fill or a mixed text layer is never averaged into an invented value: it is named as missing, or carried as segments/stops. One call writes one folder, refuses to reuse it for another node, and re-reads every file from disk. Read-only — it works with writes off; above 2,500 nodes, 24 MP per preview or 8 MiB per PNG it refuses instead of truncating. Font availability is checked within a time budget: a font the check could not confirm in time is reported as **unchecked**, not assumed present, and the reply splits where the time went — `walkMs` reading the tree, `checkMs` pinging fonts, `exportMs` rasterising |
**Optional cloud tools (enabled only by `FIGMA_CLOUD=1` plus a token):**
| Tool | What it does |
|---|---|
| `figma_cloud_ping` | Validate `FIGMA_CLOUD_TOKEN` (PAT) or `FIGMA_CLOUD_OAUTH_TOKEN` (OAuth) against `GET /v1/me`; returns the account and token type, never the token. Needs `current_user:read` |
| `figma_cloud_get_file` | Read a file's JSON with optional `nodeIds`, `depth`, `version`, `geometry` and `pluginData`. Use ids/depth to keep it small; a reply over `maxBytes` is refused, never truncated |
| `figma_cloud_get_nodes` | Read specific nodes from a file by `nodeIds` with the same depth/geometry/plugin-data controls |
| `figma_cloud_get_image` | Get temporary render URLs for nodes (PNG/JPG/SVG/PDF) with scale and SVG options. It returns URLs, not downloaded bytes; Figma's URLs expire |
| `figma_cloud_download_image` | Download nodes as PNG/JPG/SVG/PDF **files** to `outDir` (default: a temp directory) with `figma-<nodeId>.<ext>` names. One call calls `/v1/images`, then fetches each signed URL without the Figma token, verifies the file signature, caps each file and the total, and refuses to overwrite unless `overwrite:true`. The Figma document is untouched; this tool writes only local files |
### Cloud, when explicitly enabled
The cloud adapter is a separate mode that is **read-only with respect to Figma**. It uses documented Figma REST `GET` endpoints only; there is no Figma write path and no attempt to reproduce Figma's hosted AI, file creation, shaders, generated plugins or Weave. `figma_cloud_download_image` writes rendered bytes to a local directory, never to the Figma document.
| Setting | Meaning |
|---|---|
| `FIGMA_CLOUD=1` | Explicit opt-in. `0` or an unset value leaves the five cloud tools unregistered |
| `FIGMA_CLOUD_TOKEN` | Personal access token, sent as `X-Figma-Token`. Needs `file_content:read`; `figma_cloud_ping` also needs `current_user:read` |
| `FIGMA_CLOUD_OAUTH_TOKEN` | OAuth access token, sent as `Authorization: Bearer`. Set only one credential |
| `FIGMA_CLOUD_TIMEOUT_MS` | Request timeout, 1000–120000 ms (default 30000) |
| `FIGMA_CLOUD_API_BASE` | Defaults to `https://api.figma.com`; a custom base must be HTTPS |
| `FIGMA_CLOUD_INSECURE=1` | Allows plain HTTP **only** for `127.0.0.1`/`localhost` test servers; never use it with a real token |
The adapter never puts a token in a URL, a log line or a tool reply. It follows no redirects (the credential cannot be forwarded to another host), refuses a response over `maxBytes` (default 8 MB for JSON) instead of serialising a huge document, and maps failures to `CLOUD_UNAUTHORIZED`, `CLOUD_FORBIDDEN`, `CLOUD_NOT_FOUND`, `CLOUD_BAD_REQUEST`, `CLOUD_RATE_LIMITED` (with `retryAfterSeconds`), `CLOUD_UPSTREAM`, `CLOUD_TIMEOUT`, `CLOUD_UNREACHABLE`, `CLOUD_RESPONSE_TOO_LARGE` and `CLOUD_BAD_RESPONSE`. Image downloads add `CLOUD_DOWNLOAD_BLOCKED` (non-HTTPS URL, credentials in the URL, or a redirect), `CLOUD_BAD_IMAGE` (bytes do not match the requested format) and `CLOUD_DOWNLOAD_FAILED` (local path or write failure). The download tool caps each image (default 10 MB) and the whole call (default 50 MB), and never sends the Figma token to the image host. It does not run an OAuth authorization flow or refresh an expired token, and it does not retry automatically; a 429 is the caller's decision. Cloud tools work without the local daemon and are registered in read-only mode too.
Cloud-only in the official server, and not produced by this bridge in either mode: `create_new_file`, `generate_figma_design`, `generate_diagram`, `generate_image`, `html_to_figma`, `send_code_to_figma`, generative plugins, shaders, and Weave. They depend on Figma-hosted services; the bridge reports them as unavailable instead of simulating them. The official server also marks some read tools as remote-only — `download_assets`, `get_libraries`, `whoami`, `search_design_system` — but those are answered here from the open file's Plugin API and are not cloud features.
## Safety model
The bridge edits real design files with an agent at the wheel, so most of the engineering went into making failure modes *visible* instead of silent:
- **Plan, then apply.** `figma_build`, `figma_patch` and `figma_batch_edit` default to `dryRun:true`. The dry run resolves every resource and returns a content `planId` plus a fingerprint; an apply with a different `planId` is refused **before it reaches the plugin**, and an apply whose targets moved since the dry run is refused before the first write. The safe workflow is one call to plan, one call to apply — not a hopeful write.
- **Stable keys, not node ids.** A built node carries its key in `pluginData`; a re-sent build finds and updates it even after Figma re-creates the node. Keys are unique per page, duplicate keys are refused, and a key under the wrong parent is a refusal rather than a second node with the same identity. A clone is a new subtree: its descendants are rekeyed under `newKey` instead of inheriting colliding keys. A batch move resolves **both** ends: the target by key and, when given, the destination by `parentKey`. The dry run fingerprints the non-page destination too, so a parent whose layout or children changed since the review refuses the move instead of landing it in a different structure.
- **A batch has one undo boundary.** `figma_batch_edit` resolves every target, destination, font, style and variant property before the baseline, then applies the ops in order. A failed op rolls the entire batch back (or, with `stopOnError:false`, stops pretending: it reports the exact op that failed and what succeeded).
- **A component swap is a migration, not a relink.** `figma_swap_component` records the instance's direct layer overrides and root-level state before writing, compares the old and new component structures in the dry run, and names every layer and component property it cannot carry. Apply requires the `planId` and per-target fingerprints that cover the instance and the target component; a lossy swap needs `allowLoss`, a library import needs `allowUnverified`, a target inside another `INSTANCE` is refused, and an unexpected loss during apply rolls the whole transaction back. The report keeps predicted losses separate from restored overrides, so `swapComponent`'s editor heuristics are never presented as a contract.
- **A variant set is a matrix, not a folder.** `figma_variant_set` validates every property name and combination before writing. Combine replaces standalone component names; add clones one variant; edit renames one variant. Apply fingerprints the components, the set and the destination, then reads `variantProperties` and `variantGroupProperties` back from Figma and rolls the transaction back if the matrix did not land. A tool that reported success by parsing its own names would prove nothing, so it never does. If Figma refuses the undo itself, the error says `ROLLBACK FAILED` instead of claiming a rollback.
- **A token import is a plan, not a paste.** `figma_tokens` exports every variable mode as data, diffs the document against the live file, and imports through the same dry-run boundary as the other write tools. The dry run hashes the file bytes and options into the `planId`, and the file identity plus the live variable state into the fingerprint; apply needs both, and a different file or a changed variable refuses the call before the first write. A value is never guessed: an unsupported DTCG type, a missing alias target, an alias type mismatch, an alias cycle, a variable whose type would have to change, or a prune target another variable still aliases is a blocking finding. Apply is one undo transaction; every collection, mode, description, literal and alias is read back from a fresh state, and a mismatch rolls the import back — if Figma refuses the undo, the error says `ROLLBACK FAILED` rather than claiming success.
- **An iteration is measured against a baseline.** `figma_review` saves the pixels, the audit findings and the semantic fingerprint *before* an edit, then re-captures all three after it. The delta is the review: new errors/warnings or a size change make the result `regressed`, resolved problems make it `improved`, and a pixel/structure change without either is a neutral `changed` — never a claim that the design is better. The baseline reuses its audit rules and scale, so an audit delta is not an artefact of different settings; a capture that straddles two revisions is refused, and a truncated audit says so. Review files are local; the document is never written.
- **The audit cannot edit.** `figma_audit` only reads; its findings are claims about the file, not a cleanup that might have changed it. The same is true of `figma_index`, `figma_search` and `figma_design_system`.
- **The cloud adapter can only read Figma.** The optional REST tools are registered only after an explicit `FIGMA_CLOUD=1` plus a token, call documented `GET` endpoints, carry their credential in a header (never a URL or reply), refuse redirects, cap the response size before serialising it, and keep `401`/`403`/`404`/`429` as their own error codes. There is no Figma write path and no fallback that pretends a hosted AI or file-creation service is local. `figma_cloud_download_image` is the one tool that writes locally; it verifies the signature and never sends the token to the image host.
- **Motion is a timeline, not a local animation.** `figma_motion_*` only calls the Figma Motion (Beta) API. When `figma.motion` or a node-level method is absent, the call fails with `MOTION_UNAVAILABLE`; the bridge never stores pluginData that imitates a style or keyframe, and never interpolates a preview on its own. A Motion write is the same plan/apply transaction as the rest: a dry run fingerprints the node's live styles, manual tracks and timelines, apply must present that `planId`, and the style/keyframe/duration read-back must match before the call is reported as applied.
- **A design-system diff is by key, not by name.** `figma_design_system` compares two exports by Figma's stable style/variable/component keys, and drops only the volatile node `id` from the value comparison. A style that keeps its name but changes its colour is a change; a node recreated with a new id is not.
- **Idempotent retries.** `opId` is minted once per logical call and reused by every attempt (`server/src/figma.ts`). The plugin keeps a bounded `opId → result` cache and, on a repeat, replays the recorded answer (`replayed: true`) without touching the document. A repeat arriving while the first attempt is still running **joins it** instead of running the snippet twice.
- **A key names one call.** The cache stores a fingerprint of the method and arguments it answered (`plugin/dispatch.ts`), so a *different* call sent under a used `opId` is refused (`OPID_REUSED`) instead of being handed the first call's answer — the one path that could report a write that never happened. Key order does not change a call, so a retry re-encoded by another JSON writer still replays.
- **Serialized mutations.** Figma's undo barrier is global — two mutations in flight roll each other back. Mutating methods are chained one at a time (`plugin/dispatch.ts`); reads stay parallel. `rev` bumps on success *and* failure.
- **Honest disconnects.** An error distinguishes `disconnected before <method> was sent` (safe to retry) from `may or may not have been applied` (retried under the same `opId`, so a replay cannot apply code twice).
- **An abandoned write does not land unseen.** Writes are chained, so a call can wait behind another write for longer than its caller is willing to wait — and when the timeout fires, the queued write would have run anyway, at a revision nobody was watching. Instead the call stays visible: `figma_ops` shows it `queued` (nothing of it has run), `running`, or finished *after* the caller gave up with the answer it returned; `figma_cancel` stops a `queued` one, answering its caller immediately with `OP_CANCELLED` and leaving `rev` untouched because the document did not move. A `running` call is refused loudly (`OP_RUNNING`) — Figma runs plugin code to completion, so any tool claiming to interrupt it would be lying.
- **The operations trail outlives the process.** `figma_ops` settles a timeout only while the client remembers the call and the plugin keeps its opId cache; a restart, a panel reload or a crash erases both. So every call the bridge *sends* is appended to a local JSONL journal — `send`, `ok`, `failed`, `timeout`, `retry`, `dropped`, `late` — with method, target, revision and outcome, and none of the payload (no snippets, text or images). `figma_journal` reads its tail and can show only the calls that did not end cleanly; a `send` with no terminal line names a call that was in flight when its process died, which is exactly the "may or may not have been applied" nothing else can answer after the fact.
- **Fast-fail offline.** A call waits one 2-second grace period (`LINK_GRACE_MS`) for a reconnecting plugin, then fails with a precise reason. A request that was never sent is not retried at all.
- **No guessing between files.** With several files connected, a call without `fileKey` is refused client-side *before* the request is sent — both the client (`server/src/figma.ts`) and the daemon (`server/src/link.ts`) check independently, and both name the connected files. A wrong file here is a wrong edit, so neither side ever "picks the most recent".
- **Scope guard + fingerprint diff.** `figma_use` runs against a scoped, fingerprint-checked node set and is rolled back if the document drifts outside the scope. Switching pages inside a call is refused (`currentpagechange`): one call per page, or `figma_set_page` first. The snippet's own helpers (`flb`) make the same judgement before a write: a target outside `scopeNodeId`, or on another page — which the one-page diff could not see — is refused at the line that caused it, not by the rollback at the end.
- **Timeouts don't roll back.** A `Promise.race` stops waiting, but the snippet keeps running and may still write — the message says exactly that instead of pretending "(rolled back)".
- **Stale screenshots are detectable.** Figma caches exports. `rev` travels in the caption, so identical bytes with a higher `rev` means the render is stale, not that the edit did nothing.
- **A fallback is named, not disguised.** `figma_export_build_spec` can turn an unsupported layer, a frame created from SVG or a detached instance into an inline SVG render, so the rebuild keeps its appearance instead of dropping it. The rebuilt node is a flattened frame, not the original type: the reply counts every fallback (`counts.svgFallbacks`, `counts.svgBytes`), names the node in `warnings`, and states that the original child layers, component identity, overrides and style/variable links are gone and text becomes outlines. The fallback budget is bounded per node and in total because `figma_build` refuses a spec over 256 KiB; a node over budget is listed as unsupported, never silently shrunk.
- **Exported artefacts describe the file that exists.** An atlas manifest that is one pixel off is worse than no manifest: the engine slices the neighbouring sprite and nothing in the build says why. So the plugin reads the PNG's IHDR and computes every rect in the file's own pixels (reporting a mismatch instead of assuming `round(size × scale)`), the server re-reads what landed on disk and compares it against the manifest, and the numbers that had to be rounded are counted and named. A frame with a stroke — where the image is the node's own box and not the bounding box the rects would otherwise be measured from — is detected from the exported size and reported rather than silently shifting every sprite. A web handoff follows the same rule one level up: its README and `handoff.json` name the things the bundle cannot carry — gradients as tokens, image fills as files, fonts as embedded bytes, masks and effects as applied CSS — and a font that is in the file but absent from the machine is listed instead of assumed.
- **Gated writes.** `FIGMA_BRIDGE_WRITE=0` makes the bridge read-only; `figma_use` additionally needs `FIGMA_BRIDGE_DESTRUCTIVE=1` and an explicit scope.
- **Checkpoints are measured, not promised.** `figma_checkpoint` has no undo stack to lean on (Figma's is global and one step deep), so it rebuilds a subtree from a recorded description — and says what a rebuild cannot reproduce: an INSTANCE's interior structure beyond its direct overrides, and component identity. A component set's supported property metadata (SLOT descriptions, preferred values and slot settings; INSTANCE_SWAP preferred values) and definition-level default-value variable bindings are restored too, and a refused edit or missing variable is named. Direct overrides carry their path, overridden field names and a layer snapshot; restore recreates the instance from its main component and replays each override, naming a path that no longer matches instead of applying it to the wrong layer and naming every override field it cannot rebuild. A per-run text override inside an instance is replayed range by range with the same run values as a rebuilt text node; when the override touched range styling, the run snapshot is the final state, so a per-run style link or variable binding the override removed is cleared before the snapshot is replayed. Style ids, node-level variable bindings and explicit modes travel in `meta`; text runs carry their own style links and variable bindings. A restore re-links them when their style, variable, collection and mode still exist, and reports every one that does not while leaving the recorded value in place. Text runs themselves — font, size, fills, text/fill style links, variable bindings, case, decoration, line height, letter spacing and hyperlink — are saved and rebuilt. A real rebuild needs `dryRun: false` *and* `allowDestructive: true` *and* a bridge started with destructive writes; after the write the server dumps the subtree **again** and diffs it against the checkpoint, so `verified: true` means "no field differs", and a difference nobody predicted is reported with its path instead of being called a success.
## Development
```sh
npm run build # plugin (esbuild) + server (tsc)
npm run typecheck # server + plugin (with @figma/plugin-typings)
npm run smoke # does server/dist start, speak MCP and fail offline? ~3s, no Figma
npm test # rebuilds artifacts, then unit + e2e with --test-concurrency=1
```
`npm run smoke` is the fast question — it spawns the built server against a scratch info file (so it takes the offline branch even when a real bridge is running), checks the handshake, the guide, the tool surface and the offline path, and prints one line per check. `npm test` is the slow one: the whole chain against a simulated plugin.
`npm test` rebuilds `server/dist` and `plugin/*.mjs` first because the tests import the built modules — a test running against a stale artifact passes on code Figma will never execute. The suites run serially (`--test-concurrency=1`): e2e tests spin up real daemons on real ports and share one token file, and parallel runs make them flaky by construction.
Current suites: **621 unit checks** — 337 `node:test` cases (including 10 installer/config-merge cases) plus 284 harness checks (commands, fingerprint, scope, use-figma gate, checkpoint and checkpoint-store) — and **234 e2e checks** across daemon, link state, MCP, multifile, tools, checkpoint, exports, journal, cloud and the build/patch/batch/swap/variant-set/audit/review/capability/index/search/design-system/tokens/spec-export chain. The full `npm test` run is green; the custom harness still prints **518 PASS**.
## Troubleshooting
| Symptom | Cause |
|---|---|
| `FIGMA_BRIDGE_OFFLINE` | The plugin is not running in Figma, or the daemon is down |
| `FIGMA_BRIDGE_UNKNOWN_FILE` | The `fileKey` is not one of the connected files — an unsaved file's key changes when its panel reloads. The error lists the keys that exist: call `figma_ping` and retry with one of them instead of the same key |
| `FIGMA_BRIDGE_FILE_KEY_REQUIRED` | Several files are connected and no `fileKey` was given — the error lists them; pass one |
| `figma_ping` → `connected: false` | The plugin is not paired; the optional cloud adapter can still work without it |
| `figma_cloud_*` tools are missing | The adapter is off. Set `FIGMA_CLOUD=1` **and** `FIGMA_CLOUD_TOKEN` (PAT) or `FIGMA_CLOUD_OAUTH_TOKEN` (OAuth), then restart the MCP process |
| `CLOUD_UNAUTHORIZED` / `CLOUD_FORBIDDEN` | The token is invalid/expired, or it lacks the scope: `current_user:read` for `figma_cloud_ping`, `file_content:read` for file/node/image reads |
| `CLOUD_RATE_LIMITED` | Figma returned `429`; read `retryAfterSeconds` and wait instead of retrying in a loop. The adapter does not retry for you |
| `CLOUD_RESPONSE_TOO_LARGE` | The reply exceeded `maxBytes`; pass `nodeIds`/`depth`, or raise `maxBytes` deliberately. It is refused rather than truncated |
| `CLOUD_TIMEOUT` / `CLOUD_UNREACHABLE` | The REST request failed before a reply; check network/proxy and `FIGMA_CLOUD_TIMEOUT_MS`. The local daemon is not involved |
| `CLOUD_DOWNLOAD_BLOCKED` | The image URL was not HTTPS (except localhost with `FIGMA_CLOUD_INSECURE=1`), contained credentials, or redirected. The adapter does not follow it |
| `CLOUD_BAD_IMAGE` | The downloaded bytes do not have the requested PNG/JPEG/SVG/PDF signature. Nothing is written; the URL probably returned an error page |
| `CLOUD_DOWNLOAD_FAILED` | The target exists and `overwrite` is false, `outDir` could not be created, or a local write failed. The Figma request may have succeeded; no document was changed |
| The daemon won't start | Port 8787 is busy — `lsof -nP -iTCP:8787 -sTCP:LISTEN` |
| `4409 replaced by a newer socket` | Another panel took this file's slot (plugin reloaded, or the file is open in two windows). Press *reconnect* in the panel you want to keep |
| `4413 too many files (max 16)` | The daemon already serves 16 files — close a panel |
| `4403 bad key` | The daemon restarted with a new `clientKey` — restart your MCP client |
| `4401 bad token` | The token in the plugin doesn't match `figma-bridge-daemon.secret` |
| `figma_checkpoint restore` refused | Three separate gates, each with its own message: a real rebuild needs `dryRun: false`, `allowDestructive: true`, and a bridge started with `FIGMA_BRIDGE_DESTRUCTIVE=1`. Without them the default dry run still reports exactly what would be deleted and created |
| `figma_checkpoint restore` rebuilds but `verified: false` | The second dump differs from the checkpoint: read `verification.diff` (each entry has a path and both values) and `verification.notes`, which name the differences checkpoint-v1 knows it causes (HUG sizing, text-run values) |
| A write timed out — did it ever run? | The timeout error names its `opId`: `figma_ops {opId}` reports whether it is still `queued`, `running`, or finished after the timeout, with the answer recorded for it. `figma_cancel` stops it while it is still `queued`; `OP_RUNNING` and `OP_DONE` are refusals that say why it cannot be stopped — and `OP_DONE` names the revision the call actually reached |
| The bridge died or was restarted — what was in flight? | `figma_journal {unsettled: true}` reads the local journal: a `send` line with no terminal event for the same `opId` is a call that was in flight when its process died (it may have been applied), `timeout` is a caller that gave up, `dropped` got no answer at all, and `late` is the answer that arrived afterwards. It needs neither the daemon nor the plugin |
Liveness check: `lsof -nP -iTCP:8787 | grep -i figma` — empty output means the plugin is not paired.
## Limitations
- The `0.35.1` installer is macOS-only and unsigned. Gatekeeper can require **right-click → Open** on the first launch; a signed/notarized `.pkg` or `.dmg` is not part of this release. The installer writes only its own MCP entry, backs up each config it changes, and skips a config it cannot parse instead of rewriting it. Figma exposes no API for installing a development plugin, so importing `plugin/manifest.json` and pasting the token remain manual.
- Motion support is **Figma Motion (Beta)** and depends on `figma.motion` plus the node-level Motion methods being present in the running Figma build. When they are not, every Motion tool fails with `MOTION_UNAVAILABLE` — it never writes `pluginData` that merely looks like an animation. Motion writes require a dry-run `planId`, are read back after apply, and roll back on a mismatch. There is no native Motion GIF/MP4/WebM exporter in this bridge; render or record in Figma if a movie file is needed.
- The optional cloud adapter reads the public Figma REST API, not the open document. It is registered only with `FIGMA_CLOUD=1` and a PAT/OAuth token; the token's scopes decide what works (`file_content:read` for files/nodes/images, `current_user:read` for `figma_cloud_ping`). It exposes no Figma writes, does not run an OAuth authorization flow, and cannot refresh an expired OAuth token. `figma_cloud_get_image` returns temporary URLs; `figma_cloud_download_image` is the explicit byte-downloading tool and writes only local files. A response over `maxBytes` is refused; use `nodeIds` and `depth` instead of raising the cap blindly. A custom `FIGMA_CLOUD_API_BASE` must be HTTPS, except for a localhost test server with `FIGMA_CLOUD_INSECURE=1`. It follows no redirects and does not retry a 429 automatically; image downloads also refuse redirects and never send the Figma token to the image host.
- `indexTree` walks the whole page per call. On very large pages, split the work — the cache cannot be incremental because the user edits the file in parallel.
- Figma caches screenshot exports; `rev` makes stale renders visible, but there is no way to force a redraw from the Plugin API.
- `figma_use` hands you the full plugin API: it is a notice-and-rollback guard, not a sandbox. Code that runs after the call returns (fire-and-forget promises) escapes the diff; a timeout therefore does **not** roll back and says so.
- Figma unloads the plugin UI when the app is backgrounded. In-flight calls are cancelled immediately (instead of hanging until their timeout), and the bridge reconnects on its own when you return.
- A checkpoint is a rebuild, not a rollback. A restored `COMPONENT` is a new component (its instances do not follow), and an `INSTANCE` shows its main component's *current* content. Every one of those is reported by the tool rather than hidden — see the `limitations` list in a `save` reply.
- Build and patch treat **keys** as identity. They will not move an existing keyed node to a different parent: that is a refusal, not an implicit reparent, because a move changes the node's relation to auto-layout and clips. Use `figma_batch_edit` with `reparent` and a `parentId` or `parentKey` when a move is intended.
- A batch cannot target a node created by an earlier `duplicate` in the same batch: the key does not exist at plan time. Split that into two calls.
- A batch destination key is resolved on the current page only; a destination inside an `INSTANCE` or a `COMPONENT_SET` is refused because that interior is an override or a variant set, not a normal layer tree. A destination inside a `COMPONENT` is allowed but reported: it edits the component definition. Moving a node into or out of a protected parent is refused, because that changes the parent's children. A duplicate whose `newKey` already exists is a no-op; if that existing node is under a different parent, the plan says so instead of silently moving it.
- A component swap is an instance-root operation. A target that is itself nested inside another `INSTANCE` is refused — changing it is an override of the outer instance, not an independent swap. A library component key cannot be structurally checked during a dry run; apply imports it and requires `allowUnverified:true`, then compares the imported structure and reports any loss. Variant properties are not replayed: the chosen target component or variant decides them. `SLOT` properties, a renamed or type-changed component property, a layer whose node type changed, and a nested instance that now points at another component are named losses, not guessed migrations. Nested instance overrides deeper than 12 levels block the swap instead of being silently truncated. A layer that moved AND was renamed can miss the unique-name fallback and is named as a missing override.
- `figma_variant_set` treats a set as a complete matrix: every variant must use the same property names, and a combination must be unique. Property names and values cannot contain comma, equals or a newline. `combine` requires standalone components and refuses one already in a set; it replaces the old component names (reported as previous names) and appends the set, so move it afterwards with `figma_batch_edit` if placement matters. `add` clones the source variant's structure, not its instances, and offsets the clone by 24px when the set is not auto-layout. `edit` keeps the variant id and its instances, but changes their variant property values. A set whose variants do not all expose `variantProperties` is refused rather than guessed at. A combine destination inside an `INSTANCE`, another `COMPONENT_SET` or a `COMPONENT` definition is refused; Figma will not reparent a component into a component definition. `add` and `edit` still operate on an existing set inside a component and name that edit.
- `figma_tokens` manages **variables**, not paint/text/effect/grid styles. Its native `figma-tokens/v1` format is lossless for local variable collections, modes, values, descriptions and **local** aliases; a plain DTCG tree is an import convenience and covers one collection/mode. DTCG aliases are resolved within that collection; cross-collection aliases use the native `{alias:{collection,name}}` value. An alias whose target is in a remote/library collection is exported with `remote:true`, its name and its live variable id; an unchanged value round-trips and is preserved, while a new or different remote binding is refused rather than rebound to a same-named local variable. Collections, modes and variables are matched by name; renaming any of them is a create (and, for prune, a delete), not an identity-preserving rename. A mode rename creates a new mode and leaves the old one, because the import never deletes a mode. A variable is matched by name inside its collection. A variable's `resolvedType` cannot be changed in Figma, so a type change is refused. Export skips `EASING`/`TIMING` variables and names them; import blocks unsupported DTCG types. The document carries variable values, descriptions, modes and aliases, not variable scopes, code syntax, hidden-from-publishing flags, publish keys or extended-collection parentage; those are left as they are. Modes the document does not name are preserved on existing variables and filled from the default managed mode on new ones. `prune` deletes variables (node bindings break) but never deletes a collection or a mode. Colours are written as 8-bit hex, so alpha/channel precision beyond that is rounded; a `dimension` loses its `px` unit because a Figma FLOAT is unitless; a `fontFamily` array is joined into one STRING. A description omitted from the document is preserved, not cleared.
- Audit contrast is exact for solid text on a solid effective background. A gradient or image fill, or a non-normal blend mode, is reported as unknown rather than guessed at. Translucent solid ancestor fills are composited bottom-to-top; if the stack never reaches full opacity, the remaining page white is named as the approximation in `backgroundSource`.
- `figma_review` is a local baseline, not a checkpoint. It captures a PNG, an audit and a semantic fingerprint; it does not serialize a subtree and cannot restore one. A baseline is tied to the `fileKey` and stored review id, not to a published version; a node rebuilt with a new id needs the new `nodeId` passed to `compare`. A screenshot can be stale after a document change (the stored `rev` makes that visible), a missing `inspect` leaves the structure comparison `null`, and a truncated audit limits the new/resolved lists even though the counts are complete. Use `figma_checkpoint` for restoration and `figma_compare` when the reference is a mockup rather than an earlier iteration.
- Audit v2 suggestions are candidates, not bindings. A paint-style candidate is an exact 8-bit colour match with one opaque solid paint; a colour-variable candidate is an exact value in at least one of its modes, and `allModes` says whether every mode matches; a text-style candidate is the same family, style and size. If a local style or variable API throws, the audit still runs, warns, and its suggestion counts stay honest. `empty-text` and `hardcoded-effect` are opt-in rules, not defaults.
## Versions
| Version | What it carries |
|---|---|
| `0.35.1` | Performance pass: `figma_index` and `figma_search` build paths from the parent prefix instead of walking ancestors per node, read each node's name/type/id/visibility/children once, compute a build root's id once per subtree, and defer key/text reads in search until a node is a candidate. A no-op `figma_use` reuses the before-index when `nodechange` never fired instead of walking the page twice. `figma_inspect` gains `includeFingerprint:false` for structure-only reads. `figma_get_design_context` caches style and collection-mode name resolutions for the call. Large tool replies are compact JSON above 128 KiB, and `indexTree` derives parent ids from the traversal instead of reading `parent` per node. No tool removal; 50 local + 5 optional cloud. |
| `0.35.0` | Figma Motion (Beta) support: `figma_motion_styles` and `figma_motion_inspect` read animation styles, applied styles, manual keyframe tracks and timelines; `figma_motion_apply_style` / `figma_motion_remove_style` apply or remove a style; `figma_motion_set_keyframes` / `figma_motion_remove_keyframes` write one manual track; `figma_motion_set_duration` sets a timeline duration. Writes default to a dry run and require its `planId` plus the node's live Motion-state fingerprint; after apply every style id, duration, offset, property and keyframe position/value/easing is read back, and a mismatch rolls the transaction back. The plugin probes `figma.motion` and the node-level methods at call time and fails with `MOTION_UNAVAILABLE` when the Beta API is absent; it never fabricates local animations or writes fake Motion metadata. The plugin reports Motion availability in `figma_ping`. Seven new tools; 50 local + 5 optional cloud. |
| `0.34.1` | Dynamic-page correctness and a reusable agent skill. `figma_index` / `figma_search` no longer touch the synchronous `node.mainComponent` getter, which throws under `documentAccess: "dynamic-page"` (and logs to Figma's console even when caught); an index deliberately omits component metadata, and `figma_inspect` or the new `flb.main(node)` resolves one instance through `getMainComponentAsync()`. Component swap and spec export no longer retain a synchronous fallback either. The old operating guide becomes `GUIDE.md`; `SKILL.md` is now a short frontmatter-bearing skill that can be copied to `~/.claude/skills/figma-local-bridge/`. A local privacy gate (`npm run check:privacy`, optional `.githooks/pre-commit`) scans tracked or staged content for home paths, real e-mail addresses, credentials, HAR captures and a denylist stored outside the repository. No MCP tool changes; 43 local + 5 optional cloud. |
| `0.34.0` | macOS double-click installer and release packaging. `npm run build:release` builds a self-contained zip with `server/dist`, `plugin/code.js`, `plugin/ui.html`, the plugin manifest, the installer and production `node_modules`; `Install.command` copies the app under `~/Library/Application Support/Figma Local Bridge`, writes a per-user `launchd` LaunchAgent with restart, backs up and configures OpenCode/Claude Code/Claude Desktop/Cursor MCP entries, copies the token to the clipboard without printing it, and opens the plugin manifest in Finder. `Uninstall.command` removes the app, LaunchAgent and this bridge's entries while keeping backups/data by default (`--purge` removes the token too). The installer refuses an occupied port owned by an unknown process, skips unparseable JSONC instead of corrupting it, and leaves the two Figma-manual steps explicit. No new MCP tools; the server and plugin surface stays 43 local + 5 optional cloud. |
| `0.33.0` | `figma_cloud_download_image` adds the byte-downloading half of the cloud image path. It calls `GET /v1/images/:key`, then fetches each temporary URL without forwarding the Figma token, verifies the PNG/JPEG/SVG/PDF signature, writes `figma-<nodeId>.<ext>` to `outDir` (default: a temp directory), caps each image and the call total, and refuses to overwrite unless `overwrite:true`. Everything is downloaded and checked before the first write; an existing file, a bad signature, a redirect or a local write failure is named instead of silently producing a partial image. The Figma document is untouched. `figma_ping` now names five cloud tools. |
| `0.32.0` | Optional read-only cloud access. With `FIGMA_CLOUD=1` and a PAT/OAuth token, four REST tools join the surface: `figma_cloud_ping` (`GET /v1/me`), `figma_cloud_get_file`, `figma_cloud_get_nodes` and `figma_cloud_get_image`. They work without the local daemon, never write, never return the token, follow no redirects, and refuse oversized responses rather than truncating. `figma_ping` reports `cloud.available`, `configured`, `mode`, `tokenType`, the registered tools and reads; the default remains off, and a token alone does not opt in. Errors keep their HTTP meaning (`CLOUD_UNAUTHORIZED`, `CLOUD_FORBIDDEN`, `CLOUD_RATE_LIMITED` with `retryAfterSeconds`, `CLOUD_RESPONSE_TOO_LARGE`, ...). Hosted file creation, AI generation, generative plugins, shaders and Weave remain unavailable. |
| `0.31.0` | `figma_ping` becomes a capability report as well as a health check. It returns the server version, the effective local mode (`read-only`, `write`, `write+destructive`) and a `cloud` object; at that point `cloud.available` was false because no REST adapter existed yet, and the object listed the Figma-hosted features (file creation, AI generation, generative plugins, shaders, Weave) that this local bridge does not proxy or simulate. The local tools that the official server marks remote-only — assets, libraries, account identity, design-system search — remain real Plugin API reads. No new tool; no document writes. |
| `0.30.0` | `figma_review` is the iteration harness. `save` records a local baseline as a PNG, the full `audit-v2` report and the semantic fingerprint; `compare` re-renders, re-audits and re-inspects the node, diffs the pixels, and reports new/resolved/changed findings, severity deltas, size and fingerprint changes, the heatmap and a verdict (`unchanged`, `changed`, `improved`, `regressed`). A new error/warning or a size change is a regression; resolved problems are an improvement; a visual/structural change without either is neutral. The baseline reuses its audit rules, hidden-layer setting and scale, a capture that spans revisions is refused, and a truncated audit is named. `list`, `drop` and `latest` manage local files; no document writes. |
| `0.29.0` | `figma_audit` becomes `audit-v2`: with `suggest:true` (default) every hardcoded fill/stroke also carries **exact** local paint-style and colour-variable candidates (a variable candidate names the matching modes and whether all modes match), unstyled text carries exact local text-style candidates, and spacing/radius findings name the nearest on-scale value and delta. Suggestions are read-only candidates, never bindings, and a failed style/variable API is a warning rather than a failed audit. Two opt-in rules join the surface: `empty-text` (visible text with no characters) and `hardcoded-effect` (visible effects without an effect style). The audit schema is `audit-v2`; all existing rules and findings keep their meaning. |
| `0.28.0` | `figma_tokens` adds design-token export, diff and guarded variable import. `export` writes the native `figma-tokens/v1` document (collections, modes, values, descriptions and aliases); `diff` compares a document with the live file; `import` creates missing collections/modes/variables and updates values/descriptions in one undo transaction. A plain DTCG tree is accepted with `collection`/`mode` for `color`, `number`, `dimension`, `string`, `boolean`, `fontFamily` and `fontWeight`; unsupported types are blocking. The dry run fingerprints the live variable state, and the server hashes the file bytes plus options into a `planId`; apply requires both, so a changed file or a changed document cannot slip through. Variables are read back mode by mode after writing and the import rolls back on a mismatch, naming `ROLLBACK FAILED` if the undo itself fails. `prune` is opt-in, needs `allowDestructive:true`, only touches the named collections, and refuses to delete a variable another variable still aliases. Modes not named by the document are preserved on existing variables and filled from the default managed mode on new ones, with that fact in the plan. |
| `0.27.0` | `figma_variant_set` authors a component set as a property matrix. `combine` takes standalone components, validates that every variant uses the same property names, refuses duplicate combinations and nested/already-variant components, and calls `figma.combineAsVariants` after naming each component `Property=Value`; `add` clones an existing variant with a new combination; `edit` renames one variant in place and is a no-op when the combination is unchanged. The dry run fingerprints every component and the set, plus the destination layout for combine, and refuses a destination inside an `INSTANCE`, another set or a `COMPONENT` definition (Figma will not reparent a component into a component definition); `add` and `edit` still operate on an existing set. Apply needs the dry-run `planId` and `expectedFingerprints`; it reads `variantProperties` and `variantGroupProperties` back after writing and rolls the transaction back if Figma did not parse the names into the matrix. A set whose variants lack `variantProperties` is refused rather than guessed at. |
| `0.26.0` | Keyed moves resolve both ends. `figma_batch_edit`'s `duplicate` and `reparent` accept a destination `parentKey` as well as `parentId`, and the dry run returns the resolved destination plus a fingerprint for every non-page destination. Passing the dry-run `fingerprints` therefore refuses a move whose parent changed, before the first write. `reparent.index` is the final child index and omitted appends; a same-parent reorder keeps `layoutPositioning` instead of turning an auto-layout child absolute, an auto-layout destination uses `ABSOLUTE` when `preserveAbsolute` is true and `AUTO` when false, and an out-of-range index or a destination inside an `INSTANCE` / `COMPONENT_SET` is refused. A destination inside a component definition is named as an edit to that definition. A duplicate whose `newKey` already exists remains a no-op and now reports an existing node under a different parent instead of pretending the requested move happened. |
| `0.25.0` | `figma_swap_component` swaps an INSTANCE's main component without treating the editor's override heuristics as a contract. The dry run compares the old and new structures, returns per-target fingerprints and names every loss: an override path is exact index+name first, then a unique name in the new component; a changed node type, missing layer, changed nested component and dropped/renamed component property are not applied to a guess. Root-level instance state (custom name, resized geometry, opacity, paints/strokes/effects and style links, layout placement, constraints) is preserved, component properties are mapped by display name and type, and direct layer overrides are replayed after `swapComponent`. Apply requires the dry-run `planId` and `expectedFingerprints`, refuses a lossy swap without `allowLoss`, refuses a library component whose structure was not checked without `allowUnverified`, refuses a target nested inside another `INSTANCE`, and rolls the whole transaction back on an unexpected loss. Variant properties are left to the chosen target; `SLOT` properties are named. The checkpoint reader can now report nested-instance depth truncation instead of presenting a cut-short override list as complete. |
| `0.24.0` | Per-run overrides now clear what they removed. When a direct instance override touched range styling, `figma_checkpoint` records the run snapshot as the final state and, before replaying it, clears the inherited per-run `textStyleId` / `fillStyleId` links and range variable bindings that the snapshot no longer carries. `styledTextSegments` is treated as a supported override field, so Figma's range-styling signal triggers the same replay. A link or binding the file refuses to clear is named in `notes`; a missing link or variable is still reported through `stylesMissing` / `bindingsMissing`. |
| `0.23.0` | Checkpoints restore component-property metadata. After adding back a `COMPONENT_SET`'s non-variant properties, `figma_checkpoint` re-applies a `SLOT` property's description, preferred values and slot settings, an `INSTANCE_SWAP` property's preferred values, and a definition-level `defaultValue` variable binding on `BOOLEAN`, `TEXT` and `INSTANCE_SWAP` properties. A variable that no longer exists or an edit the file refuses is named in `bindingsMissing` / `propertiesFailed`, while the property and its literal default stay. Variant options still come from the variant names; component/property ids, component keys and existing instances still do not come back. |
| `0.22.0` | Build-spec shader paints close the paint gap. `figma_export_build_spec` carries a SHADER paint's shader id and property assignments keyed by definition id; `figma_build` / `figma_patch` resolve the id through `listAvailableShaders`, refuse an unavailable id or an effect shader, import an unimported fill shader inside the write transaction, and then apply the paint with `visible`, `opacity` and `blendMode`. `propertyMetadata` is read-only and is refused if sent. |
| `0.21.0` | Build-spec pattern paints survive the loop. `figma_export_build_spec` carries a PATTERN paint's source node id, tile type, scaling factor, spacing and horizontal alignment; `figma_build` / `figma_patch` resolve that source node before writing and block if it is missing, then apply the fields and keep `visible`, `opacity` and `blendMode`. The source node id is file-local, like an image hash, so a build in another file needs that node. `SHADER` remains named, not silently dropped (`SHADER` was closed in 0.22.0). |
| `0.20.0` | Build specs create boolean operations. `figma_build` accepts a `BOOLEAN_OPERATION` with `booleanOperation` (`UNION`, `SUBTRACT`, `INTERSECT`, `EXCLUDE`) and at least two children; the operands are built in spec order, combined with `figma.union` / `subtract` / `intersect` / `exclude`, and keep their keys. Operands are staged in a scratch frame first, so an auto-layout parent cannot discard their coordinates before the boolean is formed. An existing boolean updates in place; changing its operation edits the same node instead of rebuilding it. Its size is derived from the operands, so a supplied `size` is ignored with a warning. Nested booleans build inside-out. `figma_patch` may list only the operands it changes when `prune` is false; with `prune:true` a boolean patch must list at least two operands. A sparse patch keeps the existing operand order and appends a new operand; a full build enforces spec order. `figma_export_build_spec` exports a boolean as a live boolean with its operation and children, not as a flattened SVG. |
| `0.19.0` | Checkpoints rebuild component sets. `figma_checkpoint` walks a `COMPONENT_SET`'s variant `COMPONENT` children, records `componentPropertyDefinitions`, recreates each variant and combines them with `figma.combineAsVariants`, then adds back the non-variant properties by type and default value and remaps layer-level `componentPropertyReferences` to the new names. Figma assigns a fresh `#id` suffix to each rebuilt property, so verification compares definitions and references by display name. Variant options come from the component names; component/property ids, component keys, existing instances, property descriptions/preferred values/slot settings and definition-level variable bindings do not come back. `COMPONENT_SET` is no longer a structural refusal; unknown node types are still refused by name. |
| `0.18.0` | Checkpoints rebuild slice nodes. `figma_checkpoint` records a `SLICE`'s position, size, rotation, visibility, lock and `exportSettings`, creates a new slice on restore and writes the settings back; the dump and restore verification compare export settings field by field. (`COMPONENT_SET` was closed in 0.19.0.) |
| `0.17.0` | Build-spec video paints survive the loop. `figma_export_build_spec` carries `VIDEO`'s `videoHash`, `videoTransform`, `scalingFactor`, `rotation`, `filters`, `visible`, `opacity` and `blendMode`; `figma_build` / `figma_patch` apply them and validate the hash. A video hash is file-local, like an image hash. `PATTERN` and `SHADER` paints remain named, not silently dropped. |
| `0.16.0` | Checkpoints rebuild boolean operations. `figma_checkpoint` now walks a `BOOLEAN_OPERATION`'s children and records `booleanOperation`; restore rebuilds the children and recreates the operation with `figma.union` / `subtract` / `intersect` / `exclude`, then applies the node's paint, strokes, position, rotation, style links and bindings. Nested booleans are rebuilt inside out; a boolean with fewer than two rebuildable children is named and skipped rather than faked. (`COMPONENT_SET` was closed in 0.19.0.) |
| `0.15.0` | Build-spec paints keep the fields that decide how an image is placed and how a paint composites. `figma_export_build_spec` now carries an IMAGE paint's `imageTransform`, `scalingFactor`, `rotation` and `filters`, and every supported paint's `visible` flag and `blendMode`; `figma_build` / `figma_patch` apply them, including an invisible paint that stays in the stack instead of disappearing. Paint types the build still cannot create (`VIDEO`, `PATTERN`, `SHADER`) remain named, not silently dropped (`VIDEO` was closed in 0.17.0; `PATTERN` in 0.21.0 and `SHADER` in 0.22.0). |
| `0.14.0` | Checkpoints rebuild star and polygon shapes natively. `figma_checkpoint` records `STAR`'s `pointCount` and `innerRadius` and `POLYGON`'s `pointCount`, then applies them before the final size so the outline and the bounding box both come back; direct instance overrides of those fields are replayed too. The named loss for these two node types is closed. `BOOLEAN_OPERATION` and `COMPONENT_SET` were still refused by name (BOOLEAN_OPERATION closed in 0.16.0; COMPONENT_SET in 0.19.0). |
| `0.13.0` | Per-run text styling inside direct instance overrides is no longer a named loss. `figma_checkpoint` captures each overridden text layer's runs and, after re-instantiating the main component, replays them range by range with the same helper a rebuilt TEXT node uses: every run font is loaded before the character setter, then sizes, fills, text/fill style links, variable bindings, case, decoration, line height, letter spacing and hyperlinks go back through the range setters. A per-run style link or variable binding that the override REMOVED is not cleared from the main component's current run; that residual was closed in 0.24.0. |
| `0.12.0` | Build-spec export keeps the appearance of nodes it cannot express. `figma_export_build_spec` renders an unsupported node type, a frame created from SVG or a detached instance as an inline SVG (`svgFallback`, on by default) and emits the `type: "SVG"` leaf that `figma_build` already knows how to create; the rebuilt frame keeps its name, key, position, visibility, lock, mask and auto-layout placement, while rotation, opacity, effects, paints and text are baked into the SVG. Every fallback is named in `warnings` and counted, with a 128 KiB per-node / 192 KiB total budget so the spec stays usable under the 256 KiB build limit; a fallback that cannot be rendered or does not fit is listed under `unsupported` with the reason. Slices are never flattened. A spec over the build limit is named in `warnings`. A repeated build recognises the SVG-created frame by its stored kind and updates it in place; a changed `svg` replaces the interior while keeping the node id and key, and an unchanged source is left alone. The remaining losses are the original child layers, component identity, overrides, style/variable links, editable text and mixed values |
| `0.11.0` | Checkpoints carry direct instance overrides. `figma_checkpoint` save records every direct per-layer override on an INSTANCE as a structural main-component path, the fields Figma reports as overridden, a snapshot of the layer and its style/variable links; restore recreates the instance from its main component and replays the overrides field by field — text characters without flattening the base font, formatting, fills/strokes/effects and their styles, visibility, opacity, blend, rotation, corners, stroke weight, size, position, layout and component properties. A path that no longer matches the main component is named in `overridesMissing` and skipped rather than applied to the wrong layer; a field this version cannot rebuild is named in `overridesUnsupported`. An INSTANCE's interior structure beyond those overrides remains the main component's current structure; per-run text styling inside an override was closed in 0.13.0 |
| `0.10.0` | Direct instance overrides survive the build-spec loop. `figma_export_build_spec` turns every direct override into an `overrides` entry with a structural path built from the main component's layer names, plus the fields a rebuild can express — text, font/size, text style, text formatting, hyperlink, fills/strokes and their styles, effects and effect style, visibility, opacity, blend, rotation, corners, stroke weight, size and name — and names every field it cannot express. `figma_build` / `figma_patch` accept those overrides by path as well as by layer name/key, resolve styles and fonts before writing, and warn instead of silently skipping a stale path. Instance interiors remained a named loss, and checkpoint restoration of their overrides was closed in 0.11.0 |
| `0.9.0` | Text variable bindings survive the loop. `figma_build` / `figma_patch` accept a base `textVariable` and a per-run `variable` map for the eight bindable text fields (`fontFamily`, `fontStyle`, `fontWeight`, `fontSize`, `letterSpacing`, `lineHeight`, `paragraphSpacing`, `paragraphIndent`), resolved as `STRING`/`FLOAT` local variables before writing; `null` clears a binding inherited from the base. `figma_export_build_spec` emits the base binding and each run's different or cleared binding, resolving ids back to variable names, and `figma_checkpoint` saves and re-links per-run text bindings; a missing variable is named while the literal value remains |
| `0.8.0` | Per-run style links survive the loop. A run accepts `textStyle` and `fillStyle` (local style name/id; `null` clears a link), resolved before writing and applied as a real range link. `figma_export_build_spec` emits the base range's style links and each run's different link instead of flattening to literals, and `figma_checkpoint` saves and re-links per-run text and fill style ids. Per-range variable bindings were closed in 0.9.0 |
| `0.7.0` | Text runs survive the whole loop. `figma_build` / `figma_patch` accept ordered, non-overlapping `runs` with per-run font, size, fills, case, decoration, line height, letter spacing and hyperlink; the base text properties are the fallback between runs. `figma_export_build_spec` turns mixed text into a base style plus runs instead of warning, and names per-run style links and per-range bindings as remaining losses (per-run style links closed in 0.8.0, per-range variable bindings in 0.9.0). `figma_checkpoint` saves and restores the run values and keeps older `checkpoint-v1` files restorable |
| `0.6.0` | Text hyperlinks survive the whole loop: `figma_build` / `figma_patch` and `figma_batch_edit`'s `set_text` set URL or NODE links on TEXT (including instance overrides; `null` clears one), `figma_export_build_spec` emits URL links and warns that a NODE link is file-local, and `figma_checkpoint` saves and restores the link by value (mixed per-run links were named as a loss until 0.7.0) |
| `0.5.0` | `figma_checkpoint` restores node-level variable bindings and explicit variable modes by id, re-links paint bindings when their variables still exist, and reports `bindingsRestored` / `bindingsMissing` / `modesRestored` / `modesMissing`; a missing link leaves the recorded value in place. Non-empty layout grids now travel with the checkpoint too, including their variable bindings. Per-range text bindings stay a named loss. The save summary counts variable bindings and modes separately |
| `0.4.2` | Build specs carry frame/component `layoutGrids` (columns, rows, grid) and an exact `LINE` length; the reverse export emits both, so layout grids and rotated rulers no longer become warnings or default-length lines |
| `0.4.1` | Build specs carry rotation, individual corner radii, stroke cap/join/miter, masks, layout inclusion flags, and text case/decoration/paragraph spacing; `figma_batch_edit` gains `set_rotation`. `figma_export_build_spec` emits these instead of naming them as losses; a line still reports that its length is not representable |
| `0.4.0` | The declarative editing layer: `figma_build` and `figma_patch` (a keyed tree, resolved before writing, content `planId`, fingerprint-guarded apply, rollback and post-apply verification), `figma_batch_edit` (many targets in one transaction with preflight and per-target fingerprints), `figma_audit` (stable keys, hardcoded paints, text styles, placeholders, detached instances, WCAG contrast, opt-in spacing/radius), and `figma_index` / `figma_search` (a compact page index and substring/regex/glob node search), plus `figma_design_system` (styles, variables and components as one snapshot; export with a sha256 and a stable-key diff against the live file) and `figma_export_build_spec` (a live subtree back into the same keyed build language, naming every field the Plugin API cannot carry). Checkpoint restores now re-link fill/stroke/effect/text style ids when the style still exists, and audit contrast composites translucent foreground and ancestor fills before measuring. Builds and patches store keys in `pluginData`; duplicates rekey descendants; protected nodes survive prune. All new tools are covered by unit tests and the daemon/plugin e2e chain |
| `0.3.0` | `figma_compare` (a render against a PNG on disk, read and diffed without graphics dependencies), `figma_checkpoint` (a subtree as data: save, diff, rebuild, verify — with what a rebuild does not bring back named), `figma_ops` and `figma_cancel` (a timed-out call is settled by its recorded answer; a queued write nobody waits for is stopped), the durable append-only journal, the `flb` snippet library for `figma_use` (bboxes, grid, text, clear — its write helpers refuse out-of-scope targets before writing), the export tools (`figma_export_ui_atlas`, `figma_export_9slice`, `figma_export_ui_states`) and the web handoff bundle: one folder per frame with previews, spec.json, tokens and a hashed manifest re-read from disk, a 24 MP preview refused before the rasteriser, a font the time-boxed check cannot confirm reported as **unchecked**, and per-phase timings in the reply; an MCP client that starts while the plugin is already paired now learns the daemon's state instead of reporting it offline; plus an unsaved file's session key kept on the document |
| `0.2.0` | Several Figma files at once (16 slots, an explicit `fileKey` on every tool), `figma_get_layout_tree`, `opId` idempotency with a replay cache, serialised mutations, per-side stroke weights and pinned variable modes, the operating guide as MCP `instructions` and as a resource, `npm run smoke`, and the panel that fits its own content — with the octopus redrawn on the panel's grid, at half the font size, answering a tap |
| `0.1.0` | The first cut: one daemon, one token, one Figma file, the tool surface, and the safety model |
## License
MIT — see [LICENSE](LICENSE). Unofficial project, not affiliated with Figma, Inc.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues