Architecture¶
The server is a hexagon: the core knows nothing about REST, WebSockets or SQLite, and the MCP tools see ports only. Folder = layer = namespace.
| Layer | Holds |
|---|---|
Domain |
NodeId, DocumentLocator, PublishedSourceException with SourceFailure, editor exceptions, RingBuffer |
Application/Ports |
the published source split by intent, the live editor, the snapshot stores, diagnostics, the workspace of the extraction |
Application |
the read pipeline, snapshot policy, sanitizer, tokens, extraction, version diffing |
Adapters/Rest |
RestDocumentGateway, QuotaGate — the only place that speaks HTTP |
Adapters/Editor |
the WebSocket hub, command router, file registry, the agents and their slots, server-side sibling discovery, embedded read scripts, capture store |
Adapters/Persistence/Sqlite |
the three snapshot stores and the cache file, prepared on first use and shared by every server process |
Adapters/Workspace |
LocalWorkspace, the one place the design-system extraction reads the user's apps and writes the package |
Tools |
the MCP driving adapter: 141 tools and the prompt catalog |
Extensions / Hosting |
the composition root and the process lifetime |
Two channels¶
READ is plugin-first. While a plugin session exists for the file, reads go through it: zero REST
requests, zero quota, and the current state of the canvas including unsaved edits. Without a session the
server serves a cached snapshot while it is fresh (Cache.FreshnessSeconds, 300 s by default), and only then
calls REST. Every response carries a source field — plugin, cache, figma-api or cache-stale.
The path can be forced per call with the source parameter (auto / rest / plugin) and switched off
globally with Bridge.PreferPluginForReads: false.
WRITE is plugin-only. Mutations are confirmed by the plugin's answer, never assumed. After each one the
node and its ancestors are invalidated; the plugin's DOCUMENT_CHANGE event drops snapshots at the moment of
the edit. Coordinates always come from the plugin (node.x / node.y, relative to the parent).
REST stays the only path for what the plugin cannot do: version history and blame, comments, other teams'
libraries, svg/pdf rendering, and files that are not open in the desktop app.
One target per agent¶
Commands without a file key go to the agent's target. It moves only on the agent's word, figma_navigate or
the file it last named in any tool, and never on the human's clicks: with the plugin open in several files and
several agents around, a click in another file was meant for one of them and the server cannot tell which.
The only connected file is the target by itself; several with none named make the write tools refuse and list
them. When the target's plugin disconnects, the target stays with its file: a command then fails and names that
file, and the agent's next command after the plugin returns goes there again. It is never handed to the file
left behind, which belongs to another agent as often as not.
The third channel: the panel¶
The two channels above carry the agent's intent. A third one carries the human's, and it runs the other way.
MCP is pull-only — a server cannot wake an agent — so the panel cannot deliver a remark by itself. What it
can do is leave one where the agent will look. Typing into the panel field sends BUILD_STOP with the words
and the current selection; the server puts it in a queue of twenty (Domain/Remarks).
What happens next depends on whether anything is running:
- During
figma_execute_stepsthe loop checks the queue between steps and stops at a whole step rather than half a node. Only remarks left after the build began count — one written about the previous result must not kill the build that just started, so it stays in the queue instead. - In the quiet the remark waits for
figma_get_remarks, which is when a human actually reviews a result. Reading takes: the same remark never comes back twice.waitMsholds the call open for up to a minute, so an agent can hear the human within a second instead of at its next turn.
The server answers back over the same socket so the panel never has to guess: REMARKS_WAITING carries how
many remarks are still unread, and BUILD_PROGRESS carries step and total — the plugin receives every
step as its own command and cannot count them, so the share can only come from the server. A step of zero
means the build is over.
A remark is addressed, not broadcast. Every server is connected to every panel, so each server tells each
panel who of its agents works in that file, and the panel sends a remark only to them; the server, in turn,
queues a remark only for agents whose target is that file. With two agents in one file the panel lets the human
pick the addressee by name. The panel also tells every server who else has claimed the file (PEERS), which is
how figma_navigate can name the other agents: servers never see each other.
The bridge speaks two protocols, and each side says which in its first message (SERVER_HELLO, FILE_INFO).
In protocol 1 a connection is one agent: TARGET_BOUND says whether it works in the file and REMARKS_WAITING
how many remarks wait. In protocol 2 a connection carries every agent of its server: AGENTS lists them by id and
name with bound and waiting each, and BUILD_STOP, REMARKS_CLEAR and every command name their agent. Each
side answers the other in the older protocol until told otherwise, so a panel and a server of different ages still
understand each other.
One server for several agents¶
With SNAIL_MCP_FIGMA_TRANSPORT=Http one process serves every agent on the machine over Streamable HTTP, instead of
a process per client over stdio. What a process holds falls into two kinds, and the split follows it.
- Facts about a file are kept once. The open files and their sessions — selection, current page, document and
metadata changes, console lines, the variables snapshot — live in
OpenDocumentRegistry, along with the snapshot cache, the REST gateway and its quota breaker. They read the same whoever asks. - An agent's intent is its own. An
AgentSlotholds the target, the file last named, the remarks left for the agent in each file and its read position in each console. Clearing the console moves that position rather than emptying a buffer another agent reads.AgentRosterkeeps the slots.
Over stdio there is one slot, named after the launch folder, so both ways run the same code. On a shared server every
MCP session opens a slot of its own, and every request has to belong to one. The 2026-07-28 revision of the protocol has
no sessions: its requests carry nothing that tells one window of a client from another, and the server cannot ask a
window for its folder. Claude Code opens with that revision and falls back to initialize when it is refused, so the
server refuses it (-32022) and each window gets a session of its own. A call filter serves each tool call as its session's agent, and services built once,
such as the gateway, ask the roster whose call is in hand each time rather than holding an agent. The agent is named
after the first folder its client lists as an MCP root, otherwise after the client's name, otherwise agent N. Two
sessions of one project — a second conversation, or a window that reconnected before its old session timed out — are
Keynex and Keynex 2 in the server's log, and one agent everywhere else: the panel shows the project once, its
remarks wait in one queue that whichever session reads first takes, and neither session lists the other among the
agents working beside it.
Commands take turns. The plugin runs on Figma's one plugin thread and starts every command it is given. The router keeps one command per agent in flight per file, so one agent's long script cannot push another's short read behind a dozen of its own. The timeout starts when a command leaves its agent's queue, so it is never spent waiting in the plugin behind the agent's own earlier commands. Letting a few reads of one agent overlap was tried and gained nothing: Figma renders one export at a time, and three screenshots took as long together as one after another. An agent whose session ends gives up the commands still waiting. A connection that drops fails its waits at once instead of letting them run into their timeouts.
Failure stays where it happened. A plugin message the hub cannot read is logged and skipped, never allowed to
end the connection every agent in that file shares. A background service that fails is left stopped rather than
allowed to stop the host. launchd brings a process that falls back within seconds. The panels reconnect by
themselves, and a client opens a new session on the 404 for the old one. The agent's file is written to
agents.json in the data folder whenever it chooses one and when the server stops. An agent that comes back from the
same folder gets its file again without a navigate, while the record is less than an hour old or the file is
connected. A command that was in flight when the process fell is lost and has to be repeated.
The two ports stay apart. MCP answers on 127.0.0.1:9233 and refuses any request with an Origin header: a
browser always sends one, an agent's client never does. The bridge keeps its own port in 9223–9232 and its own
origin check, which admits Figma. Each route is bound to its port. A refusal for the whole host, as the shared
servers without a bridge map it, would have cut off the plugin, which always sends Origin.
A session nobody has used for 15 minutes ends, and its agent leaves the panel: a VS Code window that was killed never says goodbye.
Who the bridge trusts¶
Figma runs a plugin's window with Origin null, and any web page can take that origin in a sandboxed iframe, so the
origin check cannot tell the plugin from a page posing as it. What a page cannot do is read a file on disk. The first
server that starts on the machine writes a secret into the data folder (bridge.token, readable by its owner alone,
created exclusively so two servers starting together agree on one), and the installer writes it into the panel it
copies into plugin/. The panel sends it back in FILE_INFO; a panel without it — an older bundle, or a page — is told
an update is waiting, which is what the human sees, and the connection is closed. A reply to a command is taken only
from the connection the command was sent on: request ids are a counter and a clock, easy to guess.
The bridge reads a message of at most 128 MB — a screenshot of a whole page at 4x is about 70 MB in base64 — and drops a longer one as it arrives, keeping the connection every agent of that file shares. It parses a message from the bytes it arrived in: decoding them into a string first cost four times the message in allocations. It pings each panel every 20 seconds and gives up a connection whose pong does not come back: a Figma window that froze, or a laptop that slept, used to leave the file connected with nobody behind it.
Variables: one shape, a snapshot that can be doubted¶
REST answers variables as dictionaries keyed by id, the plugin as arrays whose items carry their own id.
VariablesShape brings both to the keyed form before anything reads them, and refuses a payload that is
neither: an empty answer is a claim about the file, and read carelessly a plugin array used to come back as
"0 collections" with ok: true.
The plugin pushes its variables at start-up and the server keeps them on the session, so a read costs no
socket round trip. The snapshot is believed only while nothing was written since (any command of this server,
any DOCUMENT_CHANGE) and while it is younger than thirty seconds — Figma raises no event for a variable
edited in its own panel, so age is the only signal for that. A doubted snapshot is replaced through
REFRESH_VARIABLES, the one command that makes the plugin read the file again.
One read, at most one request¶
The snapshot cache is a SQLite file, opened in the background as soon as the server starts. Its first open costs up to a second, nearly all of it macOS looking into the native SQLite library the first time a publication loads it; opened at start, that second is spent while no call waits.
A read costs at most one REST request, and none at all while the snapshot is fresh. The file version is taken
from the response envelope that already arrived rather than from a separate depth=1 probe — that probe sits
in the same expensive rate-limit bucket as a full read, so it would double the quota cost instead of saving
it. Precise invalidation comes from the plugin instead.
Quota circuit breaker¶
A dropped connection, an HttpClient timeout and a 502, 503 or 504 of Figma's edge are retried for a read, twice, a
second and two seconds apart; a comment is not posted twice. What still fails leaves the REST adapter as the same
Unavailable failure as an error Figma answered with, so a cached snapshot is served as cache-stale and the agent
reads the cause instead of a bare "an error occurred".
When Figma answers 429 with a long Retry-After (a daily plan quota), the endpoint bucket is closed locally
until it expires and further requests to it are refused without a network round trip — a refusal that is
already known is not worth a request, and failed requests appear to count against the limit as well. Buckets
are per endpoint (files, files/nodes, images), so a 429 on one does not silence the others. A successful
response reopens a bucket immediately. The state is visible in csharp_diagnose under rateLimit.
Images¶
figma_get_component_image goes plugin → snapshot → REST, and only the last step spends quota. A render link
lives 30 days at Figma and is cached for Cache.ImageTtlHours (24 h). The image cache is dropped for the whole
file on any DOCUMENT_CHANGE, because what is usually cached is the render of a parent node while the plugin
reports only the nodes that changed directly.
What the catalog tells a host¶
A client that mounts several servers no longer hands the model every schema: it shows the handshake instructions and a search box, loads a tool when the model asks for it, and may run a script that calls tools from a sandbox. The server meets that in three places.
- Instructions at the handshake.
initializecarries the server instructions: where to start (figma_navigate), which family answers which task, and when onefigma_executescript beats a chain of calls. Claude Code cuts server instructions at 2,048 characters, so the text stays under that and puts what matters first;ServerInstructionsTestskeeps every tool class mentioned, every reference real and the length in. - Behaviour hints on every tool. All 141 tools state
readOnlyHint,destructiveHint,idempotentHintandopenWorldHintexplicitly. Read-only means the tool changes neither the Figma file nor the user's files (a capture saved beside the server is a read); destructive means it overwrites or deletes what exists; open-world means it may reach the REST API or a URL rather than the connected file only. A host uses them to let reads through without a prompt and to ask before a delete, including the calls a script makes from a sandbox. The convention tests fail on a tool that leaves a hint silent, on a read-only tool marked destructive, and on a_delete_tool that is not. - An output schema where the answer is stable.
figma_get_statusandfigma_list_open_filesadvertiseoutputSchemaand answer withstructuredContentnext to the usual text, so a host generating typed stubs gets a return type instead ofany. The other tools keep the text envelope: their answers are Figma trees, and a schema of those would be wrong more often than useful.
Process lifetime¶
Over stdio the server talks JSON-RPC on stdin and stdout, so every log line goes to stderr — a single stray write to
stdout would break the client. A shared server has no idle exit and logs to the file launchd gives it; the rest of
this section is about stdio. An hour without a tool call and the process exits on its own
(IdleTimeoutMinutes, 0 disables it) so that orphaned servers do not pile up. A connected plugin holds the
countdown: while the WebSocket session is open the server is in use, and the full timeout starts over once the
last session drops.