An open-source MCP server that lets AI agents (Claude Code, Cursor, Codex) read and edit the Figma files you have open in Figma Desktop, through a Figma plugin: no REST API, no access token, no API rate limits, any Figma plan. Its code is open under the MIT license.
Website · Docs · Changelog · npm
Unofficial. Not affiliated with or endorsed by Figma. "Figma" is a trademark of Figma, Inc.
Requires Node.js 24.11 or newer and Figma Desktop. Run the setup in a terminal:
npx -y pixelwright@latestIt connects Claude Code and Cursor, shows what to paste for Codex and other MCP clients, and puts the Figma plugin in
~/.pixelwright/plugin. Then, in Figma Desktop:
- Plugins → Development → Import plugin from manifest… and pick
~/.pixelwright/plugin/manifest.json, once. Figma loads the plugin from there every time you run it, and the setup puts the new one there on every update. (The plugin is not on the Figma Community: Figma does not list plugins that overlap its own MCP server.) - Open your file and run Plugins → Development → Pixelwright. Keep its small window open while you work.
- The first time, the window asks for a pairing code: your agent shows it (from
status); type it in. - Ask your agent to read a section, or turn on Allow edits in the window and ask it to change something.
npx -y pixelwright@latest setup --print asks nothing: it puts the plugin in place and prints the commands and config
blocks, for scripts.
Open the Orbit demo in Figma: a small pricing site made for Pixelwright. Duplicate it to your drafts, run the plugin in your copy and follow its Start here page, which names the layer each tool shows best: three breakpoints, an A/B hero, a prototype flow, Dev Mode notes and measurements, Motion, components with an exposed instance and a slot, and the deliberate mistakes the audits find. examples/orbit is the code an agent builds from it: the tokens, the components, the Code Connect files and a baseline.
Claude Code ──stdio──► pixelwright ──ws://localhost:18720──► Pixelwright plugin ──Plugin API──► your Figma file
The plugin runs inside the file and answers the server on your computer. Every read and write goes through the Figma Plugin API, so nothing reaches api.figma.com and no seat or plan limits the number of calls. Several files can be connected at once; tools work on the one whose plugin window was in front last, or on the file you name.
| Tool | What it is for |
|---|---|
status |
The connected files, which one tools use, and what to do when none is |
file_map, outline, nodes_find |
Every page with its frames and sections in one call; a frame's layer tree (instances name their component); layers by name, type, text, component or variant, and instances counted by variant |
selection_get, selection_wait, viewport_get, node_show |
What the user picked, or waiting for them to pick; what they are looking at; showing layers, fitted or at a zoom |
changes_since |
What the designer changed since the agent last looked, by whom |
design_read |
A section as XML: Figma's layer types as tags, CSS as attributes, tokens as var(--Name), assets as files; the designer's Dev Mode notes, pinned properties and measurements; Figma Motion as CSS keyframes; what an instance changed outside its component's properties; what is ready for dev |
inspect, measure |
Layers as Figma has them, without code: layout and painted boxes, the fill with its style or variables, how strokes paint, every effect's parameters, a text's lines and line heights, overflow, interactions, centres with their distance and angle around a point; the distances between layers |
nodes_diff, variants_read, breakpoints_find |
How one layer differs from another (A/B variants); a component's states as differences from its default; a screen at its other widths |
baseline_save, baseline_diff |
Keep a section as your code was built from it, in the project; later, what the designer changed since, in the XML's terms: layers, a component's change once, tokens in every mode, icons to export again |
flow_read |
The prototype as screens and the links between them, for an app's routes |
screenshot, image_compare |
A layer, or a region of it, as PNG, in a variable mode if you like; the same render compared with a PNG of your build (rmse, differing pixels, edges off by N px) |
assets_export |
Icons and images as SVG, PNG, JPG, WebP or PDF, sized by a CSS width if you like, originals included |
tokens_read, tokens_usage, components_read |
Variables with every mode and styles, as CSS, JSON or DTCG token files; the layers a token is used on; the components a page uses, with variants, properties, the nested instances they expose and the project's code for each |
design_audit, contrast_check, fonts_check |
The scale a design uses and what is off it; every text's WCAG contrast; where the build gets each font |
a11y_read, states_find |
What a screen's markup needs for accessibility (heading levels, control roles and labels, alts, reading order); the states components and screens are drawn in and the ones they lack |
design_lint |
A layer, page or file checked against the general rules: contrast, raw colours and texts, detached components, stacks without auto layout, overflow, touch targets, missing states, leftovers; each finding with its fix |
code_connect_read |
The project's Figma Code Connect templates and batch files, what each connects and how, what its code leaves out, what has none |
design_apply |
The same XML written back: change layers by id, create new ones, instances, components and variants, images; a named version in Figma's history first, when asked |
tokens_apply |
Variables (collections, modes, aliases) and styles, in tokens_read's shape |
instances_reattach |
Frames detached from components put back as instances (texts, images and hidden layers kept), instances swapped for another component, overrides taken off |
texts_replace |
Find and replace across a layer, a page or the file (a list for copy edits or a translation), each run's style kept, component text properties changed through the property |
tokens_bind |
Raw colours, spacing, radii, texts and effects bound to the variables and styles that hold them; one token's uses rebound to another |
effects_apply |
Figma's own noise, texture and glass on layers: a ready look (grain, film, risograph, frosted or clear glass) or the effect's own values |
version_save |
A named version in Figma's history, with the baselines it goes with, when you ask for one |
nodes_delete, execute |
Delete layers; run plugin code for what the XML cannot say |
guide |
The XML format, writing, tokens, assets, Code Connect and HTML email, for the agent |
A read of the Pro card from Orbit, the demo the tests run on, abridged:
<Instance id="54:268" name="Plan – Pro" component="Plan Card" props="Price=$12, Popular=true, Description=For freelancers shipping fast., Name=Pro" note="The price comes from the billing API. Show the Popular badge only on the plan the API marks popular." display="flex" flex-direction="column" width="385px" height="394px" padding="var(--space-32)" gap="var(--space-24)" border-radius="var(--radius-lg)" border="2px solid var(--color-brand)" background="var(--color-surface)" box-shadow="var(--Shadow-Card)" $on-click="go to Checkout – Desktop; dissolve 300ms ease-out">
<Frame id="I54:268;54:203" name="Header" display="flex" align-self="stretch" justify-content="space-between" align-items="center" overflow="hidden">
<Text id="I54:268;54:204" name="Name" textStyle="Heading/H3" color="var(--color-ink)">Pro</Text>
<Instance id="I54:268;54:205" name="Badge" component="Badge" props="Label=Popular" display="flex" padding="var(--space-4) var(--space-12)" align-items="center" border-radius="var(--radius-pill)" background="var(--color-accent)">…</Instance>
</Frame>
…
<Instance id="I54:268;54:211" name="Button" component="Button" variant="Variant=Primary, State=Default" props="Label=Get started" display="flex" align-self="stretch" padding="var(--space-12) var(--space-24)" justify-content="center" align-items="center" border-radius="var(--radius-md)" background="var(--color-brand)">
<Text id="I54:268;54:211;54:190" name="Label" textStyle="Button" color="var(--color-on-brand)">Get started</Text>
</Instance>
<Frame id="I54:268;54:213" name="Features" display="flex" flex-direction="column" align-self="stretch" gap="var(--space-12)" overflow="hidden">
<Frame id="I54:268;54:214" name="Feature" display="flex" align-items="center" gap="var(--space-8)" overflow="hidden">
<Icon id="I54:268;54:215" name="Icon/Check" component="Icon/Check" src="assets/icon-check.svg" width="20px" height="20px"/>
<Text id="I54:268;54:217" name="Text" textStyle="Body Small" color="var(--color-ink)">Unlimited projects</Text>
</Frame>
…
</Frame>
</Instance>Sending that XML back changes nothing; changing an attribute changes exactly that, in one undo step.
Pixelwright runs your project's Figma Code Connect templates
(*.figma.ts) and batch files (*.figma.batch.json, one row per component through a shared template), written as
Figma documents them, itself, on any plan, without publishing anything. design_read then gives each connected
instance as $code in the XML with its code and imports, and names the properties its code leaves out:
{ "ids": ["54:268"], "component": "PlanCard", "source": "src/components/PlanCard.tsx",
"snippet": "<PlanCard name=\"Pro\" price=\"$12\" description=\"For freelancers shipping fast.\" popular>\n <Button variant=\"primary\">Get started</Button>\n</PlanCard>",
"imports": ["import { PlanCard } from \"@/components/PlanCard\"", "import { Button } from \"@/components/Button\""] }A row or a template can name the component's key (components_read gives it), which matches exactly that component
in every file, a library's included. components_read mapped=false lists the components still without code, so a
design system is mapped once, a row each. See examples/orbit
for two templates, one rendering the other inside it, a batch file, and how they match. The project folder is CLAUDE_PROJECT_DIR, else the folder your MCP client starts the server in.
- Allow edits in the plugin window is off until you turn it on for a file; while it is off, the plugin refuses every write, whatever the agent asks. Your choice is remembered per file on your computer.
- Each tool call that writes is one undo step in Figma (⌘Z / Ctrl+Z).
design_applyandtokens_applytakedryRunto check a change without writing it. - The server listens on localhost only, and a plugin pairs once with a code you type in; unpaired windows get nothing.
executeruns plugin code the agent writes, and Code Connect templates run as your project's code: use Pixelwright in projects and with agents you trust, as you would their build scripts.
- The file must be open in Figma Desktop with the plugin's window open; writing needs edit access to it.
- Figma takes images up to 4096 px a side, in PNG, JPG or GIF.
- A library's component the file does not use yet is placed by its key (
component="key:…"): the Plugin API cannot search a library's components. - Tested on macOS. On Windows the setup gives Claude Code
cmd /c npx …, as its documentation asks; Windows itself is not tested yet.
pixelwright in a terminal: the setup; started by an MCP client: the MCP server on stdio
pixelwright setup --print no questions: the plugin put in place, the commands and config blocks printed
pixelwright serve the MCP server, however it is started
pixelwright logs follow the logs of every running server on this machine: tool calls, plugins, errors
pixelwright --version
PIXELWRIGHT_HOME moves ~/.pixelwright (the plugin, pairing keys, written-files.json, the shared log in logs/);
CLAUDE_PROJECT_DIR names the project for Code Connect; PIXELWRIGHT_SUPPORT_REMINDERS=off turns off the weekly
support note (see Support); PIXELWRIGHT_NO_UPDATE_CHECK=1 stops asking npm for a newer version (see Updating);
PIXELWRIGHT_LOG_LEVEL=debug adds the bridge's details to the log.
MCP clients start pixelwright@latest, so a client started again runs the newest server. The plugin is a copy on your
computer: run npx -y pixelwright@latest again (or setup --print) to put the matching one in ~/.pixelwright/plugin.
Figma Desktop loads it the next time you run the plugin. When the plugin's window says the protocol versions differ,
this is the fix.
When a newer version is out, the plugin's window says so at the top, with what is new in it and a link to these steps.
Close it, and it stays closed until the next version. The server asks npm for the newest version once a day (the
answer kept in ~/.pixelwright/latest.json); PIXELWRIGHT_NO_UPDATE_CHECK=1 turns that off, and the plugin itself
never goes online.
Every MCP client session runs its own server, and they share one bridge port. When a newer server starts while an older
one (another client's, still open) holds the port, the older one hands the port over and switches its bridge off: its
status then asks you to restart Pixelwright in that client (/mcp in Claude Code). A plugin turned away by an older
server keeps trying, and connects once the newer one holds the port.
pnpm install && pnpm build
pnpm lint && pnpm typecheck && pnpm test
pnpm plugin:install # builds the plugin into ~/.pixelwright/plugin
pnpm check:pack # packs the server, installs it in an empty folder and runs it thereFigma Desktop is not allowed to read the folders macOS protects (Desktop, Documents, Downloads) unless you grant it
access, and it loads a development plugin's code from disk on every run. plugin:install puts the plugin outside them,
so the repository can live anywhere. Run it again after every change to the plugin.
The monorepo: packages/core (the XML, CSS and tokens, shared), apps/server (the npm package), apps/plugin (the
Figma plugin), examples/, and docs/ (the website, served by GitHub Pages).
main takes changes only through pull requests, with CI's check job passing; direct pushes, force pushes and
deleting it are blocked (a repository ruleset). Work happens on develop or a topic branch. Set the same version in
apps/server/package.json and apps/plugin/package.json on a branch, give it its section in CHANGELOG.md
(## 1.2.3 — <date>; the format is at the top of that file) and run pnpm build:docs for the website's page, open a
pull request into main and merge it, then tag the merge commit:
git switch main && git pull
git tag v1.2.3 && git push origin v1.2.3The Release workflow checks that the tag matches both versions and has its section in CHANGELOG.md, runs the checks,
publishes pixelwright to npm (trusted publishing, with provenance; a version npm has already is left alone) and
creates a GitHub release with the plugin as a zip and that section as its notes.
The first version goes to npm by hand, because npm sets up trusted publishing only for a package that exists:
pnpm build && pnpm --filter pixelwright pack, then npm publish apps/server/pixelwright-<version>.tgz --access public
from the maintainer's account. Then add the trusted publisher on npmjs.com (GitHub Actions, maximwas/pixelwright,
release.yml) and tag the version as above: the workflow skips npm and makes the GitHub release.
Every release, with its date and what changed: CHANGELOG.md, also on the website.
Questions, ideas and what you built with Pixelwright go to
GitHub Discussions; bugs to
issues, whose form asks for the version, the MCP client, the
system and the lines npx -y pixelwright@latest logs shows around the problem. The plugin's window links there too.
Pixelwright is free and open source. If it saves you time, donate on Ko-fi or to a
monobank jar. At most once a week, after a change that worked, your agent may
mention it; turn that off with PIXELWRIGHT_SUPPORT_REMINDERS=off.
MIT: Pixelwright is open source. Use it, change it and share it for anything, keeping the copyright and
licence notice. The bundled third-party packages' licences ship in the npm package (dist/THIRD-PARTY-NOTICES.md) and
at the end of the plugin's bundles.