figma-bridge-mcp
# figma-bridge-mcp
A local MCP server that lets AI assistants inspect, create, and update designs
in **Figma Desktop**. It connects through a small Figma development plugin and
exposes focused tools for screenshots, design specs, JSX rendering, tokens,
assets, components, FigJam, and Figma Slides.
Everything runs on `127.0.0.1`. No Figma Personal Access Token required. No
cloud. No binary patching of the Figma app.
An [optional REST add-on](#rest-add-on-optional) adds version history, comments,
and published-library metadata. Its Figma token stays on your machine and is
never placed in your MCP client configuration or chat.
Requirements: Node.js 18 or newer, Figma Desktop, and an MCP client that can
start local stdio servers.
### Codex, Claude Code, and Cursor: MCP and skills in one bundle
Figma Bridge ships three focused shared skills plus thin plugin adapters for
all three clients:
- `figma-bridge-design-to-code` — exact Figma implementation in the target stack;
- `figma-bridge-code-to-figma` — semantic, componentized screens from code;
- `figma-bridge-component-library` — tokens, styles, components, variants and properties.
| Client | Plugin format | Full install path |
| --------------- | --------------------------------- | ------------------------------------------------ |
| Codex / ChatGPT | `.codex-plugin/plugin.json` | This repository's Codex marketplace |
| Claude Code | `.claude-plugin/plugin.json` | This repository's Claude marketplace |
| Cursor | Agent Plugins 1.0 (`plugin.json`) | GitHub-backed team marketplace or local checkout |
The adapters all discover the same `skills/` directory and start the same local
MCP package. Users do **not** download or maintain the skills separately.
For Codex, add this repository as a marketplace and install the bundle:
```bash
codex plugin marketplace add KaiUweHella/figma-bridge-mcp
codex plugin add figma-bridge-mcp@figma-bridge
```
Start a **new Codex task** after installation; plugin MCP servers and their
tools are discovered when a task starts. Figma Bridge is a local STDIO server,
so Codex may label its MCP auth as **Unsupported**. That label only means STDIO
does not offer Codex's OAuth/Bearer login flow; the Figma connection still uses
its own authenticated localhost handshake. Once the server is loaded, Codex
discovers these 12 tools dynamically: `figma_connect`, `figma_status`,
`figma_pairing`, `figma_run`, `figma_render`, `figma_selection`,
`figma_history`, `figma_comments`, `figma_inspect`, `figma_reference`,
`figma_screenshot`, and `figma_spec`. If a task still shows **Tools: (none)**,
the server did not start in that task; it is not a static plugin capability
list.
This is a GitHub-hosted repository marketplace, not a submission to the
universal OpenAI plugin directory. The catalog follows the repository, while
each released plugin entry pins an exact `v<version>` Git tag and starts the
matching npm runtime version. `main` and `@latest` therefore cannot silently
move an installed skill bundle onto a different server contract.
For Claude Code, add this repository as a marketplace and install the bundle:
```bash
claude plugin marketplace add KaiUweHella/figma-bridge-mcp
claude plugin install figma-bridge-mcp@figma-bridge
```
Start a new Claude Code session after installing or updating the plugin, then
open `/mcp`. The `figma-bridge` server should be **connected** and show 12
tools: `figma_connect`, `figma_status`, `figma_pairing`, `figma_run`,
`figma_render`, `figma_selection`, `figma_history`, `figma_comments`,
`figma_inspect`, `figma_reference`, `figma_screenshot`, and `figma_spec`.
Claude namespaces MCP tools internally (for example,
`mcp__figma-bridge__figma_connect`) and resolves their schemas at runtime. It
is therefore normal for `claude plugin details figma-bridge-mcp@figma-bridge`
to report `tool schemas resolved at runtime; not counted`; `/mcp` is the source
of truth for the live tool count.
Choose either the full Claude plugin above or the MCP-only fallback below; do
not register both under the same `figma-bridge` server name. Claude gives an
existing manual MCP registration precedence and suppresses the plugin's copy,
so a stale manual command can make a healthy plugin appear to have no tools.
If `/mcp` reports a failed or zero-tool server, run
`claude mcp get figma-bridge`. When it reports a manual project, local, or user
configuration, use the scope-specific removal command it prints, start a new
session, and check `/mcp` again.
If `/mcp` reports `CONNECTION_CLOSED` and the debug output says
`No version is set for command node`, the process exited before the MCP
handshake. In the same shell environment that launches the MCP client, verify:
```bash
command -v node
command -v npx
node --version
npx --version
```
Both version commands must succeed and Node must be 18 or newer. If the command
paths resolve to `asdf` shims without an active Node version, configure one in
`asdf`; if `nvm` should own Node instead, place its active Node directory ahead
of stale `asdf` shims in `PATH`. Restart the MCP client after correcting the
runtime environment.
The Claude marketplace uses the same pinned GitHub release and shared skill
tree. The matching npm package must be published before users install that
release because the plugin launches its local stdio server through `npx`. Its
public plugin source is an explicit HTTPS Git URL, so installing or updating it
does not require SSH keys or a GitHub `known_hosts` entry.
For Cursor Teams or Enterprise, import this GitHub repository into a team
marketplace and install **Figma Bridge** from Customize. Individual users and
contributors can use the same GitHub source without a central Cursor listing:
clone the tagged release, link that checkout into Cursor, and reload the
window:
```bash
mkdir -p ~/.cursor/plugins/local
ln -s /absolute/path/to/figma-bridge-mcp ~/.cursor/plugins/local/figma-bridge-mcp
```
Cursor detects the root Agent Plugin manifest and loads both the skill and MCP
server.
Clients without plugin or Agent Skill support keep using the normal server
configuration below. They still receive the compact mandatory workflow through
MCP instructions, the user-invoked `design-to-code`, `code-to-figma`, and
`create-figma-component` MCP prompts, and
`figma_reference {name:"workflow"}`.
## Quick start
### 1. Add the MCP server (MCP-only fallback)
Use this when the full plugin install is unavailable or you only want the MCP
tools without the bundled skill. Do not add this fallback when the full plugin
is already installed. The `npx` setup needs no clone or build step.
For Claude Code:
```bash
claude mcp add figma-bridge -- npx -y figma-bridge-mcp@latest
```
For another MCP client, add the equivalent server configuration:
```json
{
"mcpServers": {
"figma-bridge": {
"command": "npx",
"args": ["-y", "figma-bridge-mcp@latest"]
}
}
}
```
Restart the MCP client if it does not discover the server immediately. There is
intentionally no `env` block: the bridge creates its local credentials during
pairing.
<details>
<summary>From source (contributors)</summary>
```bash
git clone https://github.com/KaiUweHella/figma-bridge-mcp.git
cd figma-bridge-mcp
npm install
```
```json
{
"mcpServers": {
"figma-bridge": {
"command": "node",
"args": ["/absolute/path/to/figma-bridge-mcp/src/server.js"],
"env": { "FIGMA_BRIDGE_PROFILE": "local-development" }
}
}
}
```
Use the `local-development` profile when a released Bridge is running at the
same time (for example from Claude Code). It pins the source checkout to
`localhost:3460` and isolates its daemon, tokens, pairing and audit data below
`~/.figma-bridge-mcp/profiles/local-development/`. `figma_connect` generates
the gitignored, checkout-owned `plugin/local/manifest.json` bundle; import that
exact file and open **Figma Bridge Local Development**. You can also refresh
both checkout import bundles explicitly with `npm run prepare:plugin-imports`.
The main Plugin passes that manifest identity's exact port to the shared UI,
while Figma's manifest policy also permits only `:3460`.
Omitting `FIGMA_BRIDGE_PROFILE` retains the normal released profile and its
3456–3460 fallback behavior.
</details>
### 2. Pair each Figma editor once
1. Ask your AI assistant to **connect to Figma**, or call `figma_connect`
directly. It starts the local bridge and returns an access key plus a plugin
manifest path.
2. In **Figma Desktop**: `Plugins → Development → Import plugin from manifest…`
and choose **`~/.figma-bridge-mcp/plugin/manifest.json`** (the path
returned by `figma_connect`).
3. Open `Plugins → Development → Figma Bridge` in Figma Design, paste the
access key, and click **Save & connect**.
4. Figma stores plugin data separately for Design, FigJam and Slides. To use
another editor, open the plugin there and save the same key once. In an
already paired editor, **Setup → Copy access key** copies the in-use key
without displaying or logging it.
5. When the plugin shows **Connected (authenticated)**, the assistant can work
with that Figma file. The pairing is remembered. Starting the configured MCP
automatically wakes a daemon that exited after its idle timeout and reuses a
healthy daemon/socket, so a new AI session does not interrupt the open Figma
file. If the automatic wake fails, call `figma_connect` once before deeper
diagnosis. If the only socket is open but its plugin iframe no longer
answers a read-only probe, `figma_connect` reloads just that iframe and it
reconnects with the stored key.
If a timed-out compatibility command leaves only the execution lane uncertain
while health reads still answer, call `figma_connect` with
`{forceRecovery:true,fileKey:"<exact-file-key>"}`. This explicitly reloads only
that authenticated Plugin UI; it refuses an unknown file and never reloads
while another Figma operation is still active.
The plugin keeps looking for the local bridge if it was opened first. It uses a
small bounded WebSocket burst for instant startup, then a quiet localhost health
probe every three seconds and opens a socket only after the daemon is present.
Use the reload icon to force one immediate scan. After an npm upgrade,
`figma_connect` refreshes the files at the stable path above, but Figma may keep
the previously imported build in its application cache. `figma_status` detects
that mismatch. Re-import the same `manifest.json` path once when it reports an
older plugin build; the saved access key is retained.
### Figma Dev Mode requires its own manifest.json bundle
The normal `manifest.json` does not work in Dev Mode. Figma does not support
combining the existing FigJam editor target with `dev` in one manifest:
- Import `~/.figma-bridge-mcp/plugin/dev-mode/manifest.json` for **Figma Bridge Dev
Mode**. It keeps the authenticated MCP bridge connected for selection,
inspection, specs and exports. Dev Mode is read-only, so rendering and canvas
edits still require switching the file to Design mode and opening the normal
**Figma Bridge** plugin there.
### 3. Use it with Figma
Select a frame or layer in Figma and describe the outcome you want. For example:
- "Inspect my current selection and explain its layout."
- "Create a settings card next to the selected frame."
- "Export the selected screen's tokens and assets into this project."
- "Implement the selected frame, then compare the result with Figma."
The assistant can read the current selection, capture screenshots and specs,
render JSX, export assets, or apply targeted edits. Keep the Figma Bridge plugin
open in every document the assistant should access. If more than one document
is connected, pass a Figma URL or file key so the target is unambiguous.
## REST add-on (optional)
Everything above works with **zero Figma credentials**. Three things the local
plugin bridge structurally cannot reach live behind Figma's REST API, and can
be unlocked with a personal access token:
| Feature | What it adds |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Version history** | `figma_history {includeVersions:true}` merges what _designers_ saved (when, by whom) into the local audit+git timeline — the plugin API can only _write_ versions, not read them. `figma_history {diff:{from:"version:…", to:"version:…"}}` goes further and diffs the documents themselves. |
| **Comments** | `figma_comments` reads design-review feedback (with node anchors and thread ids) and can reply. Posting always shows a preview first and requires `confirm:true` — comments are visible to other people. |
| **Library metadata** | `map storybook` automatically enriches `figma-map.json` with the published components' `description` and documentation links — a far stronger matching signal than name normalization. |
**Enabling it — the token never leaves your machine:**
1. Create a personal access token in Figma (Settings → Security → Personal
access tokens) with scopes: **File content (read)**, **File versions
(read)**, **Comments (read and write)**. _Current user (read)_ is optional —
it only makes `figma_status` show your handle.
2. Open the **Figma Bridge plugin** in Figma Desktop, connect, expand
**Setup**, paste the token under **Figma REST token**, and select **Save
token**. The plugin's **Save history** button becomes available after the
token is stored.
3. `figma_status` reports that the token is configured without making a remote
request. Run `figma_status {validateRest:true}` when you want an explicit
validity check; it reports your handle or verifies file access when the
optional _Current user_ scope is absent.
The token travels from the plugin over the **authenticated localhost
WebSocket** to the daemon, which stores it in `~/.figma-bridge-mcp/rest-token`
(mode 0600). It is never entered in chat, never stored in your MCP client
config, never echoed back by any tool, and never written to the audit log
(REST calls retain only the allowlisted endpoint capability, read/write class,
and non-reversible summaries). _Clear token_ in the plugin removes the file.
Headless/CI alternative: set the `FIGMA_REST_TOKEN` environment variable — it
overrides the file.
**Scope:** by default REST calls target the file currently open in Figma
Desktop (the plugin pushes its file key). Other files require an explicit
`fileKey` parameter (bare key or full Figma URL). Note that a PAT itself can
read every file its account can access — keep the scopes minimal.
The REST client is a closed internal allowlist, not a generic HTTP escape
hatch. It permits token health, version lists, version-pinned document
contents, comments, and file-wide published-component metadata. A bare current
file fetch and all node/CSS/export/variable/style/Dev-Resource endpoints are
rejected before the token is read or the network is touched; those operations
must use the local Plugin API commands above.
## How it works
```
MCP client ──stdio──▶ figma-bridge-mcp (src/)
│
MCP tool adapters ─▶ Capability Catalog ─▶ CommandPlan
│
┌───────────────────────┴──────────┐
Command Application Modules generic CLI adapter
│ │
Design Capture │
Asset Policy │
└──────┬─────┘
Daemon Client Module
│ HTTP: signed requests
▼
local daemon :3456–3460
│ WS: challenge/response
▼
Figma Bridge plugin in Figma Desktop
```
- The **engine** lives under `engine/`. It began as a fork of `figma-ds-cli`
v2.1.0 and has diverged well past it (see [attribution](#inspiration--attribution)).
The Chrome-DevTools "Yolo mode" — which patches the Figma app binary — was
removed entirely; there is no code path to it.
- Specialized MCP reads (`figma_spec`, `figma_inspect`, `figma_screenshot`)
and migrated `figma_run` actions execute directly through value-returning
**Command Application Modules**. `export assets`, `node set-text`,
`create frame|rect|ellipse|polygon|star`, bounded
`node tree`, current-page Node Discovery through `find`, direct
`node bindings`, native Inspect `node css`, exact Prototype Inspection,
Component Identity Facts, Style Facts through `style show`, bounded Style
Catalog pagination through `style list`, Layout Grid Facts through
`grid list`, paginated Style Consumer Facts through `style consumers`,
Variable Facts through `var show`, Design Link
set/inspect, and Design Contract capture/check
use the same Implementation from MCP and Commander.
Other `figma_run` actions remain on the deliberately broad child-process
compatibility Adapter while they migrate one vertical slice at a time. One
**Daemon Client Module** owns signing, timeouts and transport errors for both
paths.
- The **Design Capture Module** walks an explicit node once and locally projects
structure, style and the lossless output formats from those same facts. A
Capture is reused only after a cheap revision probe proves the authenticated
plugin connection and Figma document revision are unchanged. Missing or
unstable revision metadata disables reuse; selection and named-section calls
remain uncached in this first Slice.
Captures distinguish authored Figma Auto Layout/Grid, Figma's marked
`inferredAutoLayout` heuristic and geometry fallback. They also preserve
Code-to-Figma semantic/fallback metadata separately from later native Figma
annotations, plus full component and variable-mode contracts.
- The **Design Link Registry** gives a component, screen or frame one durable,
repository-owned **Design Entity** id. `figma-bridge.json` holds portable
code/Storybook/Figma links; Figma plugin data holds only the same id and kind.
This dual anchor lets future agents resolve the exact existing component from
either side without putting repository paths into a Figma document.
- The report-only **Round-trip Planner** compares current code and the current
normalized Figma subtree with an explicitly **Accepted Design Baseline**.
The **Project Design Context** projects that status, the entity links and the
exact next reads through one in-process Command Application. When semantic
paths exist, changed subtrees are reported with their current node ids;
plugin markers themselves never count as visual changes.
- A **Design Contract** turns one linked Design Entity's complete Design
Capture into a deterministic repository gate. Run `figma_run ["contract",
"capture","ui.button"]` once and review the JSON; later `figma_run
["contract","check","ui.button"]` reports canonical drift and separately
enforces variant matrices, token-binding floors, geometry tolerances and
prototype transitions. Volatile Figma handles are ignored and depth-limited
captures are refused.
- A versioned **Round-trip Fidelity Contract** classifies each core Figma fact
independently for Code → Figma and Figma → code, including its concrete
implementation and verification seam. CI fails when a core fact or direction
is unclassified; inspect the current projection with
`figma_reference {name:"fidelity"}`.
- One **Capability Catalog** resolves every Figma Command entering through MCP
into an immutable plan before either execution adapter runs it. That plan is
the single source for exposure, Figma/workspace/external-network/shared-local-state effects, target
need, confirmation, normalized paths, retry, timeout, accepted exit codes
and background-job identity. Unknown commands default to denied/write/no-retry.
- One **Workspace I/O** module confines Command Capability file effects to the
MCP launch directory by default. Paths are checked after symlink resolution,
opened files are revalidated before bytes are read or written, and structured
whole-file inputs have byte, nesting, collection and YAML-alias limits. This
does not narrow normal Figma-to-code or code-to-Figma work inside the project.
For a legitimate second repository, add canonical roots explicitly with
`FIGMA_BRIDGE_EXTRA_ROOTS` (use the platform path-list separator: `:` on
macOS/Linux, `;` on Windows). The launch directory always remains allowed.
- One immutable **Figma Target Context** resolves explicit `fileKey`, a pasted
Figma URL from that command's documented node field, a Design Link Registry,
or implicit single-window targeting once per command and then accompanies
planning, audit, job identity and daemon execution. URLs in text, JSX, names,
annotations, paths, labels, and remote assets remain payload and cannot
retarget a command. One shared
**Asset Policy** classifies image fills, vector art and vector clusters for
both Design Capture projections and export.
- One versioned **Dispatch Receipt** binds every authenticated Plugin action to
the selected connection epoch, file or draft session, expected editor and
advertised operation capability. The Plugin validates it immediately before
execution and echoes the same identity with a terminal state; receipt
identities never enter Audit or user-facing output.
- One deadline-aware **Plugin Write Lane** serializes conservative writes per
resolved document session before dispatch. Different files continue in
parallel; queued expiry or caller cancellation never reaches Figma, while an
unknown dispatched outcome quarantines only its target until the Plugin main
thread replays the exact settlement receipt. Aggregate queue state is visible
without exposing file, connection or receipt identities.
- Runtime protocol validators reject malformed HTTP execution payloads and
plugin frames at the transport boundary. TypeScript checks the JavaScript
seams (including the Figma plugin), while deterministic context, payload and
median and tail latency budgets catch architectural regressions in CI without
treating brief shared-runner scheduling pauses as a sustained regression.
- The **daemon** brokers commands to the Figma plugin over a localhost
WebSocket. Two gates protect it:
- **HTTP routes** (`/health`, `/exec`) require a per-request HMAC signature
keyed with the session token, a 0600 file — the token itself never crosses
the wire.
- **The plugin WebSocket** (`/plugin`) requires the **access key**: an
`Origin`/`Host` allowlist plus a **mutual challenge-response handshake** in
which the key is only ever an HMAC secret and never crosses the wire either.
This closes the upstream gap where _any_ local process could connect to the
plugin socket and run code in your Figma document — and the inverse gap,
where anything answering on a local port could drive an honest plugin.
- The unauthenticated `GET /plugin-ready` route is a CORS-scoped discovery
beacon containing only bridge identity, readiness and port. It carries no
file, token, version or connection data; every command route remains signed
and the discovered WebSocket must still complete the mutual handshake.
## Tools
| Tool | Purpose |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `figma_connect` | Ensure Safe Mode is available without replacing a healthy daemon/socket; self-heal a single unresponsive plugin iframe; explicitly recover one exact split execution lane with `{forceRecovery:true,fileKey}`; generate/show the access key and print plugin setup steps. |
| `figma_status` | Report local daemon/plugin/file/key state and, by default, run one bounded closed Figma main-thread responsiveness probe per target; `probePlugin:false` checks sockets only and `validateRest:true` explicitly checks the optional REST token. |
| `figma_pairing` | Show the access key; `{rotate:true}` generates a fresh one. |
| `figma_run` | Run a Capability Catalog-approved engine command; discover them with `figma_reference {name:"capabilities"}`. |
| `figma_render` | Render JSX into the open Figma design. |
| `figma_inspect` | Inspect a node by id: geometry, fills/strokes/effects, clip, opacity (YAML). |
| `figma_screenshot` | Save a PNG of a node/selection to a temp file (path + dimensions + applied scale returned). |
| `figma_spec` | Design-to-code spec of one node or a bounded `nodeIds[]` batch: real content, component names, tokens, vector-art refs, clip/abs — multiple same-scope reads can share one Manual Mode approval. |
| `figma_reference` | Offline Figma Plugin API reference (`api setup` once builds private state from the exact official typings with no network); `{name:"capabilities"}` lists commands and `{name:"fidelity"}` projects both workflow directions and their explicit boundaries without starting Figma. |
| `figma_history` | Private local capability history — filter by `nodeId` through redacted references, optionally merge `git log` of generated code files and (REST add-on) Figma version history via `includeVersions:true`. Or pass `diff:{from,to}` for a structural document diff. New records retain no raw commands, JSX, labels, file keys, paths, REST bodies, or errors; bounded legacy records remain readable. |
| `figma_selection` | The user's current selection in Figma (ids, names, types, sizes) — pushed live by the plugin. Instances resolve to their stable publish `key`; linked nodes show their Design Entity, code file and Storybook story. |
| `figma_comments` | REST add-on: read design-review comments (`action:"list"`) or post/reply (`action:"post"` — always previews first, needs `confirm:true`). |
Node ids are accepted in every form a user has at hand: `12:34`, the URL
form `12-34`, or a full Figma URL (whose file key is checked against the files
you actually have open — see [Several files at once](#several-files-at-once)).
`figma_run` with `["node","tree",...]` is a fast bounded outline for discovery
and follow-up node ids. It reports depth/output truncation explicitly; use
`figma_spec` plus a screenshot for exact Figma-to-code implementation facts.
An explicit detached node handle is rejected by `node tree`: Figma can retain
direct-id handles after a node leaves the canvas, so handle availability is not
present-tense hierarchy evidence. `figma_run` with
`["node","delete","12:34",...]` reports `removed`, `already-detached`, or
`not-found` per target and verifies every removal against the target's former
parent after first proving a complete parent-membership chain to the DOCUMENT
root. Nested deletion batches remain supported and execute deepest-first.
If Figma returns a 1-pixel PNG axis for a node whose scaled geometry is larger,
the Screenshot Command rejects the image before saving it and reports both the
actual raster and logical geometry. Refresh the identity with
`figma_run {args:["component","list"]}`, retry the exact current Variant id, or
capture its parent Component Set. This avoids accepting a stale or unsettled
Variant export as visual evidence.
The documented `figma_run ["export","node",…]` and
`figma_run ["export","screenshot",…]` names remain available. Their PNG mode
is a compatibility alias of the same Screenshot Command Application, so it
uses one bounded capture, validates the PNG before writing through Workspace
I/O, and reports dimensions from the encoded raster. JPG, SVG and PDF use a
separate Plan-bound Image Export Application. It validates bounded canonical
transport and the requested JPEG, SVG or PDF container before Workspace I/O;
these formats remain available without being misrepresented as verification
PNGs.
Write commands can be gated behind an explicit `confirm:true` by setting
`FIGMA_WRITE_CONFIRM=1` in the server's environment. The gate works on
subcommand level: reads like `node tree` or `component list` pass freely,
mutations like `node delete`, `combos`, or `tokens spacing` require confirm.
`figma_status` reports whether that policy is currently `required` or
`disabled`.
Omitting `confirm` or passing `confirm:false` is not a portable dry run: when
the policy is disabled, a valid write executes immediately. Use
`preview:true` with `figma_run` or `figma_render` whenever the request must be
guaranteed non-mutating. The Bridge validates the request and returns a
payload-free Command Plan without dispatching it; `preview:true` also wins when
`confirm:true` is present. The engine CLI remains direct execution and uses
only each command's explicitly documented `--dry-run` option.
Workspace file access is zero-configuration for the project from which the MCP
server is launched. For a multi-project workflow, configure additional roots
on the server process, for example on macOS/Linux:
```json
{
"env": {
"FIGMA_BRIDGE_EXTRA_ROOTS": "/work/shared-design-system:/work/second-app"
}
}
```
Use `;` between roots on Windows. Relative extra roots resolve from the launch
workspace. Paths outside the launch workspace and these explicit additions are
rejected without printing the private absolute path.
Remote raster images in `figma_render` JSX are opt-in. Configure exact origins
on the MCP/CLI host process; public sources require HTTPS, while intentional
private or loopback sources use the separate private-origin setting:
```bash
export FIGMA_BRIDGE_IMAGE_ORIGINS=https://images.example.com
export FIGMA_BRIDGE_IMAGE_PRIVATE_ORIGINS=http://assets.internal:8080
```
Multiple origins are comma-separated. Wildcards, URL credentials and fragments
are rejected. Signed query strings may be used to retrieve an image, but they
are never retained in the Render Plan, result, Audit Trail or source
provenance. The host reauthorizes redirects, validates every resolved address,
pins the approved connection, rejects compressed or oversized responses,
verifies MIME type against PNG/JPEG/GIF/WebP bytes and embeds the content by
SHA-256 identity. The Figma Plugin receives only verified bytes and remains
restricted to its existing localhost Bridge domains.
Local PNG/JPEG/GIF/WebP references use the same bounded asset contract.
Literal quoted and braced `src`/`image` forms resolve through the configured
workspace roots and converge on one content identity; empty values, runtime
expressions, unsupported formats and paths outside those roots fail before any
Figma mutation.
Safe Mode exposes the core canvas edits as typed commands; raw `eval` remains
blocked:
```text
figma_run {args:["node","duplicate","12:34","--name","Copy"]}
figma_run {args:["node","reparent","12:34","56:78","--index","0"]}
figma_run {args:["create","text","Watermark","--parent","56:78","-x","24","-y","24"]}
figma_run {args:["create","star","Badge","--points","8","--inner-radius","0.55"]}
figma_run {args:["node","boolean","subtract","12:34","12:35","--name","Cutout"]}
figma_run {args:["node","set","12:34","--rotation","8","--radii","4,8,12,16","--layout-mode","column","--padding","16"]}
figma_run {args:["component","instantiate","12:34","--parent","56:78"]}
figma_run {args:["component","prop","set","12:34","State","Active"]}
figma_run {args:["gradient","apply","12:34","linear-gradient(90deg, #7c3aed, #06b6d4)","--field","stroke","--stroke-weight","1"]}
```
The read-only `canvas info` and `canvas next` commands share one targeted
Canvas Awareness path across CLI and MCP. They wait for dynamic page loading,
validate the returned aggregate geometry, and accept only a finite numeric gap
plus the explicit `right` or `below` direction before anything reaches Figma.
This keeps automatic Code-to-Figma placement useful without treating option
values as executable code.
`canvas pages` and `canvas page` likewise use one Page Navigation path across
CLI and MCP. Page listing is a bounded safe read; switching is explicit,
non-retrying, and resolves only an id, exact name, or unique substring. Use
`node:<id-or-Figma-URL>` for explicit node identity or `name:<literal>` for an
explicit page name; an unprefixed value remains the compatible `query:` search.
Only the explicit node form may derive the target file from a Figma URL. It
changes the active editor page without modifying document content, while
ambiguous names fail instead of selecting a page by guesswork.
The same discriminator protects union lookups in Figma Slides and
`component add-variant`: `node:`, `name:` and (for Slides) `label:` select one
identity surface, while bare values preserve the existing query workflow.
URL-shaped names and labels therefore stay usable without silently retargeting
the Command. `component add-variant --from` also restores and freshly verifies
TEXT, BOOLEAN, INSTANCE_SWAP and SLOT sublayer references plus nested Instance
exposure that Figma's native clone can drop.
Top-level `find` provides bounded Node Discovery on the fully loaded current
page across CLI and MCP. Case-insensitive partial-name matching, the optional
node-type filter and a 1–200 result limit are parsed as inert data before
readiness. Search stops immediately when the result limit is reached and after
100,000 visited nodes; either bound is reported so follow-up reads never rely
on a silently incomplete node-id list.
The mutating `canvas page-create` and `canvas page-divider` commands share a
separate non-retrying Page Structure path. Duplicate page names and divider
indexes are validated before creation. Optional divider names must use Figma's
native all-asterisk, all-dash or all-space form so the command cannot silently
create a normal page. A failure after creation triggers Command-owned cleanup
and reports possible residue truthfully if Figma cannot remove it.
`node duplicate` works for cloneable design nodes including Sections;
`node reparent` accepts an explicit child index and preserves canvas position
when the destination is not Auto Layout. `create frame|rect|ellipse|polygon|star`
and `vector|slice|line|autolayout|text` use action-specific, readback-verified creation Applications
across CLI and MCP. They accept an explicit parent where Figma permits it, so
small children can be added to an existing frame without rebuilding it. Text
creation also preflights the requested font before creating its node.
ID-scoped `node group|ungroup|boolean|flatten` replace selection-dependent
structural edits. Components can be created, instantiated, swapped, detached,
and have their overrides reset or compacted without relying on the current
selection. The direct `node set` surface covers geometry, visibility/locking,
fills/strokes/effects, blend and mask settings, individual corners, constraints,
Auto Layout container/child properties, polygon/star geometry, and Section
visibility. The single-node command preflights all supported properties and
Variable references, freshly verifies the complete result, and restores its
full field journal if any setter or postcondition fails. Unsupported fields
remain explicit skips, while ambiguous Variables stop before mutation. Legacy
selection-based `create` subcommands remain unreachable through `figma_run`;
VectorNetwork input is bounded inert JSON on its typed route.
Dev Mode `measure add|edit|delete` mutations share one Plan-bound Application
across CLI and MCP. Add and edit own rollback; delete reports its irreversible
freshly verified outcome without retrying after dispatch.
`component main <instanceId>` is the bounded identity read for reuse. Commander
and MCP resolve the same explicit Instance through one safe-read Command
Application and return its native main component, optional set, publish keys,
variants and component-property facts. A full Figma node URL may supply the
target file; display names remain descriptive and are never treated as durable
component identity.
The operation audit is pinned to the installed official
`@figma/plugin-typings` version. It exhaustively classifies every PluginAPI
creator and structural method, checks every engine command against the Safe
Mode Capability Catalog, and checks all official variable/easing value types.
A Figma typings upgrade or newly registered command therefore fails CI until
the new operation is supported or recorded as an explicit boundary. Run
`figma_run {args:["api","gap"]}` for the current direct/alternative/boundary
summary.
Native JSX instances require durable Registry identity (`entity` plus a
published `key` or local `id`). Their editable overrides use the component's
real Figma structure:
```jsx
<Instance
entity="ui.card"
prop:Selected="true"
text:Title="New title"
fill:StatusDot="var:status/healthy|#22c55e"
swap:LeadingIcon="ui.icon.leaf"
/>
```
`prop:` resolves a component-property definition; `text:` and `fill:` resolve
one named descendant. `swap:` values and INSTANCE_SWAP property values are
Design Entity ids, resolved from `figma-bridge.json`; component display names
are intentionally not accepted as swap identity. Missing, ambiguous or
unlinked targets stop preflight before the first canvas node is created.
JSX and browser capture carry only the Design Entity plus variant/override
intent. Command preparation reads the Registry once and embeds a fingerprinted,
target-aware binding table in the Semantic Render Plan. Published keys are
preferred; an unpublished local node remains usable only when its Registry file
key exactly matches the resolved Figma target. Authored `key`, `id` and
`component` name handles cannot replace that binding. The generated
compatibility path consumes the same table, so it does not reintroduce a second
identity lookup.
For raster content, `<Image src="assets/artwork.png" />` is materialized by the
host as bounded embedded bytes and stays on the native executor. Image bytes
ride inside the plugin payload as base64 against a 5 MB protocol value, so one
file may be at most 3.5 MB and a render measures its whole plan before
dispatch: several images that each fit still share that one payload, and the
refusal says so before the plugin is touched. An intentional
`<Image name="Artwork placeholder" />` remains useful, but it selects the
generated compatibility executor and returns `completed-with-findings` with the
exact `image needs host-materialized embedded bytes` degradation. A placeholder
therefore never counts as native or pixel-exact image fidelity. The executable
MCP form, Registry setup command, and current element-specific props are kept in
`figma_reference {name:"workflow:code-to-figma"}`; use
`figma_run {args:["render","--prop-reference","Image"]}` for the bounded Image
prop reference.
Dimensions and typography accept the same `var:name|fallback` form. The native
executor binds width, height and min/max constraints plus font family/style,
weight, size, line height, letter spacing, paragraph spacing and paragraph
indent. Family/style use STRING variables; the other typography and dimension
fields use FLOAT variables. A missing bound font stops preflight with an
install-or-choose-another-face message instead of silently substituting it.
Named Text Styles are reconciled before canvas creation as well: an explicit
`style="Typography/Eyebrow"` is reused only when its complete typography
matches; a conflicting same-name style stops, and otherwise exact typography
is reused. Unmatched implicit typography stays literal by default instead of
creating a parallel style. Set MCP `materializeResources:true` or CLI
`--materialize-resources` when the source intentionally asks Figma Bridge to
create generated Text Styles and spacing/radius Variables. Explicit named
styles and `var:` fallbacks remain available without that flag because they
already carry authored resource intent.
Line height keeps its authored unit in the Semantic Render Plan: quoted
unitless CSS multipliers such as `lineHeight="1.1"` and percentages such as
`lineHeight="110%"` become Figma `PERCENT`; explicit
`lineHeight="24px"` and numeric JSX `lineHeight={24}` remain `PIXELS`; and
`lineHeight="auto"` remains `AUTO`. Negative, non-finite, or unknown values
stop before mutation. Text Style matching compares both unit and value.
Figma float32 metric readback is normalized for stable comparison, and
family-specific faces such as DM Sans/Manrope `SemiBold` and `ExtraBold` are
tried before any fallback family. Successful native renders return
`textStyleReport` and `variableReport` counts for references,
unique reused resources, created resources, bound properties and literals kept
as literals. After a native render the text is measured back: every rendered
text is matched to the plan by the semantic path the executor stamps on it,
and a font family or style that differs from the requested one, or a line
count that differs from what the browser measured (`render --dom-capture`
carries the browser's line count beside the plan), becomes a finding — the
`text-fidelity` stage in the outcome, printed after every CLI render. The
native executor falls back to Inter when a family is not installed; that
fallback is now visible instead of silent. Ambiguous or unsupported preflight errors include the
corresponding zero/nonzero counts and do not leave newly created variables or
canvas nodes behind. Command Preview reports the generated-resource policy
without echoing JSX or other private payloads.
`<Text>` also preserves editable inline Rich Text. Nested `<strong>/<b>`,
`<em>/<i>`, `<u>`, `<Span ...>` and `<a href="...">` markup becomes native
Figma ranges; HTML entities are decoded before UTF-16 range offsets are
calculated. Span runs support `font`, `fontStyle`, `weight`, `italic`, `size`,
`color`, `letterSpacing`, `underline`/`decoration` and safe links:
```jsx
<Text font="Inter" size="14">
Hello{" "}
<strong>
bold <em>and italic</em>
</strong>
<Span color="#ef4444" size="18">
red
</Span>
<a href="https://example.com">link</a>
</Text>
```
## Plugin window
The Figma Bridge plugin window is more than the connection status:
- **Compact by default** — the always-on view is a small status card plus one
control row. Activity and Setup expand only when needed and close each other,
so the panel does not cover the canvas after the one-time key entry.
- **Save history** — the primary safety action, available after the optional
REST token is configured. It writes a human-readable labeled checkpoint such
as `Figma Bridge checkpoint · 21 Aug 2026, 14:05` into Figma's own version
history. There is no restore API for plugins: you roll back through Figma's
version history panel.
- **Pause agent** — a kill switch: while paused, the plugin rejects every
incoming agent command with an explicit error.
- **Activity** — a secondary icon in the title bar opens every command the
agent runs, with duration and ok/error state. A small badge carries the event
count without competing with the safety controls.
- **Selection readout** — whatever the user selects is pushed to the agent
automatically (debounced) and shown as "Agent sees: …", so the user always
sees what `figma_selection` will return. Select a frame, say "build this" —
no node-id copying.
- **Setup** — the access key (masked by default, with Show/Hide) and the
optional REST token, always reachable whether or not the bridge is connected.
## Design-to-code workflow
The design is the complete specification — the tooling makes copying it easier
than interpreting it. Build a screen from Figma in six steps:
Keep the target project's framework and styling system. Do not add Tailwind, a
UI kit or an icon library solely for the screen, and never replace exported
Figma artwork with a convenient approximation. Reuse a project component only
when its rendered design and states actually match.
1. **`figma_screenshot`** on the target frame, then read the saved PNG — the
visual ground truth. Never build from a node tree alone.
2. **One `figma_spec` with `phase: "all"`, `depth: 3–4`** (for a large screen:
`depth: 30` plus `outFile`, which stitches the complete capture from bounded
plugin evals under one observed revision and writes it to the workspace —
implement from that file, never from the screenshot) — build the markup
skeleton and its bounded exact styles: real text characters, resolved
icon/component names (instances are
descended into, so overrides and true main-component names appear),
hierarchy and flex direction. Copy texts and icons verbatim. A
`layout:inferred (Figma heuristic — verify)` marker is not authored Auto
Layout; check the hierarchy before treating it as the component contract.
3. **Export tokens** (`figma_run` with `["export","css"]` or
`["export","dtcg"]`) and wire them up as CSS variables / theme. The output
names its source Figma file — check it is the file you are building.
4. **Export assets** (`figma_run` with
`["export","assets","<nodeId>","-o","/abs/path/src/assets"]`) — every
`→ assets/…` reference in the spec points at a file this writes. Pass an
absolute path; large exports keep running in the background ("still
RUNNING") — re-run the same call to poll. Manifest v2 keeps source identity,
content digest, semantic label, physical filename and placements separate.
`assets.json` is merged across runs and byte-identical assets are deduped;
same-name/different-content collisions get a stable digest suffix instead of
overwriting an earlier file. Existing v1 manifests remain readable. Each
asset carries placement data (`x`/`y` offsets, `parent` name path,
`parentId`, `absolutePosition`, `overhang`), so the manifest alone positions
an overlay — no spec cross-reference needed. The export summary lists the
absolutely-positioned and overhanging files explicitly: those are the ones
builds lose. After export, `assets.json` is authoritative for any
collision-resolved filename.
Oversized PNGs are downsampled by default to 2× their largest Figma usage
(retina density), without upscaling and only when the encoded file becomes
smaller. Aspect ratio, manifest placement and CSS crop behavior remain
unchanged; pass `--raster-scale 0` to retain original PNG bytes.
CLI and MCP now execute this through one **Asset Export Command
Application**. Equivalent default flag spellings attach to the same tracked
job; polling never starts a duplicate. Every multi-step read is pinned to
one daemon-confirmed Figma connection, file and target-Page revision. A change
aborts before publication. Document-wide revision observation initializes
only for freshness-sensitive reads; ordinary eval/render paths keep Figma's
dynamic page loading. A process-safe output lock serializes CLI/MCP
merges into the same directory. A partial byte-export failure keeps
successful files, preserves last-known-good failed placements and reports
every failed asset. If all selected assets fail, the command returns an
error and leaves an existing `assets.json` unchanged instead of replacing
it with an empty manifest. Asset bytes use an authenticated, digest-checked
stream outside the generic JSON result: each 256 KiB chunk is acknowledged
before the next, the Daemon download is signed and one-shot, and the
publication Worker stages each asset before requesting another. A single
PNG/SVG may therefore exceed the former Base64 ceiling without raising the
5 MiB result limit or retaining all export bytes in memory. Figma still
supplies one complete byte array per individual asset; the bound is the
largest current asset (maximum 512 MiB), not the total export.
Pass `force:true` to `figma_run` when an intentional fresh export should
bypass the short retry-result cache; a running job is still never duplicated.
5. **One `figma_spec` `nodeIds[]` batch for any missing deeper styles** — put
all section/node reads into the same call instead of requesting each
section separately. In Manual Mode this means one approval for the batch.
Use `phase: "style"`, `depth: 0` for exact containers and `dedup: true` for
repeated lists/cards. Apply sizes, gaps, padding, alignment, fill/hug
sizing, paints incl. gradients (`→ var(name)` marks a
design-token binding), radii, shadows, typography, `opacity`, `clip`
(overflow hidden) and `abs` positioning. Decorative vectors appear as
`vector art → assets/…` lines with placement — place the exported SVGs,
never approximate them in CSS.
Structured YAML/JSON additionally retains exact component property
definitions and values (including INSTANCE_SWAP and SLOT), property
references, preferred values, direct overrides, exposed instances and slot
violations. Variable bindings include collection identity, authored scopes,
explicit/resolved modes, `codeSyntax.WEB` and the resolved value;
`inferredVariables` is emitted separately as suggestion-only evidence.
For a large section, request `depth:0` first. This is a complete contract
for the section container itself (including background, border, radius and
layout) without descendants. Then request child node ids in bounded calls.
Use `dedup:true` for repeated cards/lists; shared `S<n>` references remain
lossless and stop identical instance styles exhausting the result budget.
6. **Verify** — screenshot your build and compare against the PNG from step 1,
then run the mechanical check:
```
figma_run ["verify-build", "/abs/path/to/project"]
```
It greps the project against `assets.json` and lists every exported file
that is _not_ referenced in the build — with size, offsets and parent, so
placing it is one step — plus a `border-image` lint (CSS `border-image`
ignores `border-radius`; gradient strokes on rounded boxes need the
wrapper or mask pattern). Exit code 1 when files are missing, so it works
as a CI gate too.
With a build screenshot it also runs the **visual pass**:
```
figma_run ["verify-build", "/abs/path/to/project", "--compare", "/abs/build.png"]
```
The reference render is fetched live from Figma (`--node <id>`, default:
the manifest's export root) or supplied offline via `--design <png>`.
Both images are normalized to a common width and pixel-diffed
(antialiasing-tolerant); the output reports the overall diff percentage,
a height-mismatch finding (build too tall/short = inserted or dropped
block), the worst differing regions in node-pixel coordinates — the same
space the spec and `assets.json` use — and writes a diff PNG (red =
differing, on the dimmed design). Informational by default;
`--max-diff <pct>` gates the exit code.
For large screens, section-level agents are an optional elapsed-time
optimization after the screenshot, structure map, tokens and assets are fixed.
Use them only for substantial sections with disjoint component/style files;
the coordinator keeps ownership of the shared shell, tokens, `assets.json`,
integration and final pixel diff. Parallel agents usually consume more total
tokens because each needs project context, so use sequential work when token
cost matters more than wall-clock time.
Use an existing browser tool or project harness for the build screenshot. Do
not install Playwright (or another browser dependency) solely for capture
without the user's approval; if it is already present, it is a valid capture
mechanism rather than a Figma Bridge dependency.
The same spec is available as
`figma_run ["export", "code-spec", "<nodeId>"]`. Its default is the readable
tree; pass `-f yaml` or `-f json` for the canonical model.
### Lossless structured spec formats
`figma_spec` and `export code-spec` default to `format:"tree"`, the concise,
line-oriented agent view whose footers carry the required asset and fidelity
actions. Use `yaml` or formatted `json` explicitly when a consumer needs the
versioned canonical model. Both structured formats serialize the **same model**;
only syntax differs. Roundtrip tests require every field — text,
ids, layout provenance, paint, typography, mode-aware variables, assets,
component contracts, Bridge intent, native annotations, capture
completeness, and fidelity checks — to survive exactly. Minified JSON is not
offered: real agent tests showed that a single huge line was materially harder
to act on despite carrying the same raw fields.
The model's `capture` field explicitly reports requested/actual depth,
payload completeness, hidden-node policy, and whether the requested depth cut
off descendants. There is no silent tool-result truncation: if a spec exceeds
the configured output budget, the call returns `complete:false` with a
section-by-section retry recipe and returns **no misleading partial design**.
`depth:0` intentionally means “the requested node only” and is complete, not
a depth-truncated tree.
Every Design Capture also carries a hidden-content census independent of
depth and `includeHidden`: total, visible, hidden and hidden-text counts plus
bounded layer ids and text previews. Hidden layers remain excluded by default
so they cannot become phantom UI, while Code-Spec marks exact hidden content
and alternate-state inspection incomplete until the same node is requested
with `includeHidden:true`.
Used Component Sets are keyed by Design Entity, published set key, or local
set id—not by their mutable display name. Code-Spec normalizes `State`,
`Status`, `Interaction`, and Boolean component properties and reports every
set as `defined`, `noneDefined`, or `notCaptured`. A `notCaptured` set blocks
style output and returns one bounded `nodeIds[]` batch recipe, avoiding both
guessed states and repeated Manual Mode approvals.
CSS and DTCG token export preserve every Figma collection mode and resolve
aliases per mode. DTCG keeps the complete mode table in the
`figma-bridge-mcp` extension while its normal `$value` remains the default
mode for standard consumers. CSS emits the default under `:root` and each
additional mode under `[data-figma-mode="<Mode name>"]`; applying that
attribute is an explicit application responsibility. Different mode values
are never converted into `clamp()` without authored viewport/min/max intent.
Color aliases with Figma-authored opacity are read as the native closed
`COMPOSE_COLOR(alias, opacityPercent)` expression. CSS receives the resolved
alpha-composed color; DTCG additionally preserves the exact Alias name,
Variable id and opacity percentage in the Bridge extension.
Public MCP failures use stable error kinds and user-facing details; internal
Node executable paths and command arguments are never returned. Safe read
recovery keeps its single retry, but the operation, readiness wait and CLI
fallback all spend the same overall deadline. A missing daemon token goes
straight to the existing startup fallback instead of waiting for readiness.
For large screens, first request one shallow structure map, then send up to
eight bounded section/style reads through `nodeIds[]` in the next single
`figma_spec` call. The batch fails closed on the first failed read or when its
combined result would exceed the output budget; it never presents partial
batch data as complete.
IMAGE-fill filenames are keyed by Figma's stable image hash, not by the local
layer name/path. This keeps `figma_spec`, isolated child calls, asset export and
`assets.json` on the same filename even when generic layers such as “Frame 64”
are reached through different roots. Linked vectors use their Design Entity
identity; unlinked vectors fall back to canonical visual content identity,
never a volatile node id. `verify-build` recomputes Manifest v2 digests from
the physical files and exits non-zero for tampering or missing asset files;
v1 entries without digests remain compatible but are reported as unverified.
For MCP design-to-code calls, `dedup:false` is the default: every visible layer
keeps its own id, native Figma Inspect `css{…}`, layout/paint/token facts and
complete text. Mixed rich-text layers carry their individual styled ranges.
The footer reconciles the live visible-layer count with explicit rows, SVG
internals, component internals and non-rendering helpers. A style projection
is rejected when depth limits or an unaccounted layer would force guessing;
split it by the node ids from the structure map. Set `dedup:true` only for a
compact overview using shared `S<n>` style and repeat references.
For repeated explicit-node calls, `phase`, `format` and deduplication do not
trigger another full Figma walk. The in-memory Design Capture cache is bounded
to 8 entries / 8 MiB by default (`DESIGN_CAPTURE_CACHE_ENTRIES` and
`DESIGN_CAPTURE_CACHE_BYTES`). Every hit still probes the live document
revision; there is no TTL and no stale-while-revalidate path.
## Code ↔ Figma design memory
Give each important component, screen or frame a durable **Design Entity** id.
The id describes the concept, not its current location: use names such as
`ui.button`, `ui.account-card` or `screen.settings`.
After selecting or identifying a Figma node, an agent can create the link with:
```text
figma_run {args:["link","set","9:9","screen.settings","--kind","screen","--source","src/routes/settings.tsx","--export","SettingsScreen","--story","screens-settings--default"], confirm:true}
```
This converges two small adapters:
- `figma-bridge.json` is the committed, reviewable Registry with repo-relative
code paths plus optional Storybook and Figma handles.
- Figma stores only `{version,id,kind}` as plugin data on the node. It contains
no local path, credential or machine-specific state.
Use `figma_run ["link","inspect","9:9"]` to resolve a node and
`figma_run ["link","list"]` to inspect the repository memory without reading
Figma. Once linked, `figma_selection` and `figma_spec` automatically expose the
same id and the Registry's code/Storybook targets. Agents should reuse or edit
that code component instead of creating a look-alike. Repeating the same `set`
command is safe and repairs either side after an interrupted write.
After visually verifying that code and Figma correspond, explicitly record
their current fingerprints. Screen entities require a real browser screenshot
and a passing pixel threshold:
```text
figma_run ["link","accept","screen.settings","--compare","/abs/build.png","--max-diff","5"]
```
Source code is never stored in the Registry. The initial code Adapter hashes
the complete linked file plus its export identity; therefore an unrelated edit
in a shared file may conservatively report a code change, but a real change is
never hidden. The Figma Adapter hashes the normalized linked subtree. For
Code-to-Figma nodes it also stores each unique `figmaBridge.semanticPath` with
that node's subtree hash. A later `link status` can therefore list the exact
added, removed or changed semantic paths and recommend node-scoped specs.
Changing a semantic marker alone does not change the visual fingerprint;
duplicate paths are reported as ambiguous instead of guessed.
```text
figma_run ["link","status","screen.settings"]
figma_run ["link","context","screen.settings"]
```
| Status | Meaning |
| ------------ | ---------------------------------------------- |
| `unchanged` | Neither side moved from the accepted baseline. |
| `code-only` | Only the linked code file moved. |
| `figma-only` | Only the linked Figma subtree moved. |
| `conflict` | Both moved; neither side is overwritten. |
| `untracked` | No baseline has been explicitly accepted yet. |
`link context` is the preferred agent entry point after a link exists. It
returns the smallest relevant projection: entity, code/export, Figma root,
Storybook story, current Round-trip Plan, discovered `DESIGN.md`/token files
and exact next reads. It is generated on demand, not persisted as another
memory file. `link accept` writes only `figma-bridge.json`; it never changes
Figma or code. For screens it also stores the measured diff and SHA-256 hashes
of both comparison images, so a structural fingerprint cannot certify a
visibly wrong baseline.
Conventional `DESIGN.md`, `design/DESIGN.md`, `tokens.json` and
`design/tokens.json` locations are discovered automatically. Configure custom
repo-relative locations once when needed:
```text
figma_run ["link","configure","--design-doc","docs/product-design.md","--tokens","src/theme/tokens.json"]
```
Commit `figma-bridge.json`. Do not put secrets, absolute paths or generated
credentials in it.
### Reviewed CSS ↔ Figma boundary strategies
Semantic Code-to-Figma uses stable policy ids rather than silent visual
substitutions: `minmax.native-grid`, `space-around.equal-slots`,
`border.single-paint-native`, `sticky.metadata-only`, `filters.layer-stack`,
`masks.vector-mask`, `font.named-faces`, and `figma-effects.native`. The full
matrix and its remaining hard stops live in
[`docs/css-figma-semantic-matrix.md`](https://github.com/KaiUweHella/figma-bridge-mcp/blob/main/docs/css-figma-semantic-matrix.md).
Reviewed lossy policies can opt into an automatic native Figma annotation on
the exact affected semantic node. The annotation explains the unsupported CSS
fact, links the relevant Figma properties and is mirrored as versioned
`figmaBridge.fallbackAnnotations` plugin data for future agents. Equivalent
native conversions remain unannotated to avoid review noise. The first active
policy is `border.single-paint-native`: Figma receives the first explicitly
painted CSS side as the shared native stroke, retains all four side weights,
and marks `strokes` plus `strokeWeight`. Native renders report how many
fallback annotations were added, deduplicated or unsupported.
Intrinsic auto-sized DOM containers and their single-line text map to Figma
HUG sizing when the parent does not stretch them. Explicit fixed dimensions
stay fixed. Text that participates in an authored stretch/available-width or
wrapping relationship maps to FILL instead; centered or end-aligned FILL text
keeps that intent through the native Text alignment property. Positioned text
keeps measured box geometry. The bridge does not add arbitrary percentage
width headroom to prevent wrapping.
Variable-font axes are captured, but the structural gate asks whether the
required font should be installed or an available named face should be used
before rendering. Native Figma Glass remains an editable native effect with
all effect parameters retained; it is not silently treated as CSS
`backdrop-filter`, because Figma's CSS export does not expose those Glass
parameters.
## Storybook mirroring
Figma components carry a **stable publish key** (survives library publishing;
node ids are file-local). The key now flows through `figma_spec` (canonical
structured model + the "Component sets used" tree trailer), `figma_selection`, `component
list`, `figma_inspect`, and DESIGN.md.
To link them to their code mirror:
```
figma_run ["map", "storybook", "http://localhost:6006"]
```
The conventional local origins `localhost:6006`, `127.0.0.1:6006`, and
`[::1]:6006` work without configuration. Approve an exact hosted HTTPS origin
or an intentional private-network origin before using it:
```bash
export FIGMA_BRIDGE_STORYBOOK_ORIGINS=https://storybook.example.com
export FIGMA_BRIDGE_STORYBOOK_PRIVATE_ORIGINS=http://storybook.internal:6006
```
Configured values are exact origins, not wildcard URLs. A Storybook base path
such as `https://storybook.example.com/design-system` may then be passed to the
command. Every redirect must lead to another approved origin. You can also pass
a local Storybook directory; those reads stay inside the configured workspace
roots.
This matches the file's components against the Storybook index by normalized
name and writes **`figma-map.json`** into your project: Figma key ↔ story id /
import path, with a `confidence` per match plus both unmatched lists. Edit
entries by hand and set `"matchedBy": "manual"` to pin them — pinned entries
survive re-runs. When the file exists, `figma_selection` and `figma_spec`
annotate components with `↔ story <id> (<importPath>)` automatically.
`figma-map.json` remains a legacy read adapter, so existing mappings continue
to work. New durable links belong in `figma-bridge.json`; `link set` never
copies legacy rows into it. Migrate a component when you next touch it by
assigning its real Design Entity id and passing its story via `--story`. Remove
the legacy file only after `link list` shows every mapping you still need.
## Bring your own design system
This project ships **no** design system — no shadcn, no Tailwind preset, no icon
pack. That is deliberate: a bundled system is someone else's opinion rendered
into your file. What it ships instead is a way to make _your_ system legible to
an agent in one command:
```
figma_run ["kit", "init", "./my-app", "--storybook", "http://localhost:6006"]
```
Four reads, one report:
| Step | Result |
| ---------------------------- | -------------------------------------------------------- |
| `extract` | `design/DESIGN.md` — structure, tokens, variant matrices |
| `export dtcg` | `design/tokens.json` — W3C design tokens |
| `component list --all-pages` | inventory with stable publish keys |
| `map storybook` | `figma-map.json` — Figma component ↔ story |
It ends by naming what is still missing — an unmapped Storybook, components with
no story, the `tokens sync` command that keeps the two in step — because a setup
that quietly lacks the mapping looks finished until an agent needs it.
DESIGN.md is what an agent should read first; `tokens.json` is what it binds to.
## Several files at once
The bridge holds one connection per Figma window in which you started the
plugin. That is the consent model: a file is reachable because _you_ opened it
and launched the plugin there — not because a flag widened the scope.
- **One window** — nothing changes. Commands go there.
- **Several windows** — a command must name its target, or it fails with the
list of connected files:
```
figma_status # lists every connected window
figma_run {args: ["canvas","info"], fileKey: "GY5SasBJ…"}
figma_spec {nodeId: "12:34", fileKey: "GY5SasBJ…"}
```
`figma_render`, `figma_selection`, `figma_inspect`, `figma_screenshot`, and
`figma_spec` accept the same `fileKey` parameter. A full Figma node URL also
supplies its file key automatically. Without a target, `figma_selection`
says which files are open rather than guessing. On the engine CLI the flag is
`--figma-file`, not `--file`: `eval` and `spec` already use `-f, --file`
for a local path.
There is deliberately **no "all files" option**. Every write names one file, so
a mistaken command cannot fan out across a library. Two windows on the _same_
file are indistinguishable for routing, so the newer one takes over and the
older is told it lost the bridge. Audit entries carry only a redacted target
summary, so multi-file operations remain correlatable without retaining a raw
file key.
Reaching files you have _not_ opened is out of scope: Figma's REST API cannot
write document content, so a bulk rename across thirty library files is not
something this tool can honestly offer.
## FigJam
The plugin runs in FigJam boards too, over the same bridge — no second
transport, no extra permission:
```
figma_run ["jam", "sticky", "Ship the handshake", "--color", "green"]
figma_run ["jam", "stickies", "[\"Discovery\",\"Build\",\"Ship\"]", "--columns", "3"]
figma_run ["jam", "shape", "Decide?", "--type", "DIAMOND"]
figma_run ["jam", "connector", "1:2", "3:4", "--text", "yes"]
figma_run ["jam", "table", "3", "4", "--data", "[[\"Step\",\"Owner\"],[\"Handshake\",\"Alex\"]]"]
figma_run ["jam", "board"] # read everything back, with connectors
figma_run ["jam", "arrange"] # arrange only the current selection
figma_run ["jam", "arrange", "--ids", "1:2,3:4"]
figma_run ["jam", "arrange", "--all"] # explicit: whole page
```
New nodes land to the right of whatever is already on the board unless you pass
`--at x,y`, so an agent adding to a populated board does not stack everything at
the origin. Every command checks `figma.editorType` first and says "this is a
figma file, not a FigJam board" rather than failing on an undefined API.
`figma_status` reports which editor the bridge is attached to.
`jam arrange` is deliberately selection-scoped. Agents can pass exact node ids
without changing the user's selection; rearranging the whole page requires the
visible `--all` flag. Sections and connectors are never moved by this command.
The public surface was exercised in Figma Desktop on 2026-08-10. Maintainers
keep the detailed command and readback evidence outside the public repository.
## Figma Slides beta
Slides uses the same authenticated plugin bridge and the same Semantic Render
Plan; there is no separate presentation renderer. The surface covers deck
structure, native slide properties, deck-wide reads, bounded batch edits,
media insertion and one explicit render target:
```
figma_run ["slides", "inspect"] # deck grid, focus, labels, transitions
figma_run ["slides", "manifest"] # compact per-slide projection for review and version control
figma_run ["slides", "lint", "--fail-on-issues"] # duplicate-name, skipped-placement, missing-transition, empty-slide, unlisted-slide
figma_run ["slides", "create", "Agenda", "--row", "0", "--col", "1"]
figma_run ["slides", "duplicate", "Agenda", "--label", "Agenda alternative"]
figma_run ["slides", "move", "Agenda alternative", "1", "0"]
figma_run ["slides", "transition", "Agenda", "DISSOLVE", "--duration", "0.4"]
figma_run ["slides", "skip", "Appendix", "on"]
figma_run ["slides", "delete", "1:42"]
figma_run ["slides", "batch", "[{\"op\":\"create\",\"label\":\"Q&A\"}]"] # plan only
figma_run ["slides", "batch", "[{\"op\":\"create\",\"label\":\"Q&A\"}]", "--apply"]
figma_run ["slides", "media", "label:Agenda", "assets/loop.gif", "--fit", "FIT"]
figma_run ["slides", "focus", "label:Agenda"]
figma_run ["slides", "view", "single-slide"]
figma_run ["render", "<Frame>…</Frame>", "--slide", "12:34"] # render one plan into one slide
```
Figma renumbers native slide names whenever the canvas grid changes. The
optional argument to `create` and `--label` on `duplicate` therefore store a
durable Bridge label in plugin data; `inspect` and `manifest` report both the
native `name` and the stable `label`. References resolve by id, exact native
name or label, then unique substring; typed `node:`, `name:` and `label:`
prefixes restrict the lookup. Ambiguity is an error, delete always requires an
explicit reference, and duplicate/move refuse a nonexistent target row rather
than accepting Figma's fallback placement.
`render --slide <nodeId>` takes a node id from `inspect`; it deliberately does
not accept a name or label, because a render must never pick its own target
when a reference turns out ambiguous. The root lands in the slide's own
coordinate space. `render-plan-batch` stays refused in Slides.
`batch` is a plan by default and performs nothing until `--apply`; every
reference and coordinate is validated against one grid snapshot first, a
coordinate that only becomes valid because of an earlier item is refused, and
the first failing item reverses everything applied before it. A created
slide whose grid position Figma has not listed yet is verified by identity,
label, focus and selection and reported as `gridObserved: false`.
`media` inserts a still image (`IMAGE` paint), an animated GIF (a playable
`MEDIA` node) or a video (`VIDEO` paint) into one slide. The file rides inside
the plugin payload as base64, so the ceiling is 3.5 MB of file bytes — the
same ceiling `render` and `node set-image` apply to images.
Two facts about Figma's canvas grid shape every command. `figma.getCanvasGrid()`
can lag the page tree by minutes: a slide that exists on the page may be
missing from the grid, or the grid may keep a removed slide's id and throw on
every read. The Bridge treats the grid as authoritative for coordinates and
the page tree as authoritative for existence. Readers list such slides under
`unlisted` with no coordinate (and say why when the grid cannot be read at
all), `lint` reports them as `unlisted-slide`, `delete`, `transition`, `skip`,
`media` and `focus` still reach them, and `move` refuses one with the reason
— without a coordinate there is no origin to restore. Every operation checks
`figma.editorType === "slides"` before touching a Slides-only API. Polls,
facepiles, embeds and speaker notes have no public create or read API; the
roadmap in [`docs/slides-roadmap.md`](https://github.com/KaiUweHella/figma-bridge-mcp/blob/main/docs/slides-roadmap.md) records what was
probed against the live runtime, and editor acceptance is maintainer-verified
separately from the public repository.
## Token sync (two-way)
`tokens import` only ever creates, so a value edited in code never reaches an
existing Figma variable and a value edited in Figma never reaches code.
`tokens sync` closes that loop:
```
figma_run ["tokens", "sync", "src/tokens.json"] # plan only
figma_run ["tokens", "sync", "src/tokens.json", "--apply"] # write it
```
The import surface is broader than the sync surface. One-shot `import` accepts
Tailwind v3 config, Tailwind v4/CSS, Storybook indexes, DTCG/W3C JSON, and the
DTCG-compatible token shapes exported by Style Dictionary and Tokens Studio.
That compatibility does not include Tokens Studio theme semantics or arbitrary
preprocessors; metadata such as `$themes` is ignored while token sets and
aliases are read.
Tailwind JS/CJS/MJS/TS configuration is parsed as static data. The importer
understands ordinary object exports, `theme`, `theme.extend` and typed
`satisfies Config` forms without resolving imports or executing plugins.
Dynamic expressions inside token-bearing theme fields stop with instructions
to import Tailwind v4 `@theme` CSS or resolved W3C JSON instead. Unrelated
fields such as `content` and `plugins` remain compatible but are discarded.
New FLOAT variables in explicit `spacing/*` or `space/*` namespaces are scoped
to Figma's `GAP` consumers only. `radius/*` and `radii/*` variables are scoped
to `CORNER_RADIUS` only. The inference is deliberately namespace-exact:
names such as `spacingFactor` are left at Figma's default scope, and rendering
does not silently change the scopes of existing user or library variables.
Numeric spacing and radius literals may reuse an exact generated variable only
when its identity and value are unambiguous. If duplicate collections expose
the same generated name, the native renderer keeps the exact visual literal
unbound and reports that decision; explicit `var:` references still fail
closed until their collection identity is resolved. If no exact generated
Variable exists, the literal remains literal unless the caller explicitly sets
`materializeResources:true` / `--materialize-resources`; the opt-in preserves
the former generated-token workflow rather than removing it.
Other new COLOR, FLOAT, or STRING variables surface `SCOPE DECISION REQUIRED`
with only the compatible Figma choices. The agent should ask before narrowing
them; inspect the catalog with `figma_reference {name:"variable-scopes"}` and
apply the answer with `figma_run ["var","update","<name>","--collection",
"<collection>","--scopes","TEXT_FILL,STROKE_COLOR"]`.
Safe three-way sync accepts only **DTCG / W3C design tokens** (`.json`, what
`export dtcg` emits) and **CSS custom properties** (`.css`, what `export css`
emits). Sass `$variables` are not CSS custom properties and `.scss` is refused
rather than partially parsed. Note that
`export dtcg` writes _every_ local variable into one file while sync targets one
collection — pass `--collection` accordingly. If most names in the file already
live in another collection, sync says so instead of offering to duplicate them. Tailwind configs
are an import source only — their parser buckets values into
colour/spacing/radius and cannot round-trip, so sync refuses them by name
rather than silently dropping tokens it did not understand.
**Why a lockfile.** A two-way sync without memory cannot tell "the code
changed" from "Figma changed" — it only sees that the two differ, and whichever
direction it picks destroys the other side's work. `figma-tokens.lock.json`
records the state at the last successful sync, so every decision is a
three-way comparison:
| code | Figma | result |
| ------------ | --------- | ------------------------------------------------------- |
| changed | unchanged | update Figma |
| unchanged | changed | **reported, never overwritten** — update your code file |
| both changed | | **conflict** — nothing is applied |
| unchanged | unchanged | unchanged |
Commander and `figma_run` execute this comparison through the same closed
Command Plan. The source, prior lock and exact Collection are resolved before
Plugin readiness; duplicate Collection or Variable names and flattened DTCG or
CSS name collisions stop explicitly instead of selecting the first match.
For compatibility with the original sync command, an existing Collection uses
its first declared mode. If the Collection has more modes, sync says so; it
does not silently switch to `defaultModeId` or change values in the other
modes.
Conflicts stop the whole run. Resolve them by editing one side, or decide them
all at once with `--ours` (the code file wins) / `--theirs` (Figma wins, and
nothing is written to Figma).
Deletions need `--prune`, and even then only touch variables sync itself
created — a variable it never tracked is reported as untracked and left alone.
The lockfile also stores each variable's Figma id, which is what makes a
**rename** one rename instead of a delete plus a create that would drop every
layer binding. Pairing is by value and only when unambiguous: renaming _and_
re-valuing a token in the same commit falls back to create + delete, so do
those as two steps if the bindings matter.
Without `--apply` the command exits 1 when changes are pending, so it works as a
CI check for "is Figma in sync with the repo?".
Apply is intentionally non-atomic inside Figma: retype and delete have no
general safe inverse. It rechecks the complete observed state immediately
before the first write, stops after the first failed operation, verifies the
fresh post-state and reports `no-mutation`, bounded `partial-residue`, or
`verification-unavailable`. An apply is never automatically retried after
dispatch. The lockfile advances only after complete verified success. A
per-lockfile process lease serializes source/lock read, Figma work and
publication across CLI and MCP processes, and publication atomically replaces
the prior file from a fully written sibling. If publication fails, the prior
lock remains intact and the command reports any Figma changes that already
committed.
### Binding, and switching which collection a design follows
`tokens sync` writes token _values_. Two neighbouring things it deliberately
does not do:
```
figma_run ["node", "bind", "12:34", "radius", "radius/lg", "--collection", "TARGET_COLLECTION"]
figma_run ["tokens", "rebind", "TARGET_COLLECTION", "--node", "12:34"] # plan
figma_run ["tokens", "rebind", "TARGET_COLLECTION", "--node", "12:34", "--apply"] # write
```
`node bind` attaches a variable to a property of an **existing** node — `fill`,
`stroke`, `radius`, `gap`, `padding` (or one side), `opacity`, `stroke-width`,
`width`, `height`. Its read counterpart is `node bindings`. The legacy
`--batch` mutation is currently disabled until it has a typed bounded request,
complete preflight and truthful rollback/residue result; run one explicit node
per command in the meantime.
For a bounded machine-readable read, add `--json`:
```
figma_run ["node", "bindings", "12:34"]
figma_run ["node", "bindings", "12:34", "--json"]
```
The result preserves direct `node.boundVariables` topology: scalar aliases,
array positions such as `fills.1`, and component-property names. It also keeps
the variable id, name, resolved type and collection identity when Figma can
resolve them. This is intentionally a fast discovery outline, not proof of
every variable influencing the node. Use Design Capture/Code Spec for bindings
inside styles, paints, effects or text ranges, mode values, resolved values and
provenance.
A variable name that is not unique is **refused, not guessed** — this file has
`radius/lg` in two collections, and the answer names both so `--collection` can
settle it. The variable's type is checked against the property first, so a
COLOR on `radius` fails with a sentence rather than a plugin stack trace.
Typography variables have their own range-aware command because text can carry
different bindings on different character spans:
```
figma_run ["font", "bind", "12:36", "fontWeight", "type/weight", "--collection", "Typography"]
figma_run ["font", "bind", "12:36", "line-height", "type/line-height", "--start", "0", "--end", "12"]
figma_run ["font", "unbind", "12:36", "lineHeight", "--start", "0", "--end", "12"]
```
Bindable fields are `fontFamily`, `fontSize`, `fontStyle`, `fontWeight`,
`letterSpacing`, `lineHeight`, `paragraphSpacing` and `paragraphIndent`;
kebab-case spellings are accepted too. Existing fonts—and for family/style/
weight bindings the relevant available family styles—are loaded before the
binding is changed. Variable names are refused when ambiguous, and STRING vs
FLOAT is checked before Figma is called. A numeric `fontWeight` binding still
is not a general variable-font-axis setter: Figma selects a valid weight for
the active font.
`tokens rebind` is the theme switch: it walks a subtree and repoints every
binding at the same-named variable in a target collection. Design a card
against `SOURCE_COLLECTION`, run rebind with `TARGET_COLLECTION`, and the same
card follows the target collection's values — no redesign. It plans by default;
`--apply` writes. Tokens
with no counterpart in the target are listed and left pointing where they were,
so a partial theme is a report rather than a half-broken design.
Page-wide rebind remains available as a dry-run report, but `--page --apply` is
temporarily disabled. A scoped `--node <id> --apply` write remains available;
the page-wide write returns only after it has bounded traversal, exact
collection resolution, complete preflight and rollback evidence.
`node set` changes properties on nodes that already exist — `fill`, `stroke`,
`strokeWidth`, `radius`, `opacity`, `x`, `y`, `width`/`height`, `name`,
`visible` — one explicit node at a time:
```
figma_run ["node", "set", "12:34", "--name", "Card", "--radius", "12"]
```
The legacy `node set --batch` path is disabled until FID-03 replaces it with a
typed bounded mutation Operation that preflights all entries and can report
rollback or residue without oversizing the Plugin response.
`var delete-all` is also disabled. Use exact `var delete <names...>` or
ambiguity-aware `col delete <collection>` so destructive scope is explicit.
Collection deletion always returns a non-mutating confirmation preview before
dispatch, even when global write confirmation is disabled. Prefer
`col delete <collection> --only-if-empty` when cleanup must never remove member
Variables: the Plugin verifies empty membership immediately before the native
irreversible removal and refuses a populated Collection without mutation.
A colour takes a hex or `var:<name>`. The difference is not cosmetic: a hex is
frozen, a `var:` reference stays **bound**, so a later `tokens rebind` can still
move it.
### Local styles, variable metadata, and modes
The local design-system primitives that used to require manual UI work now
have first-class Figma Commands. They use the live Plugin API, not REST:
```
figma_run ["style", "list", "--type", "TEXT"]
figma_run ["style", "show", "--type", "TEXT", "Heading/H1"]
figma_run ["style", "create", "PAINT", "Brand/Primary", "--properties", "{\"paints\":[{\"type\":\"SOLID\",\"color\":{\"r\":0.1,\"g\":0.3,\"b\":0.9}}]}"]
figma_run ["style", "apply", "Brand/Primary", "12:34,12:35", "--field", "fill"]
figma_run ["style", "consumers", "Brand/Primary"]
figma_run ["style", "publish-status", "Brand/Primary"]
figma_run ["style", "bind-font", "Body", "fontSize", "--variable", "type/size/body"]
figma_run ["style", "unbind-font", "Body", "fontSize"]
```
`style` covers local PAINT, TEXT, EFFECT and GRID styles. `update` accepts the
same type-specific JSON properties as `create`; `apply` validates the style
type against `fill`, `stroke`, `text`, `effect` or `grid`. Name lookups refuse
ambiguity. Consumers come from `getStyleConsumersAsync()`, and publish state is
one of Figma's `UNPUBLISHED`, `CURRENT` or `CHANGED` values.
`style show` returns exact bounded Style Facts through the same safe-read
Application from CLI and MCP. It preserves ids/keys, plain and Markdown
descriptions, documentation links, authored PAINT/TEXT/EFFECT/GRID unions and
the scalar-versus-array topology of Variable Aliases. A GRID Auto count is
projected as `{ "$figmaScalar": "Infinity" }` instead of the lossy JSON value
`null`. When the Figma runtime omits a typings-required plain or Markdown
description, Style Facts reports `null` instead of fabricating content.
`--type` is optional, but narrows name lookup to one style family and
avoids loading unrelated local styles. Resolved Variable values, consumers,
image/video bytes, shader definitions and complete Design Capture remain
separate reads. URL-shaped
style names remain payload and cannot retarget the Figma Command.
`style list` is the compact bounded catalog rather than a dump of every
authored value. It returns id/key/name/type/remote identities plus
`total`, `returned`, `omitted`, `complete` and a scope-bound `next` cursor.
Use `--type TEXT` (or PAINT/EFFECT/GRID) to load only one native family,
`--limit 1..500` to size a page, and `--cursor <next>` to continue. The default
page size is 100. A continuation page remains `complete: false` because that
page alone is not the full catalog; `next: null` means no later page remains.
Use `style show` with an id for the complete authored facts of a row.
`style consumers <style>` is a separate long-budget read because Figma's
native `getStyleConsumersAsync()` scans the document and cannot be interrupted
with a visit limit. The result says this explicitly under `scan`, while
`total`, `returned`, `omitted`, `complete` and `next` describe the bounded
output page. `--type`, `--limit 1..500` and `--cursor` mirror the catalog flow;
the cursor is additionally bound to the original style query. Rows contain
only consumer node identity and official inherited-style fields. No document
content is changed.
`style publish-status <style>` returns only compact Style identity and Figma's
official `UNPUBLISHED`, `CURRENT` or `CHANGED` state. It uses the same bounded
safe-read path from CLI and MCP; `--type` can narrow ambiguous names without
loading unrelated families. Complete authored values remain available through
`style show`, so publish-status never transports GRID Auto counts or other
irrelevant value topology through a lossy generic JSON path.
Variables expose the metadata and mode operations that token-file sync does
not own:
```
figma_run ["var", "show", "space/md", "--collection", "Primitives"]
figma_run ["var", "create-batch", "--collection", "Primitives", "[{\"name\":\"color/neutral/0\",\"type\":\"COLOR\",\"value\":\"#ffffff\"}]"]
figma_run ["var", "create-batch", "--collection", "Roles", "[{\"name\":\"surface/default\",\"type\":\"COLOR\",\"alias\":\"color/neutral/0\",\"aliasCollection\":\"Primitives\"}]"]
figma_run ["var", "update", "space/md", "--description", "Medium spacing", "--scopes", "GAP"]
figma_run ["var", "update-batch", "[{\"variable\":\"space/md\",\"collection\":\"Primitives\",\"description\":\"Medium spacing\",\"scopes\":[\"GAP\"]},{\"variable\":\"space/lg\",\"collection\":\"Primitives\",\"hidden\":false}]"]
figma_run ["var", "set-value", "space/md", "12", "--mode", "Light"]
figma_run ["var", "set-value", "space/card", "--alias", "space/md", "--mode", "Light"]
figma_run ["var", "set-value-batch", "[{\"variable\":\"space/md\",\"collection\":\"Primitives\",\"mode\":\"Light\",\"value\":12},{\"variable\":\"space/card\",\"mode\":\"Dark\",\"alias\":\"space/md\"}]"]
figma_run ["var", "code-syntax", "space/md", "WEB", "var(--space-md)"]
figma_run ["var", "resolve", "space/md", "12:34"]
figma_run ["col", "mode-add", "Primitives", "Dark"]
figma_run ["col", "mode-rename", "Primitives", "Dark", "Dim"]
figma_run ["col", "extend", "Primitives", "Brand"]
```
`var show` returns exact bounded Variable Facts through the same safe-read
Application from CLI and MCP: values by mode, alias ids, scopes, code syntax,
collection/mode identity and publish status. Variable and collection names must
resolve uniquely; `--collection` is the explicit way to disambiguate duplicate
variable names. For a color Alias whose opacity was authored in Figma,
`valuesByMode` retains Figma's native `COMPOSE_COLOR` expression and percentage.
The current public Figma Plugin API can read but cannot set that expression
(measured on 2026-09-03 with typings 1.136.0: `setValueForMode` throws
`Composed color variable values are not supported`, and the typings admit an
`Expression` only when reading `valuesByMode`): `var set-value` and
`var set-value-batch` therefore refuse before mutation when replacing such a
prior value, and exact Snapshot import refuses before Plugin readiness instead
of flattening it. Create the variable and its alias through the Bridge, then
set or detach the opacity in Figma until Figma exposes a writable public
setter. `var create-batch` accepts either `value` or an explicit
`alias` per item. `aliasCollection` scopes a target in another Collection;
same-batch forward references are also supported. Alias ambiguity, type
mismatch and cycles stop before creation, and equal literals are never inferred
as Aliases by the command. A workflow may supply an explicit `alias` after an
opted-in policy proves one exact compatible target; ambiguous candidates remain
a report rather than a guess. One selected mode is initialized per call, so separate mode intent
remains separate rather than being copied into empty modes. When metadata edits for several known local Variables are
already available, collect them into one `var update-batch` JSON array instead
of emitting one `var update` call per Variable. The batch preflights every
identity before its first write and owns reverse rollback under one confirmation;
keep `var update` for a single target. Batch several already-known mode-value
assignments into one `var set-value-batch` call instead of repeating `var
set-value`. Its explicit `best-effort-rollback` policy does not reject
previously empty modes: successful writes commit normally; after a later
failure, prior values are restored and any value that Figma cannot remove is
reported as exact residue. This preserves normal utility without claiming
atomic rollback. Keep `var set-value` for a single pair. URL-shaped names remain payload and cannot
retarget the command. `var resolve` deliberately requires a consumer node because aliases
can resolve differently under that node's selected modes. Collection
`show`, `update`, `mode-add`, `mode-rename`, `mode-remove` and
`publish-status` follow the same ID/exact-name/unique-substring lookup policy.
Figma plan limits on mode count remain Figma-enforced and surface as errors.
Collection extensions use `VariableCollection.extend()` for local collections
and `extendLibraryCollectionByKeyAsync()` for published keys. Figma restricts
this feature to Enterprise plans; the CLI reports Figma's plan error unchanged.
Text-style bindings support exactly Figma's bindable typography fields:
family, style, weight, size, line height, letter spacing, and paragraph values.
### Enabled team libraries
Library discovery and imports also stay on the authenticated plugin transport:
```
figma_run ["library", "collections"]
figma_run ["library", "variables", "Acme/Primitives", "--type", "COLOR"]
figma_run ["library", "import-variable", "<published-variable-key>"]
figma_run ["library", "import-style", "<published-style-key>"]
figma_run ["library", "import-component", "<published-component-key>"]
figma_run ["library", "import-component-set", "<published-component-set-key>"]
```
`collections` and `variables` are reads. The four `import-*` commands
materialize published assets in the current file and are therefore writes in
the Capability Catalog. They share one non-retrying Command Application in CLI
and MCP: the selected native import runs once, then a fresh lookup verifies the
returned id, exact published key, resource kind and remote identity. Figma does
not expose a safe inverse and an imported resource may have existed already, so
failed or unavailable verification reports possible residue without deleting
or retrying the import. Resource descriptions are bounded in the structured
result. Figma only exposes discovery for variable collections and variables.
Published styles, components and component sets can be imported when their
stable key is already known, but the Plugin API cannot enumerate them.
Libraries must be enabled for the current file in Figma's UI before
`library collections` can see them; the Plugin API cannot enable a library.
The shipped plugin already declares the required `teamlibrary` permission.
Name lookup uses collection key, exact collection name, then an unambiguous
collection or library-name substring. Library discovery owns an 18-second
Plugin-API timeout below the Bridge deadline, so a stalled Figma library
request names the operation and suggests checking whether the library is
enabled instead of degrading into a generic execution timeout.
### Prototypes, Dev Mode measurements, and annotations
These document features are also Plugin-API-first:
```
figma_run ["prototype", "inspect", "12:34"]
figma_run ["prototype", "add", "12:34", "--trigger", "click", "--navigate-to", "12:36"]
figma_run ["prototype", "set", "12:34", "--json", "[{\"trigger\":{\"type\":\"ON_CLICK\"},\"actions\":[{\"type\":\"BACK\"}]}]"]
figma_run ["measure", "add", "12:34:right", "12:36:left", "--offset", "16", "--text", "gap"]
figma_run ["annotate", "categories"]
figma_run ["annotate", "add", "Review spacing", "--node", "12:34", "--category", "Review", "--properties", "width,fontSize"]
figma_run ["annotate", "edit", "12:34", "0", "--text", "Resolved"]
```
`prototype set --json` is the lossless form for Figma's multiple actions,
`SET_VARIABLE`, `SET_VARIABLE_MODE`, and conditional blocks. It writes through
`setReactionsAsync()` so dynamic-page manifests are supported. Measurement
writes are guarded to Figma Dev Mode and use `PageNode`'s native measurement
methods. Annotation indexes are zero-based; custom category create/edit/remove
commands are available alongside `categories`. These manual review notes are
independent from the semantic renderer's automatic Boundary Fallback
Annotations, which are emitted only by explicitly opted-in lossy mapping
policies and remain machine-readable through plugin data.
`prototype inspect` is the read-only verification path shared by CLI and MCP.
It preserves the complete authored reaction JSON while enforcing explicit
depth, collection, value, string and total serialized-size bounds below the
Plugin protocol cap. Oversized or malformed facts fail rather than being
silently truncated, and safe JSON escaping keeps terminal controls inert
without changing the parsed interaction facts.
`prototype add`, `set`, and `clear` use one non-retrying mutation Application
in both CLI and MCP. Reaction JSON is bounded before dispatch, the previous
reaction set is journaled, and fresh readback proves the exact requested set.
Failed verification restores the journal when possible and otherwise reports
bounded reaction residue; an exact readback after a native setter error remains
an explicit `verified-after-error` success.
### 2026 Plugin APIs: video, shaders, grid, slots, and Draw
The current official Plugin API surface is exposed as Figma Commands rather
than REST calls:
```
figma_run ["export", "video", "12:34", "--format", "mp4", "--fps", "30", "-o", "/abs/demo.mp4"]
figma_run ["shader", "list"]
figma_run ["shader", "import", "<shader-id>"]
figma_run ["shader", "apply", "12:34", "<shader-id>", "--field", "fill", "--properties", "{\"definition-id\":0.8}"]
figma_run ["layout", "grid", "set", "12:34", "--rows", "2", "--columns", "3", "--row-gap", "12"]
figma_run ["layout", "grid", "auto-flow", "12:34", "--auto-tracks", "rows", "--positioning", "row_auto_flow"]
figma_run ["layout", "grid", "reorder-rows", "12:34", "--from", "0,2", "--to", "3"]
figma_run ["grid", "list", "12:34", "--json"]
figma_run ["slot", "create", "12:37", "Content", "--settings", "{\"minChildren\":1,\"maxChildren\":3}"]
figma_run ["slot", "validate", "12:37"]
figma_run ["draw", "inspect", "12:38"]
figma_run ["draw", "text-path", "12:38", "--text", "Around the curve"]
figma_run ["draw", "stroke-profile", "12:38", "--preset", "TAPER"]
figma_run ["draw", "pattern", "12:38", "12:39", "--field", "fill"]
```
Video export resolves a selected descendant to its top-level animated frame
and accepts only Figma's format-specific FPS values. CLI and MCP share one
long-running, non-retrying Command Application whose Plan owns the exact output.
It validates requested settings, canonical bounded Base64 and the MP4, GIF or
WebM container before Workspace I/O. The current generic result transport
supports exports up to its documented bounded payload; larger video streaming
requires a separate versioned media-stream capability rather than widening the
image/SVG Asset Stream. Shader properties are
keyed by definition ID, not display name, and an available shader must be
imported before it is applied. Shader import and application use the same
non-retrying verified mutation path in CLI and MCP: apply never imports
implicitly, and a failed postcondition either restores the complete prior
paint/effect field or reports possible residue. `layout grid` means the Auto
Layout Grid model; the older top-level `grid` command remains Layout Guide
management.
`layout grid set` uses one journaled, non-retrying CLI/MCP path for Grid
conversion, dimensions, gaps and explicit track definitions. It refuses a
manual shrink beneath an occupied child span, verifies the complete requested
configuration while preserving unspecified facts, and restores prior Grid or
free-layout state on failure when Figma permits it.
`layout grid auto-flow` uses one non-retrying CLI/MCP mutation path: it treats
auto-track and item-positioning settings as one intent, verifies both through
a fresh node read, and restores both prior values on a mismatched result or
reports the exact remaining field residue.
`layout grid place` likewise shares one non-retrying CLI/MCP path. It validates
manual Grid bounds and occupied cells before moving the child, freshly proves
the resulting hierarchy, cell, spans and alignment, and restores the complete
prior parent/free-layout or Grid state when the postcondition fails.
`layout grid reorder-rows|reorder-columns` shares one irreversible,
non-retrying path. It validates the native track permutation and freshly proves
the track definitions plus every child anchor/span. A thrown call is reported
as no mutation only when fresh state proves the complete old Grid; otherwise
confirmed differences remain explicit residue because the Plugin API exposes
no safe general inverse.
`grid list [nodeId]` is the exact bounded read for those layout guides and may
use the current selection when the id is omitted. It preserves `gridStyleId`,
all ROWS/COLUMNS/GRID fields, colors and Variable Aliases. Figma's Auto count
is returned as `{ "$figmaScalar": "Infinity" }`, never the lossy JSON value
`null`; text output renders that value as `Auto`.
Slots expose GA `SlotSettings`, preferred values, reset, and limit violations.
Create and edit share one non-retrying, freshly verified mutation path in CLI
and MCP; failures remove only newly created Slot identities or restore the
prior effective definition when Figma permits it. Reset is also non-retrying
and reports native confirmation plus fresh before/after facts; it does not
claim an independently known default subtree or rollback. JSX `<Slot>` uses
`ComponentNode.createSlot()` and validates configured limits after rendering.
An empty Slot on a main Component can have no reported
violation even when `minChildren` is nonzero; instantiate the Component and run
`slot validate` on that exercised Instance to observe `BELOW_MIN`. Component
Property Facts normalize Figma's missing SLOT `defaultValue` to `null` and
nullable descriptions, preferred values and settings to their complete
effective defaults.
When generating a library from code, decompose each rendered subtree from the
leaves upward before creating the wrapper. Repeated buttons, icons, avatars,
badges, rows and card shells should remain nested reusable Components. Map
editable copy to TEXT, optional descendants to BOOLEAN, replaceable children to
INSTANCE_SWAP, state/size/tone to VARIANT and structurally flexible regions to
native SLOT. A visually polished wrapper with inert descendants is not a
finished code-to-Figma component contract.
Draw commands cover text paths, repeat transform
groups, stretch/scatter/dynamic strokes, variable-width profiles, and async
pattern fill/stroke setters. Run the corresponding `inspect`/`validate` read
before writes when modifying an unfamiliar document.
### Finding what needs fixing
```
figma_run ["analyze", "lint", "--node", "12:34"]
```
One pass for the four things a design-system review acts on: colours that match
an existing variable but are not bound to it, layers still carrying a default
name, text with no style, text under 12px. `--fail-on-issues` makes it a CI
gate; `--kind` narrows it; `--json` never truncates.
A hardcoded colour is only reported **when a variable already holds that exact
value** — otherwise the finding is noise you cannot act on. Because the match
is known, each one arrives with the command that fixes it:
```
unbound token colour — 1
12:35 Badge fill is #8a9a8d, which is sage/400
fix: node bind 12:35 fill "sage/400" --collection "Sprout Primitives"
```
`analyze colors|typography|spacing` still give the full census. Lint is the
pass that answers whether anything needs doing at all.
### Variable fonts and OpenType facts
Figma does not expose a general variation-axis tuple through the Plugin API.
The bridge therefore separates facts Figma actually reports from axis intent
that a caller records explicitly:
```
figma_run ["font", "inspect", "12:36"]
figma_run ["font", "inspect", "12:36", "--start", "0", "--end", "12", "--all-open-type"]
```
`font inspect` returns styled text ranges with `fontName`, numeric read-only
`fontWeight`, size, enabled OpenType feature tags and resolved typography
variable bindings. `--all-open-type` also includes false feature values. The
result names the API limit explicitly: a reported `fontWeight` is not a general
`wght`/`wdth`/`opsz`/custom-axis tuple, and OpenType features are read-only.
Typography Variable bindings use the same explicit node/range boundary:
```
figma_run ["font", "bind", "12:36", "fontSize", "type/body", "--collection", "Typography"]
figma_run ["font", "bind", "12:36", "lineHeight", "type/leading", "--start", "0", "--end", "12"]
figma_run ["font", "unbind", "12:36", "lineHeight", "--start", "0", "--end", "12"]
```
`font bind` and `font unbind` are non-retrying writes shared by CLI and MCP.
They preflight type compatibility, fonts, ambiguity and the prior binding, then
freshly read the exact node/range binding after the setter. A silent no-op or
wrong Variable id is a failure, not success; the prior binding is restored and
verified where possible, otherwise the result reports bounded residue.
When the exact axis values are known from the UI or another font tool, preserve
them on the text node as range metadata:
```
figma_run ["font", "remember-axes", "12:36", "wght=357,wdth=82", "--start", "0", "--end", "12"]
figma_run ["font", "axes", "12:36"]
figma_run ["font", "forget-axes", "12:36", "--start", "0", "--end", "12"]
figma_run ["font", "forget-axes", "12:36"] # clear every stored range
```
`remember-axes` changes plugin metadata only — never the font or rendered
glyphs — and is therefore classified as a write by the Capability Catalog.
`figma_spec` carries these records as `axes-meta[start:end](tag=value,…)`, plus
Figma's reported `fw…` value and enabled `ot(…)` tags, so design-to-code capture
does not silently discard the documented intent.
## Native Plugin API facts
Two read commands expose Figma's own representations without contacting the
REST API:
```
figma_run ["node", "css", "12:34"]
figma_run ["node", "css", "12:34", "--json"]
figma_run ["export", "node-json", "12:34"]
figma_run ["export", "node-json", "12:34", "-o", "facts/card.json"]
```
`node css` calls `getCSSAsync()` and returns the declarations Figma exposes for
its Inspect panel. This is deliberately separate from `export css`, which
exports design-token custom properties. Its text and JSON projections share
one target-bound, bounded safe-read Application; an oversized or malformed
Plugin result is refused rather than silently truncated. `export node-json`
uses `exportAsync({format:"JSON_REST_V1"})`: Figma returns the official REST
nodes response envelope with the requested node under `document`, alongside
`components`, `componentSets`, `schemaVersion` and `styles`. The bytes
come from the live plugin document and need neither a token nor a network
request. CLI and MCP share one target-bound safe-read Application: the Command
Plan owns the optional output file before readiness, and bounded
`document.id/type`, JSON structure, transport size and formatted output are
validated before stdout or Workspace publication.
## Version history and diffs
Figma's plugin API can _write_ a version but not read one back, so "what changed
since this morning" has no answer from the bridge alone. `history` supplies one
without any credential: record the structure of a subtree, record it again
later, diff the two.
```
figma_run ["history", "save", "Before refactor", "--description", "Agent restore point"]
figma_run ["history", "snapshot", "--label", "before refactor"]
# … agent works …
figma_run ["history", "diff", "latest", "live"]
```
`history save` creates the named entry directly through
`saveVersionHistoryAsync()` and is a Figma write. `snapshot`, `list` and `diff`
remain local/read-only Figma operations; reading Figma's native historical
versions still requires the optional REST add-on.
A snapshot stores one normalized record per node — geometry, layout, paints,
typography, component keys — plus a content hash and a subtree hash, so the
differ can report an untouched section instead of walking it. They live in
`~/.figma-bridge-mcp/snapshots/<fileKey>/`, gzipped, newest 20 kept. The
Structural Snapshot Store keeps its directories `0700` and files `0600`, and
bounds both compressed and expanded data before parsing.
Refs are `latest`, `previous`, a bounded index from `history list`, an exact or
unique-prefix filename, or `live` for the document right now. Local refs never
accept host paths or escape the owning file namespace. The report separates
**added**, **removed**, **replaced**, **moved** and **changed** — that last distinction is the one that
matters in practice: an agent that deletes a frame and re-renders it keeps the
name path but gets new node ids, and without the replaced-detection every
re-render would read as a hundred deletions. `--changelog` emits markdown
instead; `diff` exits 1 when anything differs, so it also works as a CI gate.
Via MCP this is a parameter, not a thirteenth tool:
```
figma_history {diff: {from: "latest", to: "live"}}
figma_history {diff: {from: "version:1234", to: "version:5678"}} # REST add-on
```
`version:` refs go through the REST layer and diff what _designers_ saved, using
the same differ. The two sources cannot be mixed in one diff: a REST document
and a plugin snapshot expose different properties, so every node would look
changed — the tool says so rather than producing a misleading wall of output.
## Motion
Figma Motion (Config 2026 Beta) is reachable through `figma_run` with
`["motion", …]`: keyframe tracks (`add`), whole specs from JSON (`apply`),
named presets (`preset`), choreographed offsets across nodes (`stagger`),
Figma's first-party animation styles (`styles`, `style`), frame duration
(`timeline`), readback (`inspect`) and removal (`clear`).
Like every other command it runs over the plugin bridge — there is no separate
transport for it. `styles`, `inspect`, and `timeline` without `--duration` are
reads. `timeline --duration …` and every other Motion action count as writes
under `FIGMA_WRITE_CONFIRM=1`.
Motion is rolling out behind a Figma Beta flag. Without access, the commands
fail with a named `MOTION_DISABLED` error telling you to update Figma Desktop
rather than a generic API failure.
Motion also travels with the design in both directions:
- **Figma → code.** `figma_spec` and `export code-spec` capture a layer's
native keyframe tracks and project them to CSS: every animated layer gets a
`motion:` segment and the spec ends with a `## Motion (implement as CSS)`
section holding one `@keyframes` block and `animation` declaration per
layer, using Figma's own easing curves. Springs, Variable-bound easings and
fields CSS cannot express are listed as limitations; Figma timelines play
once, so loops (`iteration-count`, `direction`) stay a code decision, and a
`prefers-reduced-motion: reduce` override is expected. The JSON/YAML model
carries `motion` with the same `css` projection.
- **Code → Figma.** The browser capture expression reads each element's CSS
animation facts and the referenced `@keyframes`, pauses playback while it
captures resting geometry, and `render --dom-capture` writes the animation
as native Motion tracks on the created layer (negative delays become phase
shifts, `alternate` becomes a two-period timeline). The render result
reports `Motion: N native keyframe track(s) …` plus any layer Figma refused.
## Security model
- **No Figma API token required** — Figma is driven through the local plugin,
never `api.figma.com`. The REST add-on is strictly opt-in: without a token
the code path is inert, and with one the token lives in a 0600 file (or your
own env var), not in the MCP client config.
- **No binary patching** — Yolo/CDP mode is stripped from the vendored engine.
- **Capability-gated commands** — `figma_run` only accepts Commands exposed by
the Capability Catalog; `connect` is _not_ exposed, so Safe-Mode-only
connection is enforced. The same resolved plan drives the write-confirm gate,
target requirement and retry policy, preventing adapter drift.
- **Target/editor-bound dispatch** — every Plugin action carries a Dispatch
Receipt derived from the resolved connection. Stale epochs, wrong files,
wrong editors and missing capabilities fail before mutation; malformed or
mismatched terminal receipts are outcome-unknown and never authorize a write
retry.
- **Owned Structured Render rollback** — native single and batch renders
preflight every plan through one shared mutation-free resource catalog, then
journal only Command-created variables, collections, Text Styles, image
handles and scene nodes. Failures clean removable entries in reverse order;
non-removable or failed cleanup is reported as bounded residue rather than
false atomicity.
- **Bounded Semantic Render outcomes** — the Plugin response must match one
closed, versioned and deeply bounded result before any Adapter can report
success. CLI text, MCP structured content, JSON/YAML, Audit completion and
process code project the same root, structural, resource and mutation facts;
stacks, source payloads, file keys, credentials and arbitrary fields cannot
enter that result.
- **One compound-render lifecycle** — wrapper auto-split and `render-batch`
use the same closed request through CLI and MCP. Gap/direction, placement,
presets, collections, Registry links, icons, fallbacks, local assets and
optional conversion/resize/verification stages are retained explicitly.
Roots prepare before readiness, the visible render dispatches once, and
bounded stage evidence preserves exact known partial handles on failure.
- **Host-materialized remote images** — `figma_render` never gives the Plugin a
remote URL. Explicit exact-origin policy, DNS/address checks, connection
pinning, redirect reauthorization, identity encoding, byte/time limits and
raster validation complete on the host inside the Command deadline. Local
and remote rasters then enter the same embedded, content-hashed Plan shape;
repeated references are read once per Command.
- **No shell** — the engine is spawned with `execFile` (`shell:false`).
- **Two-layer daemon auth, no secret on the wire** — signed HTTP requests
(per-request HMAC over method/path/body, keyed with the session token, nonce
replay guard) + a mutual challenge-response handshake on the plugin socket
(`Origin`/`Host` allowlisted). Neither the session token nor the access key is
ever transmitted in either direction — see [Handshake](#handshake).
- **Localhost-locked plugin** — `plugin/manifest.json` restricts
`networkAccess.allowedDomains` to WebSocket and discovery HTTP traffic on
`localhost:3456–3460` only.
- **Isolated state** — token, pid, key, and Audit Trail live under
`~/.figma-bridge-mcp/`, separate from any upstream figma-ds-cli install. A
source checkout can additionally select `FIGMA_BRIDGE_PROFILE=local-development`
for a distinct namespace and a daemon/plugin pinned to `:3460` while the
released profile remains active. Its importable bundle is generated below
the gitignored `plugin/local/` directory; tracked `plugin/manifest.json`
always remains the normal released identity.
- **Private Audit Trail** — commands attempt a closed, versioned start/outcome
lifecycle in `~/.figma-bridge-mcp/audit.log`. Capability, effects, redacted
data summaries, outcome, duration, terminal Dispatch state and bounded
mutation attempted/restored/residue counts remain useful to `figma_history`; raw
commands, JSX, labels, paths, file keys, REST bodies, credentials, and errors
or residue identities are never retained in new records. CLI errors and MCP
structured errors consume the same closed mutation projection.
Directory/file modes are `0700`/`0600`.
It rotates at 5 MB with one previous generation, which also bounds readable
legacy entries. Storage failure never blocks Figma work: MCP output reports a
degraded Audit Trail until a complete record succeeds.
**Port fallback.** The daemon binds the first free port in 3456–3460 and
publishes it in `~/.figma-bridge-mcp/daemon-port`; the CLI/MCP layers resolve the
port per call (env `DAEMON_PORT` > port file > 3456), and the plugin scans the
whole range, so a foreign process squatting 3456 no longer blocks connecting.
After three bounded WebSocket scans, quiet `/plugin-ready` probes discover a
returning daemon every three seconds without flooding Chromium with refused
socket attempts. If the daemon is reachable but this iframe still cannot open
its authenticated socket, the panel says that this Figma window is
disconnected instead of claiming MCP is absent; another Figma window may still
be connected. The discovery beacon still exposes no connection state or
identity. A normal `figma_connect` is idempotent and preserves an
authenticated, responsive socket. If that one socket is open but a live
round-trip fails, it asks the persistent Figma plugin thread to recreate only
the UI iframe; if the relay cannot answer, it resets that stale socket so quiet
discovery can reconnect. Only explicit key rotation or `daemon restart`
replaces the daemon. Stop/restart signals are sent only after the PID file and
the published listening socket identify the same process, and escalation is
limited to that listener PID rather than every client sharing the port.
The squatter check is an _unauthenticated_ `/health` probe, and authenticated
requests are HMAC-signed — a squatter on a range port sees neither the session
token nor anything replayable (signatures bind timestamp, nonce, method, path
and body; the daemon rejects reused nonces). The plugin socket is safe on any
range port for the same reason: the handshake below carries no secret and binds
the port it ran on. Setting `DAEMON_PORT` explicitly disables the fallback;
values outside 3456–3460 are unsupported — the plugin manifest is
Figma-enforced and cannot reach them.
### Handshake
The plugin socket runs a mutual challenge-response (proto 4,
`engine/src/lib/plugin-handshake.js`):
```
daemon → plugin {type:'challenge', proto:4, nonce:<dNonce>, port:<bound>}
plugin → daemon {type:'hello', proto:4, nonce:<pNonce>, version, capabilities, proof}
daemon → plugin {type:'hello-ack', connectionEpoch, proof, restTokenConfigured}
```
where `proof = HMAC-SHA256(access key, transcript)`. The plugin proof binds both
nonces, the bound port, the plugin version and its canonical capability list;
the daemon proof binds both nonces, the port and its random connection epoch
with a distinct role label and nonce ordering, so neither proof can be replayed
as the other. Five properties follow:
- **The key never crosses the wire.** A process that binds a range port before
the daemon and records the whole exchange learns one HMAC over nonces it will
never see again. This retires the residual risk earlier versions documented,
where the raw key was the first frame the plugin sent.
- **The daemon proves itself too.** Before proto 2 the plugin trusted whatever
answered and would run any `eval` it was sent — impersonating the daemon
needed no key at all. The panel now refuses every command until the ack
verifies.
- **The bound port is inside the transcript and checked against the actual
socket.** A squatter on 3456 that forwards a challenge from the real daemon
on 3457 is rejected before the plugin sends a proof. Rewriting the challenge
to 3456 instead makes the daemon's verification fail.
- **Capabilities are authenticated.** The daemon verifies the exact canonical
capability claim the plugin signed, so a relay cannot add a newer write-lane
safety capability to a stale plugin.
- **The connection epoch is authenticated.** Every Dispatch Receipt uses the
epoch signed by the daemon ack, so a result retained by the Plugin main thread
across an iframe reconnect cannot impersonate a request from the new socket.
- **Write-lane settlement survives an iframe reconnect.** The Plugin main
thread keeps awaiting an uncancellable Figma operation after its caller times
out, then sends `lane-settled` with the exact receipt. If the UI socket was
disconnected, the same main thread replays that proof after reauthentication.
Reconnect alone never releases quarantine; a new runtime lacks the random
request identity. If a runtime was forcibly terminated before it could
report settlement, recovery requires first stopping that old Plugin runtime,
then explicitly restarting the Bridge Daemon—closing only the socket is not
sufficient proof.
- **Status proves more than socket presence.** Its bounded health operation is
a closed typed read with no JavaScript payload and never enters the write
lane. A responsive new connection epoch may release stale slots belonging
only to non-mutating reads from an older epoch. Unknown writes remain
quarantined until their exact settlement arrives; an attempted retry that
reaches its deadline reports duplicate risk and tells the caller to inspect
the targeted file before authoring another mutation.
There is no fallback to an older handshake protocol. `figma_connect` refreshes the installed plugin
files on every run. Figma may continue using an application-cached plugin
build, so the authenticated handshake reports the imported build separately
from the bundled build. `figma_status` then gives an explicit re-import path
instead of treating the mismatch as a generic connection failure.
### Multiple Figma MCP servers
Figma Bridge can coexist with another Figma MCP. Its registered server
namespace is `figma-bridge` and every tool result carries
`_mcp: "figma-bridge-mcp"` plus protocol metadata that scopes failures to this
server. Errors also repeat that a failed Bridge call does not establish that
Figma or another Figma MCP is unavailable.
The bundled skills pin their workflows to Figma Bridge and begin with this
server's `figma_status`. They never silently switch to a different MCP for a
write, because another server may use different targeting, approval, retry and
security semantics. An MCP cannot control which tool a host selects before the
first call, so a generic task with several Figma servers remains a host/agent
routing decision; once a Bridge skill is selected, the server identity and
workflow contract make that route explicit and testable.
The panel carries its own SHA-256/HMAC implementation: the plugin UI is a
sandboxed null-origin iframe, where WebCrypto availability is not ours to
guarantee, and a silent fallback to something weaker is the worst outcome for an
auth handshake. `tests/plugin-handshake.test.js` runs that shipped code against
Node's `crypto` so the two implementations cannot drift apart.
## Known limitations
- **Figma can terminate the complete plugin runtime.** Reconnect loops and UI
reloads work only while at least the plugin main thread is still running. If
Figma destroys both the iframe and main thread, no plugin JavaScript remains
that could reopen itself; start **Plugins → Development → Figma Bridge** once
in that file. The stored access key is reused and discovery reconnects it
automatically, so no new pairing is needed.
- **Figma Slides is beta and deliberately bounded.** Grid inspection, slide
create/duplicate/move/delete, skip state and transitions are supported.
Speaker notes, interactive polls/embeds, presenter controls and a complete
content-authoring workflow are not. See the
[Slides roadmap](https://github.com/KaiUweHella/figma-bridge-mcp/blob/main/docs/slides-roadmap.md) for actionable candidates versus
Plugin API boundaries.
- **Non-localhost network actions are few and explicit**: `api setup` has no
network effect; it verifies the exact official `@figma/plugin-typings`
dependency and atomically builds a private offline reference for
`figma_reference`. External access is limited to the explicitly approved Storybook origin
used by `import`/`map storybook` (or the local directory you pass), and — only
when you opt into the REST
add-on — calls to `api.figma.com`. Nothing else talks to the network — the
upstream's iconify/unsplash/remove.bg/screenshot-url integrations were
removed entirely; `<Icon>` in `figma_render` JSX renders as a named
placeholder (real icons come out of the Figma file via `export assets`).
- **One transport, no CDP remnants.** Every command reaches Figma the same
way: engine → daemon → plugin eval. The upstream's Chrome-DevTools client,
its `figma-use` shell round-trip, the binary-patching `init` wizard and the
`figma-use` dependency are all gone (~5,600 lines removed), so there is no
second code path that could bypass the plugin bridge.
## Development
```bash
npm run check:contracts # separate host + Figma Plugin static contracts
npm run check:plugin # generated Plugin runtime has no source drift
npm run check:architecture-latency # warmed latency budget in an idle process
npm run measure:architecture # context, payload and local latency baselines
npm run build:package-artifact -- --output-dir release-artifact # reproducible reviewed tarball + evidence
npm test # all contracts and regression suites
```
The current domain language lives in [`CONTEXT.md`](https://github.com/KaiUweHella/figma-bridge-mcp/blob/main/CONTEXT.md), API coverage in
[`docs/figma-plugin-api-coverage.md`](https://github.com/KaiUweHella/figma-bridge-mcp/blob/main/docs/figma-plugin-api-coverage.md). The public
documentation index is [`docs/README.md`](https://github.com/KaiUweHella/figma-bridge-mcp/blob/main/docs/README.md).
Avoid running an upstream `figma-cli` at the same time. The daemon now falls
back within 3456–3460 when 3456 is taken, so both _can_ coexist, but the plugin
scans the whole range and the two daemons use different access keys — which one
the plugin reaches first is a coin toss. This build isolates its own
token/pid/port files under `~/.figma-bridge-mcp/`.
## License
figma-bridge-mcp is released under the [MIT License](LICENSE). It is provided
"as is", without warranty; the exact warranty and liability terms are in the
license itself. Third-party copyright and license notices are retained in
[`NOTICE`](NOTICE) and [`engine/LICENSE`](engine/LICENSE).
## Inspiration & attribution
Two projects shaped this one, in different ways.
[**figma-cli**](https://github.com/silships/figma-cli) (Sil Bormüller) is where
the `engine/` directory comes from: it was vendored at v2.1.0 in July 2026 and
has diverged since — the CDP transport and the binary-patching installer are
gone, the plugin socket is authenticated, and most of what the engine does now
was written here. The remaining explicitly derived source files have all
diverged from their upstream bytes. The upstream MIT license is retained in
full at [`engine/LICENSE`](engine/LICENSE), and [`NOTICE`](NOTICE) records what
changed.
Attribution inventory: 3 retained modified-derived source files, 0 byte-identical source files, and 1 verbatim upstream license file.
The exact revision, paths, and reference hashes are machine-verifiable in
[`UPSTREAM_PROVENANCE.json`](UPSTREAM_PROVENANCE.json).
[**figma-console-mcp**](https://github.com/southleft/figma-console-mcp)
contributed an idea rather than code: that a Figma bridge can be genuinely
local — a plugin socket on the loopback interface, no cloud relay, no patched
binary. Nothing here is derived from its source; the tool surfaces, the
transport and the plugin are unrelated. Where this project differs is that the
socket also proves who is on the other end of it.
**Plugin identity.** The development manifests use the product-aligned ids
`figma-bridge-mcp` and `figma-bridge-mcp-dev`. Figma scopes `clientStorage` —
where the paired access key lives — to the plugin id and, in the desktop
editors, separately across Design, FigJam and Slides. Installations from before
0.5.0 therefore need to re-import the manifest; each editor in use needs the
same Bridge access key saved once.
TDQS
Scored across 12 tools
Most tools are distinct, but figma_inspect and figma_spec both target node/layer facts, and figma_connect, figma_status, and figma_pairing all relate to setup/connection. The descriptions provide enough detail to choose correctly in most cases, but the boundaries are not fully crisp.
All tools share the figma_ prefix and snake_case, but the suffix pattern is mixed: connect, run, render, and inspect are actions, while status, pairing, comments, history, and spec are nouns. The convention is readable and consistent in prefix, but not consistently verb_noun.
Twelve tools is a reasonable size for a Figma bridge covering setup, rendering, inspection, comments, screenshots, and history. Some tools overlap slightly and could be consolidated, but the count is neither excessive nor too thin.
The surface covers connection/setup, status, rendering, selection, comments, node inspection, specs, screenshots, history, and an offline reference. Minor gaps like comment update/delete or explicit file-scoped operations exist, but core workflows appear supported.