Files

120 KiB
Raw Permalink Blame History

FigCraft MCP server

FigCraft ships a Model Context Protocol server so AI agents can design with it: create and edit layers, run any menu command, inspect the result as JSON and look at it as a PNG. It speaks newline-delimited JSON-RPC 2.0 over stdio (MCP protocol 2025-06-18; 2025-03-26 and 2024-11-05 clients are accepted too).

The server lives in crates/mcp (figcraft-mcp) and is started by the CLI:

Command Backend
figcraft-cli mcp Headless: an in-process engine session with one untitled document. No window; renders with the CPU renderer.
figcraft-cli mcp --attach <port> Attached: drives a running FigCraft app through its control channel (figcraft --control <port>). The user sees every change live.

--attach also takes host:port. The control channel only listens on 127.0.0.1.

Fonts

The headless server lays text out with the same fonts as the app: the fonts built into FigCraft plus the installed system fonts, indexed once, the first time text is laid out (a session that never touches text never pays for it). So text sizes, auto layout and exports match what users see in the app. For renders that come out the same on every machine (CI), start it with figcraft-cli mcp --no-system-fonts or set FIGCRAFT_NO_SYSTEM_FONTS=1: text then uses only the built-in fonts. Attached, the app's own fonts apply. The browser build has no system fonts.

Configure a client

Build the CLI first (cargo build --release -p figcraft-cli) and use the absolute path to target/release/figcraft-cli.

Claude Code

# headless
claude mcp add figcraft -- /path/to/figcraft-cli mcp
# drive the running app (start it with: figcraft --control 7979)
claude mcp add figcraft-app -- /path/to/figcraft-cli mcp --attach 7979

Or in a project's .mcp.json:

{
  "mcpServers": {
    "figcraft": { "command": "/path/to/figcraft-cli", "args": ["mcp"] }
  }
}

Claude Desktop

claude_desktop_config.json (Settings > Developer > Edit Config):

{
  "mcpServers": {
    "figcraft": { "command": "/path/to/figcraft-cli", "args": ["mcp"] },
    "figcraft-app": { "command": "/path/to/figcraft-cli", "args": ["mcp", "--attach", "7979"] }
  }
}

Conventions agents must know

  • Coordinates are page units (1 unit = 1 px at 100% zoom), x right, y down.
  • create_node and set_properties take x/y relative to the parent frame (the page when there is none, so they are page coordinates then). Groups and boolean groups have no coordinate space of their own: a layer in a group is placed in the frame (or on the page) the group is in. inspect_document reports x/y the same way, plus absoluteBounds in page coordinates.
  • Frames, groups, sections and masks: drawing a frame or section with the pointer tool around layers puts those fully inside it in it; sections go only on the page or in sections (moves, drags, pastes and new sections elsewhere are refused or land on the page); resizing a section leaves its contents where they are, and so does resizing a frame with cmd held (constraints ignored); a layer a mask hides can't be clicked there, and a mask group's box and exports are the mask's (plus any layers under the mask).
  • Children of an auto-layout frame are positioned by the layout: set layout, gap, padding, mainAlign, crossAlign, sizingH/sizingV instead of x/y. More auto layout props:
    • sizingH / sizingV: fixed, hug (auto layout frames and text) or fill (children in an auto layout flow). A child can't fill an axis its parent hugs: setting fill turns the parent fixed on that axis, and a parent set to hug turns the children that filled it fixed. A layer moved out of auto layout (or whose frame loses it) keeps its size as fixed. Fill children share the space like CSS flexbox, within their min/max.
    • minWidth / maxWidth / minHeight / maxHeight (px, null removes) bound Hug, Fill and Fixed sizes; a hugging text wider than its max width wraps.
    • absolute: true takes a child out of the flow (it stays where it is and follows its constraints when the frame resizes; Fill becomes Fixed).
    • mainAlign takes min, center, max or an auto gap: spaceBetween (no space at the edges), spaceEvenly (the same space in every gap and at the edges) or spaceAround (gaps twice the edge space). An auto gap never goes below 0.
    • padding never lets a frame get smaller than it: a fixed or hugging frame (or one with a smaller max size) sizes up to its padding.
    • strokesIncluded (default true for new auto layout): the frame's own inside stroke takes up space like padding (per side with strokeSides); center and outside strokes never do.
    • reverseZ (canvas stacking): true paints and hits the first child on top, false (default) the last. The layer order doesn't change.
    • crossAlign: "baseline" lines up the first text baselines in horizontal layouts (other layers align to the top; nested auto layout frames use their first child's baseline).
    • wrap with crossGap (alias rowGap) for the gap between wrapped rows (horizontal) or columns (vertical layouts wrap top to bottom into new columns).
    • object.convertToGroup / object.convertToFrame turn a frame into a group or back in place (layers stay put; a group has no auto layout, fill, stroke, corner radius, clipping or grids).
    • contentAlign (wrapping layouts): auto (default) packs the lines crossGap apart at the start, centre or end crossAlign names (lines of nothing but fill children stretch instead); spaceBetween puts the free space between the lines (one line, or no free space: like auto).
    • Grid (layout: "grid"): gridColumns, gridRows (minimum; rows grow), columnGap / rowGap, track sizes columnTracks / rowTracks as [120, "1fr", "auto"] (px fixed, fr fill share, auto hug) or the layout.setTrack {axis, index, sizing, value?} command; on a child, gridColumn / gridRow (0-based anchor cell, null = placed automatically) and gridColumnSpan / gridRowSpan, and gridAlignH / gridAlignV (min, center, max; auto or null uses the frame's alignment) to align it in its cell. fr tracks behave like the CSS fr unit: a track never gets less than its content (a fixed-size child bigger than its share keeps it; the other fr tracks share the rest). inspect reports a grid child's resolved gridCell.
  • Every menu action is a command (list_commands), and most commands act on ids when given, else on the selection. New layers become the selection.
  • Colours are "#RRGGBB" or "#RRGGBBAA". Inside fills, strokes and effects passed through the MCP tools, color may also be a hex string and gradients can be written {"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}; the server converts them to the engine's paint JSON.
  • Blend modes: blend on a layer (node.set) or a paint (fill.set / stroke.set) is a menu label or CSS keyword ("Normal", "pass through", "multiply", "plusDarker", ...; normal is Normal). Groups and frames start as pass-through: their children blend with what is behind them, through clipping too (SVG exports put a clipping frame's clip on what it holds, since a clipped group would isolate it); Normal blends the children among themselves first (SVG isolation:isolate, a PDF isolated group). Plus darker is exact in renders and pixel exports; PDF has no such mode (multiply) and SVG writes the CSS plus-darker keyword. Inspecting a layer reports a paint's blend when it isn't normal.
  • Drop shadows hide behind what their layer covers, its fills (translucent and 0% ones too) and strokes, unless the effect has showBehind: true; groups, and layers whose pixels a layer blur or texture moves, hide them behind their pixels instead. SVG exports cut them out the same way, with filter regions as large as the effects reach.
  • Colour profile: colour values are in the file's profile, sRGB (default) or Display P3 (inspect_document reports colorProfile). file.colorProfile {profile: srgb|displayP3, convert?} changes it: convert (default true) converts every colour value so colours keep their appearance; false keeps the values and reinterprets them. Images are converted from their own colour profile into the file's space: an embedded ICC profile (PNG, JPG, WebP, TIFF; RGB or grey), a PNG's sRGB / gAMA / cHRM chunks, else sRGB (also for CMYK or damaged profiles); image.place reports the profile's name as imageProfile (null: none usable, taken as sRGB). PNG exports are tagged (sRGB chunk, or a Display P3 ICC profile plus cICP), Display P3 JPG and WebP embed the ICC profile, and SVG and PDF export Display P3 files converted to sRGB (out-of-gamut colours clip). The app's canvas shows sRGB (P3 colours outside sRGB clip on screen): view.proofSrgb {on?} (View ▸ Soft-proof sRGB) stripes those pixels so screenshot {window: true} shows where they are (after the next canvas render), and the colour picker says Outside sRGB for such a colour.
  • Image adjustments: image paints carry filters (exposure, contrast, saturation, temperature, tint, highlights, shadows; each -1..1). image.adjust {ids?, target?: fill|stroke, index?, preset?, filters?} applies a built-in preset (vivid, muted, warm, cool, blackAndWhite, fade, reset) or one saved in the file, with any filters values on top. image.savePreset {name, filters?} saves the current (or given) adjustments in the file, image.deletePreset {name} removes one and image.presets lists both kinds.
  • Text props (fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textDecoration, textCase, textFills, fontVariations…) apply to the whole layer, or to chars start..end when given (text.style {id, start, end, …} does the same). Styled ranges come back as styledRuns. Variable fonts: fontVariations (alias variationSettings) sets axis values in user units, e.g. {"wght": 550, "wdth": 85} (null = back to what the style name implies); text.setVariations {variations, replace?} merges instead. text.fonts {family, style?, axes: true} lists a font's axes and named instances (named instances are also valid fontStyle values). OpenType features: features {"liga": false, "tnum": true}. Decorations: textDecorationStyle (solid, dotted, wavy), textDecorationThickness and textDecorationOffset (auto, px, or "10%" of the font size; the offset runs from the baseline down to an underline's top edge), textDecorationSkipInk (underlines break around descenders) and textDecorationColor (a colour, or auto for the text's) are character props too.
  • Lists are per paragraph: listStyle (none, bulleted, numbered) and indentation (the nesting level, 0–8) apply to the paragraphs chars start..end touch (all of them without a range); get_node reports one listStyle for the layer, or lineTypes and lineIndentations per paragraph. text.bulletList, text.numberList and text.listStyle act on the selected paragraphs while text is edited. Layer props: listSpacing (px between list items, in place of paragraphSpacing), hangingList (markers outside the box) and hangingPunctuation. While editing, key Tab / Shift+Tab nest an item or move it out, Enter on an empty item and Backspace at an item's start step out of the list, and typing - , * or 1. at a paragraph's start starts one.
  • Text styles link chars, not just layers: style.apply {style, ids?, start?, end?} with a text style links chars start..end (while text is edited, the selected chars; else whole layers), and style.detach {which: "text", start?, end?} unlinks them (values stay). A layer's styles.text is the style all its chars share (none when they differ); get_node lists the linked chars as textStyleRanges ([start, end, style id]). Changing what a text style sets (font, size, line height, letter spacing, decoration, case, axis values) on linked chars unlinks them; style.update moves every linked char. style.create {type: "text", name} takes the selected chars while editing; a value may use the node.set spellings ({"fontSize": 32, "lineHeight": "120%", "letterSpacing": "2%"}).
  • Styles stay true to their layers: editing a styled fill, stroke, effect or layout grid list (node.set, fill.set, fill.add, a bound variable…) unlinks that style (styles.fill etc. go null), so a later style.update doesn't overwrite the edit; style.apply replaces variables bound to what the style sets; a text style goes on text layers only. Names are trimmed per / folder part. style.move {styles, before?, folder?} reorders (and with folder, renames into it), style.duplicate {style} adds " copy" right below, style.renameFolder {folder, name}.
  • Variables: variable.bind refuses a variable of a type the field doesn't take (a colour on width); scopes only filter pickers. variable.delete / variable.deleteCollection unbind the layers using them (they keep their last value) and give aliases to them their values. variable.addMode {from} duplicates a mode, variable.moveMode {collection, mode, to} reorders (index 0 is the default mode, which layers on Auto follow). variable.update {codeSyntax: {WEB?, iOS?, ANDROID?}} sets code names ("" clears); generated CSS uses the Web one.
  • Live previews: text.preview {command, params} shows what style.apply, library.applyStyle, text.setVariations or text.style would do on the canvas without an undo step (in place of an earlier preview); text.endPreview puts the document back. Any other command ends the preview first, so running the command for real commits it as one undo step.
  • Every mutation is one undo step (batch makes several one step). A failing command leaves the document as it was.
  • Programmatic calls never open dialogs.
  • Verify your work: inspect_document / get_node for numbers, screenshot for pixels.

Tools

Tool What it does
list_commands {filter?} Every command: id, label, menu, shortcut, undoable, params doc.
run_command {command, params} Run any command (e.g. align.left, layout.add, component.create, instance.place (drop an instance at a page point into the frame or auto layout slot under it), component.exposeInstance / instance.exposed (nested instance properties on instances), component.makeSlot / instance.slotInsert / instance.resetSlot (slots, below), inspect.code (CSS, HTML + CSS for a whole frame, React, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), inspect.properties (layout, auto layout, typography, fills, strokes and effects with their style and variable names; colorFormat: hex, rgb, css, hsl or hsb), inspect.assets (layers under the selection with export settings, icons and images, each with its kind), inspect.code takes unit: rem and rootFontSize for CSS, HTML and React, style.create, variable.bind, variable.update (rename, describe, scope), variable.set with {alias}, variable.renameMode / variable.removeMode, variable.deleteCollection, proto.interaction.add, codeConnect.set (components linked to real code, see Dev Mode below)).
batch {commands: [{command, params}], label?} Several commands as one undo step (command.batch); all-or-nothing.
create_node {type, x, y, width, height, parent, index, name, text, path, props} Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns {id, node}.
set_properties {ids, props} node.set on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (isMask, maskType: alpha|vector|luminance), handoff status (devStatus: none|readyForDev|completed; marking again clears "Changed"), a section's sectionContentsHidden, text…); returns the updated layers.
inspect_document {depth, allPages} The document as JSON (layer tree, styles, selection, history).
get_node {id, depth} One layer as JSON.
select {ids} Replace the selection ([] deselects).
export {ids, format, scale | size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?} png, jpg, svg, webp, pdf, or fig: the whole file as .fig, experimental (see Exporting .fig files below); size is 2x, 512w or 512h. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it); contentsOnly: false also draws every other layer showing in that area, includeBoundingBox exports the layers' own boxes; SVG: svgOutlineText: false keeps <text>, svgIdAttribute writes unique ids from layer names, svgSimplifyStroke: false writes every stroke as an outline. The same options go in export settings (export.add / export.update). PDF: frames one page each (sized in points), anything else together on one page; print options: bleed (points or a length like "3mm", "0.125in"), crop and bleed marks, CMYK colour (see Exports below), also taken by file.export, file.exportLayers, file.exportFramesPdf and stored per export setting by export.add / export.update; run_command file.exportFramesPdf {page?, path?} writes every top-level frame of a page. Without path, images come back as MCP image content, SVG as text. Layers' own export settings: run_command with export.add / export.update {index, size?, suffix?, format?} / export.remove and file.exportLayers {ids?, dir?, assets?} (every layer once per setting, named <layer><suffix>.<format>, a slash in a layer name making a folder; assets: true exports the Inspect tab's assets instead: icons as SVG, images as PNG).
screenshot {ids?, scale?, maxSize?, window?} PNG of the page (with background) or of layers, scaled to fit maxSize (default 1600). window: true captures the app window in front when attached, a window id (see Windows below) that window.
open_file {path, variables?} / save_file {path?} Open .figcraft, .json (FigCraft JSON or REST API file JSON with its variables, see below), .fig (best effort, see below), .svg (see Importing SVG below) or an image; save as .figcraft.
undo / redo Step through history.
pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?} Mouse gestures in page coordinates with the current tool: click, double, drag, down, move, up, hover. A dragged layer drops into the frame or auto layout slot under the pointer, except one inside a group, a boolean group or an instance (outside its slots), which only moves. pressure (0–1) is pen pressure; a drag goes from pressure to pressureTo (see Brushes and pen pressure).
key {key, shift, alt, cmd} A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity, arrows that pan the view when nothing is selected; while editing text, the text shortcuts and then text editing keys). Keys are names like A, 0, Enter, ArrowLeft, =, /, or egui's (Plus, Questionmark); cmd is Cmd on macOS and Ctrl elsewhere.
type_text {text, id?} Type into the text being edited, or replace the contents of text layer id.
inspect_ui {} Tool, view and status (and panels, timings and the app's windows when attached).

Starter templates

An agent can start from a template instead of an empty page. run_command {command: "template.list"} returns the starter templates, each {id, name, description, kind, size} (kind: app, website, kit or media): mobile-app, landing-page, dashboard, design-system, wireframe-kit, social-posts, slides and icon-set. run_command {command: "file.newFromTemplate", params: {template, name?}} builds one as a new, unsaved document, makes it the active one and returns {index, template}; open documents are left alone. The templates are built in code from the same commands an agent uses (frames with auto layout, components and instances, colour and text styles, variables with Light and Dark modes, prototype links), so everything in them is editable; their content is synthetic. Building one takes from about 10 ms to about 190 ms. Without template the command returns {templates} (in the app it also shows the home screen's Templates tab). help.gettingStarted {show?} shows (or with show: false closes) the home screen's Getting started tips; headless, it only sets the preference.

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"file.newFromTemplate","params":{"template":"dashboard"}}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"export","arguments":{"format":"png","path":"dashboard.png"}}}

Sections, handoff and arrow keys

run_command with object.wrapInSection {ids?, name?} (Wrap in new section: layers on the page or in a section, with some room around them), section.toggleContents {ids?, hidden?} (a section hiding its contents shows only its name and outline; its layers aren't drawn or hit, and the layers panel dims them), object.resizeToFit. A frame or section marked devStatus: readyForDev turns "Changed" (devChanged: true in get_node) when a layer in it is edited, added or removed; dev.resolveChange {ids?, reason?} returns it to Ready for dev and keeps the reason (devNote). Frames in sections are top-level frames (flows can start there; proto.flow.add); a "Navigate to" whose destination is a section shows the frame of it shown last in the presentation, else its first.

transform.nudge {dx, dy} (arrow keys) moves auto layout children one place instead of nudging: along the flow, to the previous/next row of a wrapping layout, to the neighbouring grid cell, and, at the end of a flow or across a row's axis, into the neighbouring nested auto layout frame when the frame around flows that way (a list of rows: Down moves an item into the row below).

Keyboard shortcuts

The default shortcuts live in one table (figcraft_engine::keymap), grouped like the public shortcut reference (Essential, Tools, View, Zoom, Text, Shape, Selection, Edit, Transform, Arrange, Components, Prototype) with the macOS keys and, where they differ, the Windows/Linux keys (for example Use as mask is ⌃⌘M on macOS and Ctrl+Alt+M elsewhere; Bring to front ⌥⌘] and Ctrl+Shift+]). list_commands reports each command's first key for the platform the server runs on; key resolves keys the way the canvas does. Commands behind keys:

  • object.opacityKeys {digits, ids?}: the opacity number keys. One digit is tens of percent ("0" is 100%); two digits are the exact percentage ("45" is 45%, "05" is 5%), what typing two digits in quick succession does on the canvas.
  • view.zoomAt {x, y, out?, width?, height?}: the zoom tool (hold Z on the canvas; Alt zooms out). A point zooms one step in (or out) keeping that page point in place; an area fills the view.
  • view.designPrototype (Shift+E), view.devMode (Shift+D) and view.leftSidebar (Shift+Cmd+\) switch the right panel's tab or hide the left sidebar in the app (no-ops headless).
  • doc.nextTab / doc.previousTab (Ctrl+Tab / Ctrl+Shift+Tab): the next or previous open file.
  • view.pan {dx, dy, big?}: the arrow keys with nothing selected (key ArrowRight does the same) pan the view by 20 screen pixels a step, 200 with big (Shift).
  • view.multiplayerCursors {on?} (Alt+Cmd+\): show or hide others' cursors in a live session.
  • view.outlinesHiddenLayers {on?} and view.outlinesObjectBounds {on?}: what outline mode (view.outlines, view.outlinesSelection) draws besides the shapes: hidden layers, and the box of every layer whose outline isn't its box (groups, text, ellipses, vectors). What they draw can be clicked (pointer) in outline mode. View settings of the file: no undo step.
  • view.panelWidths {left?, right?}: the app's side panel widths in pixels (kept within their limits; without either, both go back to the default). A no-op headless.
  • component.hideWhenPublishing {ids?, on?}: keep components or sets (those ids, or the layers given, are or are in; default the selection) out of the library the file publishes, or let them back in (on: false; without on it toggles). The sign of it is the name: hiding puts a . before it, showing takes the leading . and _ away. Returns {ids, hidden}; one undo step.
  • New files and pages: file.new and page.add give a page the colour #F5F5F5. Only the app with its dark theme showing makes them #1E1E1E; headless sessions (the CLI, this server) have no theme. Text made with text.create or node.create is black whatever the page here; in the app (the control channel, --attach) it is white on a dark page unless props name a fill, as the Text tool (pointer with tool.text) makes it everywhere.
  • transform.rotationOrigin {on?} (Alt+R): show or hide the selection's rotation origin handle (toggles without on); transform.setRotationOrigin {x, y} moves the origin (page coordinates), {reset: true} puts it back at the centre. Both return {editing, moved, x, y}. The corner rotation handles, a rotation set through node.set on the selection, Rotate 90°/180° and transform.rotate without cx/cy turn about it. It belongs to the selection, follows the layers as they move and turn, and is dropped when the selection changes; it isn't saved.
  • tool.place {tool?, x?, y?}: what Enter does with a shape or frame tool chosen (key Enter does the same): makes what a click with the tool makes (100×100; lines 100 long; sections 400×300) centred on x, y (default the middle of the view), selects it and returns {ids}. One undo step.
  • text.listIndent / text.listOutdent (Cmd+] / Cmd+[ while editing text): the selected list items a level deeper or out, like Tab and Shift+Tab.
  • view.nextRegion / view.previousRegion (F6 / Shift+F6, live while editing text too): move the keyboard between the app's regions (toolbar, layers, canvas, properties). In the app, Tab and Shift+Tab then move between the region's controls, Enter or Space activate one, the arrows move along the toolbar and through the layer rows, and Escape returns to the canvas (no-ops headless).
  • object.rename {ids?} without a name (Cmd+R, or F2): rename the layer inline in the app's layers panel, also from a layer row that has the keyboard; when the rename ends the row gets the keyboard back (Escape cancels it there). With several layers it opens the Rename layers dialog. Headless it changes nothing and leaves no undo step.
  • object.batchRename {ids?, find?, regex?, replace?, name?, start?}: rename several layers in one step. find (plain text, or a regular expression with regex: true) has each match replaced by replace; name renames the whole name. Both take codes: $& the current name (the match when matching), $` and $' the text before and after the match, $1…$9 groups, $n a number counting up from start (default 1), $N one counting down to it, $nnn padded to three digits, $$ a dollar sign. Layers are numbered in stacking order, bottom first. Returns {names}.
  • Pages: page.add {name?} (a name starting with - adds a divider: an empty page shown as a line in the pages list, never opened and holding no layers), page.list (each page's divider), page.rename, page.move {id, index}, page.duplicate ("Copy of …", after its source), page.delete (the last page that isn't a divider stays).
  • component.list: each component's folder (the Assets panel's folders: its page, the frames holding it, then its name's slash parts) and label (the name on its tile).
  • view.contextMenu (Shift+F10): the selection's context menu, at its layer row when the layers panel has the keyboard (after F6 or a click on a row), else on the canvas below the selection; the menu takes the keyboard and gives it back when it closes (no-op headless).
  • select.area {x, y, width, height, add?, deep?}: select what a marquee from x, y over that box (page units) would: top-level layers, a frame only when the box covers it, the children of the top-level frame the box starts in; add toggles like a Shift marquee, deep takes nested layers like Cmd/Ctrl. Returns {selection}. Without a box (F8 in the app) it draws one for the keyboard around the selection (or in the middle of the view): the arrows move it, Shift+arrows move its far corner (10 screen pixels a press), Enter selects (Shift+Enter adds, Cmd/Ctrl+Enter nested layers), Escape or a click drops it.
  • pref.reduceMotion {on?}: reduced motion (the interface's own animations off; the system's accessibility setting turns it on too). Prototype transitions still play.
  • pref.language {language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and every result stay English.

Editing vectors

Vector edit mode is driven with run_command (or pointer/key for gestures): vector.edit.begin {id? | ids?} (default the selection; converts shapes to vectors; several layers are edited at once, the first active; key Enter with shapes selected does the same), vector.edit.state (page-space overlay of the active layer, its local network, each point's handle mirroring, the width points, layers (every edited layer, the active first), others (the other layers' selected points and segments) and handles (the selected Bezier handles as [segment, start])), vector.edit.tool {tool: move|pen|bend|paint|lasso|cut|eraser|width}, vector.edit.select {vertices?, segments?, handles?: [[segment, start], ...], id? (an edited layer, made active), add?}, vector.edit.selectAll (every edited layer's points), vector.edit.moveVertices, vector.edit.nudge, vector.edit.position {x?, y?}, vector.edit.setHandle, vector.edit.mirroring {mode: none|angle|angleAndLength}, vector.edit.smooth {smooth?}, vector.edit.bend, vector.edit.addVertex, vector.edit.connect {a, b}, vector.edit.pen {x, y, dx?, dy?} (continues from the pen's open end; clicking a point connects or branches), vector.edit.penEnd, vector.edit.paint {x, y} | {points} (paint bucket: fills or unfills the closed area under a page point; with points, the areas under the rest go the way the first went, like a drag), vector.edit.cut {segment, t} | {vertex} | {from: [x, y], to: [x, y]} (the last cuts every path the page line crosses; parts that come apart move to layers of their own above, listed in layers), vector.edit.erase {points, radius?} (the eraser), vector.edit.lasso {points}, vector.edit.delete, vector.deleteHeal, vector.join / vector.smoothJoin (join the selected points while editing), vector.edit.cornerRadius (rounds the selected sharp points, next to straight or curved segments: a curve is cut where the arc meets it and keeps the rest of its shape; drawn and exported; node.set {cornerRadius} on a vector rounds every point), vector.edit.strokeCap {cap, vertices?} and vector.edit.strokeJoin {join, vertices?} (the stroke's cap where it ends at these points and its join where it turns there, null for the layer's; drawn, exported and outlined), vector.edit.moveHandles {dx, dy, handles?} (moves the selected handles by a page offset), vector.edit.end. Point indices refer to network.vertices in vector.edit.state; local coordinates are the layer's own.

Several layers at once: with more than one layer in edit mode, a press on another layer's point, handle or segment makes it the active layer; Shift-click and marquee or lasso select points in any of them; dragging a selected point, the arrows, Delete, vector.edit.nudge, vector.edit.moveVertices, vector.edit.delete, vector.deleteHeal, vector.edit.mirroring, vector.edit.smooth, vector.edit.cornerRadius, vector.edit.strokeCap and vector.edit.strokeJoin reach every layer's selected points in one undo step (a vertices list names points of the active layer only). The pen, bend, paint bucket, cut, eraser, join, position and the transform box work in the active layer; the box hides while points are selected in more than one layer. Shift-click Bezier handles to select several; dragging one drags them all.

The Pen tool outside edit mode: clicking the path's first point closes it; clicking another of its own points (not the last) connects there and ends the path; with an open vector selected, a click on one of its end points continues that path in edit mode. vector.deleteHeal fits the healed curve through where the deleted point was, keeping the neighbours' tangents. With pointer, the Paint bucket and Cut tools also drag (across areas to paint, across paths to divide), one undo step each; shift on a selected point at a corner of the points' box takes it out of the selection on a click and scales in proportion on a drag.

The selected points' transform box: vector.edit.bounds ({x, y, width, height, corners, points}: the box in the vector's axes, x/y in the parent's coordinates like the Position fields), vector.edit.resize {width?, height?, center?} (keeps the top-left unless center), vector.edit.rotate {angle, cx?, cy?} (degrees counter-clockwise about the box centre or a page pivot), vector.edit.transform {matrix: [a, b, c, d, e, f], vertices?} (any page-space affine). While points are selected, object.flipH / object.flipV and the 90°/180° rotations act on them. On the canvas (pointer), dragging points, handles and pen clicks snap to other points and segment midpoints (this vector's and other vectors'), the usual layer targets and the pixel grid; cmd held while dragging turns the object snapping off.

Variable width strokes: stroke.widthProfile {ids?, preset?: uniform|wedge|taper|eye|rounded|pinch, points?: [[t, scale], ...]} (also node.set {widthProfile}; t runs 0–1 along each subpath by length, scale multiplies the stroke weight, widths in between are interpolated smoothly; no preset or points = uniform). While editing, the Variable width tool (width, ⇧W) drags width points: vector.edit.widthPoint {t, width? (px) | scale?} adds one, {index, t?, width? | scale?} moves or resizes it, {action: "remove", index} deletes it. Rendering, PNG/SVG/PDF export and vector.outlineStroke follow the profile.

One-sided widths: a width point can be wider on one side of the path than the other (left and right looking along the path, y down). Points are [t, left, right] (or {t, left, right}) where each side's scale is what scale would be with both sides that wide, so [t, s] equals [t, s, s]; get_node reports one-sided points that way. vector.edit.widthPoint {index, side: left|right, width? | scale?} changes one side (width: the distance from the centre line to that edge; with t it adds a point keeping the other side as it is); a width without side makes the point symmetric again. With pointer, alt: true while dragging a handle (or the path) changes that side only; vector.edit.state lists widthPoints with both handle positions (sides: right, left) and oneSided.

Prototypes

Interactions are proto.interaction.add / set / remove / clear (a navigate action can keep the scroll position with preserveScroll: true; {type: "scrollTo", dest, offset?: [x, y]} stops the layer that far from the top-left of the innermost frame scrolling it, e.g. below a fixed header; delayMs makes a mouse enter, leave, down or up trigger wait before it acts; {type: "openOverlay", dest, position: "manual", offset?: [x, y]} puts the overlay that far from the top-left of the layer that opens it, and right below that layer without an offset). A key trigger takes a key or a combination ({type: "keyPress", key: "Shift+K"}; Ctrl, Alt, Shift and Cmd, all held and no others) or a controller button (key: "Gamepad A"; Gamepad followed by A, B, X, Y, LB, RB, LT, RT, View, Menu, LS, RS, Up, Down, Left, Right or Home: the standard layout, face buttons by position), which the player reads from controllers on Windows and in the browser and which the control channel presses with ui.input {events: [{gamepad: "A"}]}. Widgets react to clicks in the player, on its own copy of the file (see plugins.md). In the player, an "on drag" trigger with a move, push or slide animation starts only for a drag that way, and the animation follows the pointer until it lets go; spring easings play until they settle (their duration follows from the spring, not durationMs); an overlay's after-delay timer starts when it opens; instances of a component play the interactions on the variant they show unless they have their own for that trigger; fixed layers draw above the content scrolling under them. Variable actions: {type: "setVariable", variable, value} sets a value, or with expr an expression computed when it runs; {type: "setVariableMode", collection, mode} switches a collection's mode for the prototype (ids or names; modes chosen on layers inside the screen still win); {type: "conditional", blocks: [{condition?, actions}]} runs the first block whose condition holds (a block without one is the "else"). Expressions are {value: {type: number|string|boolean|color|alias, value}} (alias reads a variable) or {op: {op, args}} with add (text joins when a string is involved), subtract, multiply, divide, equals, notEqual, less, lessOrEqual, greater, greaterOrEqual, and, or, not and negate; {inMode: {variable, mode}} reads a variable's value in one mode of its collection whichever mode is chosen, and {mode: {collection}} gives the name of the mode a collection is in where the interaction runs (as text; it follows set variable mode actions). Variables, collections and modes may be given by name; they are stored by id. proto.evaluate {expression | expr, id?} computes an expression without changing anything, with variables in the modes where layer id's interaction would run (default: the default modes) → {value, type, text}; it fails with what's wrong, like the interaction commands. Expressions can also be written as text: expression: "count + 1" on a set variable action, or expression: "count >= 3 and not done" on a conditional block, in place of expr / condition (variables by name, {name} for any name; numbers, "text", true / false, #RRGGBB; or / ||, and / &&, not / !, == (or =), !=, <, <=, >, >=, + (joins text), -, *, / and brackets; accent[Dark] for a variable's value in a mode, mode(Theme) == "Dark" for a collection's current mode, names with brackets or spaces at their ends in quotes). Text expressions are checked: a condition must give true or false and a set variable's expression a value the variable can hold, otherwise the command fails with what's wrong (Something is missing at the end, No variable \x`, Can't compare a number with text). Single actions, also inside conditionals, are edited with proto.action.add {id?, index, path?, action}(→{path}), proto.action.set {index, path, action}, proto.action.remove {index, path}andproto.action.move {index, path, to}, where indexis the interaction andpaththe action:[i]the interaction's actioni, [i, b, j]actionjof blockbof the conditional actioni, and so on (for add, where it goes: [i, b, n]appends to a block withnactions; default: after the last action). A conditional's blocks:proto.conditional.addBlock {index, path, else?, expression? | condition?}adds an else if (before the else; by default the first boolean variable equal to its value) or withelse: truethe else,proto.conditional.setCondition {index, path, block, expression | condition}andproto.conditional.removeBlock {index, path, block}(removing the if when only an else is left removes the whole conditional). Every one is an undo step and applies to allidsgiven (layers with the same interactions). Flowsproto.flow.add/rename/remove, and the device, its rotation and the background proto.settings {device?, background?, rotation?, color?, width?, height?, responsive?} (None, iPhone 16, iPhone 16 Pro Max, Android Compact, iPad mini, MacBook Air, DesktoporCustom size; rotation portraitorlandscape, which turns phones and tablets; colorone of the device's colours by name, e.g.Black, White, Pink, Teal, Bluefor the iPhone 16 (an error lists them),""for the default;width/heightset the custom size's screen, 1–10000 points, and pickCustom size, which is a bare screen without a body). On a device the presented frame is sized to the device's screen: what doesn't fit is clipped, or scrolls when the frame's overflowallows it, whilefixedlayers stay on screen (pinned to the edges their constraints name). Withresponsive: true(saved in the file) the presented frame is instead resized to the device's screen, or without a device to the presenter's window at 100%, and its constraints and auto layout respond as when it is resized in the editor (an axis it hugs keeps hugging its content); the file's frames don't change.proto.present {start?}plays the prototype full-window andproto.preview {start?}in a resizable pane over the canvas that follows edits live;proto.closePreviewcloses it.proto.presentOptions {scale?, deviceFrame?, hotspotHints?, inspect?} sets how the full-window presenter sizes the prototype (fitscales it down to fit,fitWidthfills the width,fillcovers the window,actualshows it at 100%; what overflows pans), whether it draws the device, whether a click where nothing reacts briefly flashes the screen's hotspots (the layers with interactions, on by default; also in the presenter's Options menu) and whether its Inspect panel shows the size, colours and typography of the layer under the pointer (also the Inspect button in its bar); the options are kept for later presentations (in the presenter Z cycles them and R restarts). The player's state is ininspect_ui (presenter, preview: screen, scroll, scale, zoom, pan, the screen's hotspotsand whether they are flashing, theinspectedlayer) when attached, andui.set {presentScroll: [x, y]}scrolls its screen (see [control-protocol.md](control-protocol.md)). The presenter's Options menu offers Responsive next to the size options (without a device) or Responsive / Fixed size (on one), the sameproto.settings {responsive}`; its flow picker lists each flow with its description.

Local libraries

A .figcraft file can be published as a library and used from other files (library files here; libraries kept on a figcraft-server for a team: Team libraries; the browser build has no file system, so there the commands that read library files return an error).

  • Publish (in the library file): library.publish {description?, include?, exclude?} publishes the file's components, component sets, styles and variable collections as a new version (by default everything except names starting with . or _, or what the last publish offered; include/exclude take component ids and style or collection ids or names). A collection comes with its variables (values per mode, aliases, scopes), except those hidden from publishing (variable.update {variable, hiddenFromPublishing: true}). Components, styles and variables that published assets use (a component's bound variables, a variable's alias targets) come along, unoffered. Each asset keeps the version it last changed in; publishing with no changes is refused. Save the file (file.save) so other files see the new version.
  • Enable: library.add {path} / library.remove {id} (id, name or path) / library.list {assets?} (versions, pending updates, errors; with assets: true each library's components, styles, variable collections (with their modes) and variables, and what this file can publish). Libraries are read read-only with size limits, and read again when the file changes.
  • Use: library.place {library, component, x?, y?} (a component, set or variant by id or name; page point, default the view's centre) copies the component, with the components and styles it uses, into the file as a read-only main component and places an instance; library.import {library, component} only copies it (then instance.create, instance.swap…); library.swapAt {library, component, x, y} copies it and swaps the instance at that page point to it (as instance.swapAt, below). library.applyStyle {library, style, ids?, target?} copies a style in (linked, read-only) and applies it. Instances of library components behave like any instance: overrides, properties, variants, swap, reset, detach. Library components keep their variable bindings and explicit modes (bound to copies of the library's variables).
  • Library variables: library.bindVariable {library, variable, field, ids?} copies a published variable in (linked, read-only, with the variables it aliases and its collection) and binds it like variable.bind; library.aliasVariable {library, alias, variable, mode?} makes a local variable's value an alias of a library variable; library.setVariableMode {library, collection, mode, ids?} sets a library collection's mode on frames (the page when no ids are given; the Design panel's Variable modes section lists enabled libraries' collections under the library's name and sets them this way); library.importVariable {library, variable} only copies it (then variable.bind, variable.set {value: {alias}}). The copies render without the library file. Editing the copies (library components, styles, variables or collections) is refused; edit the library file and publish.
  • Extended collections: library.extendCollection {library, collection, name?} → {id} makes a collection of this file (default name " (extended)") with the library collection's modes and one inheriting variable per variable of it. variable.set on an inheriting variable overrides its value in that mode (the variable lists it in overrides); variable.resetOverride {variable, mode?} goes back to the inherited value (every mode when mode is left out). Variables of its own come from variable.create. Modes, and the names of inheriting variables, come from the library collection (renaming, adding or deleting them is refused); accepting library updates brings new values (overrides stay), renames and new variables. A variable the library adds to the extended collection is an update of its own (library.updates lists it without an id, it has no copy yet). A library collection that is itself an extended collection can be extended again: the chain follows as each file accepts its library's updates. The Variables editor offers Extend collection on a library collection and marks overridden values, with a reset button.
  • Update: library.updates lists the components, styles, variable collections and variables with a newer published version; library.acceptUpdates {ids? (library ids)} pulls them in, keeping instance overrides; bound layers, aliases and explicit modes follow the updated values.
  • Paste between files: instances copied from another open file (edit.copy there, edit.paste here) follow their mains: library components join (or bring along) this file's copies of the same library, components the other file publishes as a library are used from it (and that library is enabled when the other file is saved), and other mains stay behind: the instance shows a missing main (component.restoreMain makes it a main here).

Team libraries

A file kept on a figcraft-server (server.md) can hold a library that other files use through one of its share links. Each call waits for the server (a few seconds at most when it can't be reached).

  • library.publishToServer {server?, key?, description?, include?, exclude?} publishes the active document's library as the next version of the one the server keeps under the file (selection and per-asset versions as library.publish, following the server's version): server is an edit link of the file, or omit it while the document is in a live session on a server with edit access; with the server's address and key (ws://host:47900/?key=…, or key) the document is kept there as a new file first and the result has its file, edit and view links. A view link is refused. The document itself doesn't change (nothing to undo).
  • library.add {path: <share link>} enables it (a view link is enough); then library.place, library.applyStyle, library.bindVariable… work as for library files. library.list and library.updates ask the server for a newer version first ("server": true in the listing); library.acceptUpdates takes it, overrides kept.
  • library.usage {library?} → {id, name, instances, files: [{name, updated, instances}], components: [{id, name, instances, files}]}: what the files using it reported (each fetch reports the asking document's name and its instances per component). library is an enabled team library; omit it for the active document's own library on its server.
  • library.swap {from, to} (any two enabled libraries, files or team) re-points every instance, style and variable copied from from to the published asset of the same name in to (variants by name in their set, styles by name and type, variables by collection, name and type, modes by name), keeping overrides; one undo step → {components, styles, variables, unmatched: [names still from from, including this file's collections that extend one of its collections], removed (nothing of from was left, so it was removed)}.

Overrides and variants

  • What an instance overrides (node.set / text.set on its layers; get_node lists overrides): fills, strokes, effects, opacity, visibility, layer names, corner radius, text content and text properties (font, size, spacing, resizing, lists…), auto layout padding, gap and alignment, layout grids, export settings and prototype interactions; nested instances swap (component) and take property values (props). Each stays when the main changes that property and goes with component.resetOverride {id, group} or component.resetInstance.
  • What it doesn't: moving, rotating, resizing or re-constraining a layer inside an instance, its resizing (hug / fill / min / max), the auto layout direction and other auto layout settings, and adding, removing or reordering layers (outside slots) are refused (nothing changes); resize the instance itself (its layers follow the main's constraints) or edit the main.
  • Variants: instance.setProperties {ids?, properties} switching variants keeps an override where the layer's name path matches and both variants had the same value for that property; text layers keep all theirs. The default variant (instance.create with a set, the Assets thumbnail) is the one in the set's top-left corner; component.editProperty {name, default} moves the chosen variant there, {name, index} reorders variant properties (the variants' names follow). component.properties on a set reports conflicts: variants sharing one combination of values.
  • component.pushChanges {id?} copies the instance's overrides into its main (from a layer of a nested instance too: always into the outer instance's main).

Swapping instances

  • instance.swap {ids?, component} shows another component in the instances (overrides on layers with matching names stay; property values with the same names too). Nested instances can be swapped on their own (an override of the instance they're in).
  • instance.swapAt {component, x, y} swaps the instance at a page point: of the instances under it (innermost first), the first showing a variant of the same component set, else the innermost (never one inside the dropped component); only text layers keep their overrides. The Assets panel does this when a component is dropped onto an instance with Alt (Option) held, highlighting that instance while dragging.
  • Instance swap properties offer preferred values: component.addProperty {type: "instanceSwap", default, preferred? [component or set ids]} and component.editProperty {name, preferred}; component.properties lists them, and the Design panel's swap menus show them first.

Slots

A slot is a frame inside a main component whose instances take any content of their own (add, remove and reorder layers there, unlike the rest of an instance). It is a component property of type slot (component.properties lists it: on a main with its layers, on an instance with value: {layer, edited, layers}).

  • Define (on the main): component.makeSlot {id (a frame inside a main component), name?, preferred? [component or set ids]} (the property is named after the frame by default); component.editProperty {name, preferred}; component.removeSlot {id} (instances lose their own content in it). component.addProperty {type: "slot", layer} and component.bindProperty {field: "slot"} work too (one frame per component for a slot).
  • Fill (on an instance): instance.slotInsert {id (the slot layer, or the instance with slot (property name)), component, index?, replace?} places an instance of a component in it; any structure command (shape.*, node.reparent, edit.paste with the slot selected, edit.delete, node.reorder, instance.place or a drag onto it) works inside it. The main's default content shows (and follows the main) until the slot is first edited; then the slot's layers are the instance's own (override group slot): kept across main updates, variant switches, copy/paste and detach, and instances placed there stand alone (their own overrides, properties and detach).
  • Reset: instance.resetSlot {id} (or component.resetOverride {id, group: "slot"}, or component.resetInstance) brings back the main's default content.

get_node shows "slot": "<property>" on slot layers.

Boolean groups

bool.union / bool.subtract / bool.intersect / bool.exclude {ids?} wrap layers in a new boolean group, except: with one boolean group selected they set its operation (every operand follows it again), and with layers inside boolean groups they set those operands' own operation. Each operand above the bottom one combines with the result of the operands below it by its own operation, or the group's (node.set {operandOperation: union|subtract|intersect|exclude | null} on any operand, or booleanOperation on an operand that isn't itself a boolean group; on a group booleanOperation is the group's). get_node shows operandOperation on operands that have one. Operands stay editable layers: select.children (Enter) or a double-click selects them, cmd: true clicks reach them directly, and moving, resizing or editing an operand's points (vector.edit.*) updates the group's result on every pointer move, one undo step per gesture; a dragged operand stays in its group. Clicks and select.layersAt hit a boolean group where its result is (a click in a subtracted hole reaches what's behind it); while the group or one of its operands is selected, its operands are hit instead, so each can be picked. bool.flatten bakes the result. Only the operands from the edited one up are recombined during a drag (figcraft_render::boolean; timed by cargo xtask bench).

Shape builder

The selected shapes' outlines split the canvas into regions (areas covered by the same shapes). shapeBuilder.regions {ids?} lists them: an SVG path in page coordinates, the sources covering each one (top-most first), the parts covering it ({id, stroke}: a shape's fill, or its stroke's outlined area; top-most first), bounds and a point inside. Visible strokes take part as their outlined areas, so stroked lines and open paths split the regions they cross and a stroke's band is a region of its own. shapeBuilder.merge {ids?, regions?, points?} makes one vector from the regions (by index, or the regions under page points), styled like the top-most fill or stroke over the first one (a stroke's region is filled with the stroke's paints); shapeBuilder.remove {ids?, regions?, points?} deletes them. Either way every shape covering a picked region loses that area (it becomes a vector, or goes when nothing is left); the strokes of the shapes involved become outlined vectors just above them (outlines in the result; an open path is replaced by its outline), and each call is one undo step. The tool is tool.shapeBuilder (Shift+M): click or drag across regions with pointer to merge, add alt to remove; shapeBuilder.mode {mode: merge|remove} swaps what a plain click does.

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"shapeBuilder.regions","params":{"ids":[2,3]}}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"shapeBuilder.merge","params":{"ids":[2,3],"points":[[120,80],[160,80]]}}}}

Text on a path and pattern fills

  • Text on a path: text.onPath {text?, path?, start?, flip?} (default: the selected text layer and the other selected layer) puts a text layer on a vector's or shape's path: the text takes the path layer's place, transform and box, and the path layer is removed (edit.undo brings it back). The baseline follows the path from start (0..1 of its length); the text's horizontal alignment places it there (left starts, centre centres, right ends at it); flip runs it on the other side the other way. Glyphs past an open path's end are hidden; a closed path (ellipse, rectangle) wraps them around; only the first subpath is followed. text.pathOptions {start?, flip?, ids?} changes them, text.removeFromPath {ids?} makes it plain auto-width text again. Resizing the layer scales the path. The text stays editable (text.edit.*, type_text); get_node shows textPath {d (SVG path data in the layer's space), start, flip}. Exports draw it as outlines (PDF too: not selectable); underline and strikethrough aren't drawn on a path.
  • Pattern fills: fill.pattern {source (layer id), ids?, index?, tile? (grid|horizontalHex| verticalHex), scale?, spacing? [x, y], align? (start|center|end), opacity?} makes the fill at index (default the top one) the source layer repeated, at scale × its own size with spacing between tiles; hex tiles shift every other row (column) by half a tile; align puts a tile in the box's top-left corner, centre or bottom-right corner. The fill is {type: "pattern", source, tile, scale, spacing, align} and can also be set with fill.set {paint}. It updates as the source changes; a layer can't tile itself or a layer containing it (a pattern nested more than two deep draws nothing). Copies made together with their source tile the copied source. Exports: SVG <pattern> and PDF tiling patterns of the source's vectors (a source with effects as an image of the tile); the canvas draws one tile at the zoom it shows (at most 2048 px a side) and repeats it.

Importing SVG

open_file {path} opens an SVG as a new document; run_command {command: "file.import", params: {path | data + name, x?, y?, center?}} adds it to the current page as a frame (top-left at x, y, or centred there with center: true, as pasting does). It comes in editable: groups named from their id, transforms baked into the layers, <rect>/<circle>/<ellipse>/<line> (and paths tracing those outlines) as rectangles with their corner radius, ellipses and lines, everything else as vectors; strokes with their width, dashes, caps, joins and miter limit; linear and radial gradients with their exact handles (gradientTransform, reflect/repeat unrolled, a focal point as the centre); patterns as image fills (fill, fit, crop or tile); clip paths as vector masks and masks as luminance or alpha masks; shadow and blur filters (feDropShadow, the offset / blur / flood / composite chains, colour-matrix shadows, inner shadows, feMorphology spread) as drop shadows, inner shadows and layer blurs; opacity and mix-blend-mode; text as text layers (fonts the SVG names when installed, anchors, letter spacing, styled <tspan> runs, stacked lines as one layer; text on one <textPath> as text on a path, with its start offset and anchor); images as image fills, and SVG images imported in place. CSS, <use>/<symbol>, units and viewBox/preserveAspectRatio are resolved. The result's warnings list what was left out (filters that aren't shadows or blurs, rotated repeating patterns, a clip path's own clip path). Linked files are never read, only data URIs; documents nested more than 512 elements deep are refused.

Importing .fig files

open_file {path} (a new, untitled document; the file is never saved over) and file.import (pages appended) read .fig files, best effort. Each file carries the schema its data is encoded with, so the data is decoded generically and mapped by field name onto the REST import below: the same layers, paints, text, auto layout, components, styles, variables and prototyping come across, with image fills from the file's own images/ folder. Instances are rebuilt from their main component with the overrides and property values the file records. Vectors come in as editable vector networks (their outlines when the network is missing or damaged); sticky notes, shapes with text, connectors, tables and similar layers come in as the closest frames, text layers and vectors; set-variable, set-variable-mode and conditional actions come across; video fills are skipped. The result's opened.warnings list what was approximated or skipped.

Exporting .fig files (experimental)

export {format: "fig", path?} (or run_command file.export {format: "fig"}, or any path ending in .fig) writes the whole document as a .fig file, from the same public format notes the import uses: the layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, and prototype interactions. warnings lists what was left out (shared style links, variables, comments, Dev Mode data and the like). Only round trips through FigCraft's own importer are verified; other apps may not open every file. Without path the file comes back as base64 data. Check one by opening it again:

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"export","arguments":{"format":"fig","path":"/tmp/handoff.fig"}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"open_file","arguments":{"path":"/tmp/handoff.fig"}}}

Importing REST API file JSON

The JSON returned by the public REST API's file endpoint (GET /v1/files/:key, best with geometry=paths so vectors carry their outlines) and its nodes endpoint opens like any document: open_file {path} (a new, untitled document; the JSON is never saved over) or run_command {command: "file.import", params: {path}} (pages appended to the current document, with their flows, prototype settings and variables). The result's warnings list what was skipped or approximated (unsupported layer, paint and effect types, video triggers, controller triggers on more than one button, expression functions other than a variable mode lookup over a variable and one of its modes, bindings to library variables, instances of components outside the file, which come in as frames). Pages, the layer tree, geometry, paints, strokes, effects, corners, constraints, auto layout, layout grids, exports, text with styled runs, vectors, components, component sets, instances and component properties are mapped, and so is prototyping: interactions (on click, hover, press, drag, after delay, mouse enter/leave/down/up and key triggers, controller triggers as controller buttons; navigate, overlays, back, scroll to, change to, open link, set variable (values, variable references and expressions), set variable mode and conditional (if / else if / else over expressions) actions; dissolve, smart animate, move, push and slide transitions with their easing, custom bezier and spring curves included), flow starting points, scroll overflow and fixed/sticky layers, and the prototype device. Image fills keep their imageRef and show a grey placeholder unless the image files sit in an images folder next to the JSON, named by imageRef (images/<imageRef>.png); nothing is fetched from the network.

Variables come from the local variables response (GET /v1/files/:key/variables/local): collections with their modes (the default mode first), values per mode, aliases and scopes. Pass it as variables (a path, or the JSON object or text) to open_file, file.open or file.import, put it next to the file as <name>.variables.json or variables.json, or embed its meta in the file JSON. Layers' boundVariables (sizes, gaps and padding, corner radii, opacity, stroke weight and per-side stroke weights, visibility, text, font properties, single solid fills and strokes) and explicitVariableModes then link to them, so switching a mode with variable.setMode restyles the layers (mode: null sets a frame back to Auto, inheriting its parent's mode).

The same JSON carries variables between FigCraft files: variable.export {collections?, format?, path?} writes this file's collections (default: all but library copies) as a local variables response (format: "json", the default) or as CSS custom properties (format: "css": the default mode on :root, each other mode on [data-<collection>="<mode>"], aliases as var(--name)), and returns {text} (or writes path and returns {path}). variable.import {variables} (an object, JSON text or a path) reads one back: a collection joins this file's collection of the same name (values matched by mode name, new modes and variables added, a same-named variable of another type skipped with a warning; library copies and extended collections are never targets), others are added; aliases are checked for loops; it returns {collections, variables, updated, warnings}. The Variables editor's Import… and Export… buttons do the same through file dialogs.

Strokes take colour styles and variables like fills: style.apply {style, target: "stroke"} links a colour style to the strokes (styles.stroke; edits of the style follow, style.detach {which: "stroke"} keeps the colour), and variable.bind binds strokeWeight and each side's weight on its own (strokeTopWeight, strokeRightWeight, strokeBottomWeight, strokeLeftWeight; binding or setting one side with node.set turns on individual strokes, the other sides at the weight) to number variables scoped for stroke weights.

variable.bind also binds gradient stops (fills/<i>/stops/<j>/color, strokes/<i>/stops/<j>/color; the binding follows its stop when an earlier stop is removed and its paint when the list is reordered) and variant properties of instances (componentProperties/<property> with a string variable: the instance shows the variant its value names; a value naming no variant changes nothing). The colour picker binds the selected stop; the Design panel's variant dropdowns take a string variable.

Comments

Comment threads live in the file, pinned to a page point or to a layer (anchor: a node id, or true for the innermost frame under the point) so they move with it; when the layer is deleted the pin stays where it was, on the nearest surviving parent. Driven with run_command: comment.add {x, y, text, page?, anchor?} (returns {id, link}), comment.reply {id, text}, comment.edit {id, text, reply?} and comment.delete {id, reply?} (a message is the thread's first one, or reply: an index into its replies; deleting the first message deletes the thread), comment.react {id, emoji, reply?, on?} (toggles your reaction), comment.resolve {id, resolved?}, comment.move {id, x, y, anchor?} (pins it to the frame under the new point unless anchor: false). Everyone acts as the name from Preferences (pref.userName {name}, default "You") unless author says otherwise; only a message's author can edit or delete it. @Name in a message mentions someone (comment.people lists the names used in the file).

Read state is kept per name: a new message is unread for everyone but its author; comment.markRead {id? | link?, read?} marks one thread (or, without id, all of them) read or unread (not an undo step). comment.list {page? | "all", filter? (all|open|resolved|mine|unread), sort? (date|page), query?, frame?} returns the threads with their position, frame/frameName, pageName, unread, link and replies (with edited times and reactions). Links are figcraft://comment/<id>: comment.link {id} returns one, comment.open {id | link, view?} goes to its page, zooms to its frame (fit, the default; center keeps the zoom; keep doesn't move) and opens it in the app, and comment.next {id?, dir?, …list params} steps to the next or previous thread of a list the same way. comment.zoom {ids} zooms in on a group of pins. Comment ids made in a live session are large numbers (their creator's id space): pass them back as given.

Version history

Named snapshots of the document, kept in the .figcraft file (they persist once the file is saved; a new, renamed or deleted version marks the file as changed). Driven with run_command: file.saveVersion {name?, description?} (returns {id, index, name, description, time}; untitled versions are named "Version N"), version.list (oldest first; time is Unix seconds), version.restore {id | index} (replaces the design with the version as one undo step; the file name and the comments stay, comments on layers the version lacks staying where they were, and the design it replaces is first kept as an autosave unless a version of it was just saved or restored), version.rename {id | index, name, description?} (a titled autosave becomes a named version), version.delete {id | index} and version.duplicate {id | index} (opens the version as a new document tab). version.preview {id | index} shows a version in place of the document, read-only: edits, undo and gestures are refused until version.restore keeps it or version.exitPreview goes back. Saving, closing or saving a version ends the preview first, and version.list marks the shown version previewing. Indexes are validated (a negative or out-of-range index is an error). A file keeps at most 100 versions. version.autosave saves an automatic checkpoint (auto: true, named "Autosave") when the document has unsaved changes since its last version; the app runs it every 30 minutes for each open document, and a file keeps the newest 20 autosaves (user versions are never dropped for them). Merging a branch adds the main file as it was (an autosave, when it changed since its last version) and then Merged “<branch>”. file.versionHistory returns the same list and opens the panel in the app. Versions stay with the person who saved them: in a live session they aren't shared.

Branches

A branch is a copy of the file to try changes in, reviewed against the main file and merged back. Branches are kept in the main .figcraft file (saving either tab saves the main file with its branches). Driven with run_command:

  • branch.create {name?} copies the file into a new branch tab and returns {id, name, index}. The branch remembers the file as it is now (its base) and makes new ids in its own id space, so layers, styles and comments made on both sides never clash.
  • branch.list lists the branches of the file shown (or of the file a branch tab belongs to): [{id, name, time, state: active|merged|archived, index?}] (index: its open tab).
  • branch.open {id} opens a branch in a tab (or switches to it). Edit it like any document.
  • branch.review {id?} (default: the branch tab shown) returns what changed on each side since the base and the conflicts: {branch: {layers, document, truncated}, main: {…}, conflicts: [{key, id?, name, property, branch, main, deletedBy?}]}. layers are the changed layers with their property changes formatted like Inspect (fill #2563EB → #DC2626); document lists styles, variables, comments and file settings. A conflict is the same property changed on both sides to different values (key <layer id>/<property>, e.g. 4/fills; fills, strokes and effects compare item by item, so changing different items of one list on each side, or adding items at its end on one side, merges without a conflict; a layer's parent and position are one property, parent), a layer deleted on one side while the other edited it or what's inside it (<layer id>/layer, with deletedBy), or a document-level entry or setting changed on both sides (style/<id>, variable/<id>, comment/<id>, doc/<property>…).
  • branch.merge {id?, resolutions?} merges the branch into the main file as one undo step, archives it (state: merged), closes its tab and shows the main file; the main file's version history gets a version for the merge (Merged “<name>”, after an autosave of main as it was when it had changed). resolutions maps every conflict key to "branch" or "main"; the merge is refused while a conflict has none, so neither side's work is dropped without a choice.
  • branch.updateFromMain {id?, resolutions?} brings the main file's changes since the base into the branch the same way (the new base is the main file as it is now). It is not undoable: the branch tab's undo history starts over.
  • branch.archive {id?} sets a branch aside without merging (it stays in the file and can still be opened, but not merged).

Each branch keeps its own version history: on a branch tab, file.saveVersion, version.autosave and the version.* commands (list, preview, restore, rename, delete, duplicate) act on the branch's versions, which are saved in the file with the branch and stay with it when it is merged or archived. Branches aren't available in a live session.

Collaboration changes

collab.diff {against? (undo: the last undoable action, the default | saved: since the last save)} returns {changes, count, bytes}: the change batch a live session would send for that edit (per-property create / set / delete and document-level changes, see docs/collab-protocol.md) and its binary size. It changes nothing. In a live session, where undo is per user (edit.undo / edit.redo take back only your own steps, see docs/collab-protocol.md), only saved applies.

Windows

The attached desktop app can show files in several windows, each with its own tabs; the files, preferences, libraries and clipboard are shared. run_command with window.new {path?, index?} opens one (a new file, the file at path, or open file index moved out of its window) and window.focus {id} brings one to the front; inspect_ui lists them as windows (id, main, title, files, active, focused). Tools act on the window in front (the last one opened, focused or clicked): focus a window before working on its files, and pass its id to screenshot {window} to see it. Headless (one session, no windows) and in the browser the two commands do nothing.

Live sessions

People (and agents) edit one file together and see each other's cursors, selections and viewports. Both the headless session and the attached app can host and join (the web build can join but not host); each call takes in what happened since the last one and sends your edits (a joined file opens as a new document).

Sessions are per document: every open document has its own session or none, and several run at once (host one document, join another, publish a third). They keep syncing while another document is active; there your presence is empty (no cursor, selection or view). The collab.* commands and collab.peers are about the active document's session (doc.switch {index} changes which one that is), and sessions in collab.peers lists them all. Closing a document ends its session only: a host stops hosting it, a guest leaves it.

  • collab.share {via?, access?, server?, name?, port?, bind?} shares the active document with a join code, the short way to do what collab.publish and collab.host do. via: "online" (the default) publishes it to the server in Preferences (pref.publishServer {url?}: the FigCraft server unless changed; {url: ""} turns online sharing off, {} goes back to the built-in one; server names another for this call, with ?key=… when it needs its key) and returns status: "connecting"; once collab.peers says "live", code is the code to give others (K7M-2QX-9TD) and share is {via, access, shared, editCode, viewCode, editLink, viewLink}: links and codes made for others (published.edit / published.view and link are your own way in; keep them). access: "view" (or calling again with it while shared) makes code the view-only one. A server older than join codes leaves code null: give share.editLink / share.viewLink instead. via: "local" hosts the document for people on the same network and returns at once with a longer code (20 symbols, carrying this machine's IPv4 address, the port and the session's secret; bind: "local" keeps it to this machine); everyone who joins edits. Calling it publishes the document to a server other people run: do it only when asked to share. collab.stop {} stops the sharing. Local: the session ends for everyone ({ended: true}). Online: {ended: false, shared: false}: the others' links and codes are revoked and they are disconnected at once (with their copy), while you stay in the file, which stays on the server, open only to you (share.shared is false, code null); collab.share {} then shares the same file again with new codes, collab.leave {} leaves it there, and collab.manageFile {file, action: "remove"} takes it off the server (the file is recorded with your online files: see collab.files below).
  • collab.host {port?, bind?} starts hosting the active document (a session that document was in ends; other documents' sessions go on): bind is "local" (this machine, the default), "lan" (the local network) or an interface address; the port is 47900, or any free one when it's taken, as it is for a second hosted document (port picks one, 0 any). Returns the session as collab.peers does, with link (figcraft://join?host=…&file=…&token=…) and url (ws://…/?file=…&token=…): both carry the session's secret, so share them only with people you invite. collab.stop {} ends the active document's session.
  • collab.join {code} or collab.join {link} (a join code of either kind: spaces, dashes and case don't matter, O reads as 0 and I or L as 1; or a link in any form: an app's invite, a figcraft-server share link, or the ws(s)://…?file=…&token=… URL; either parameter takes either) returns at once with the new session (session, status: "connecting") and touches no other document's session. Its file opens as a new document, which becomes the active one: poll collab.peers until status is "live". Until then the join shows on the document it was started from when that one isn't in a session itself, and in sessions either way, where it becomes "failed" with a reason when it can't be joined ("No session with that code", "That code has expired or its sharing was stopped", "Couldn't reach the server (…)", too many wrong codes from your address, a wrong token, a full session, a revoked link, an unreachable host, a wss:// server whose certificate isn't trusted: see FIGCRAFT_CA_FILE in server.md). collab.leave {} leaves the active document's session (or ends a join started from it that is still on its way or failed); the synced document stays open as your copy.
  • collab.publish {server, name?, key?} puts the active document on a figcraft-server (server.md) and syncs it from there (as that document's session; others go on): server is the address figcraft-server key prints (ws://host:47900/?key=…; or give key separately), name the file's name (default: the document's). It returns at once with status: "connecting"; the document is read-only until the upload is done, then collab.peers has published: {server, file, name, edit, view} with the file's first edit link and view link (they carry their tokens: share them like passwords). Someone joining with the view link gets access: "view": every edit on the synced document is refused with "View only: …" (the server refuses them too). On a server open to the public (server.md) server needs no key (wss://figcraft.example.com/): published then also has owner (the owner token, shown this once: keep it) and expires (when the server deletes the file unless someone opens it, seconds since 1970), and hosting says who runs the server. A server that isn't open answers that it takes files only with its key (reason in collab.peers).
  • collab.serverInfo {server} → {open, operator, terms, idleDays, maxFileMb, expires}: what a server says about its hosting, before publishing (open: no key needed; idleDays: how long files published without the key are kept unopened, null when they are kept). A server older than open hosting is reported as taking files only with its key.
  • collab.files {refresh?} → {files: [{server, host, file, name, created, opened, shared, code, viewCode, state, expires, session, document, hasOwnerToken}], problems: [{server, host, state, reason}], max}: your online files, the files this session (or, attached, the app with its saved preferences) put on a server without the server's key, most recently opened first. file is the id the other commands take; shared says whether others get in now, with code / viewCode to hand out; session and document name the session and open document a file is in, or are null. Without refresh nothing is asked of any server and state is what was last heard ("unknown" until asked). refresh: true asks each server once what became of its files: "online", or "gone" (removed, or expired unopened); a name changed elsewhere and links revoked elsewhere are taken over. A server that can't be reached, or is older than the request, is listed in problems (state: "offline" with the reason, or "unsupported") and its files stay as they were. Owner tokens and your own links are never in the result (hasOwnerToken: true stands for the token); they are kept with the preferences. The headless session keeps its list only while it runs: note published.owner from collab.peers if a file must be managed after it ends.
  • collab.openFile {file, server?} joins one of your online files again as its owner → {session, status: "connecting", document, alreadyOpen, file, server, name}; it opens as a new document (poll collab.peers until "live"), where collab.stop {} and collab.share {} stop and start its sharing. A file that is open already becomes the active document instead (alreadyOpen: true, document its index). server is needed only when files on several servers share an id. A file whose state is "gone" is refused.
  • collab.forgetFile {file, server?} → {forgotten, file, server, onServer} drops a file from the list on this computer only: the file stays on its server (onServer) until it expires, and can't be managed from here again. To take it off the server use collab.manageFile {file, action: "remove"} instead.
  • collab.recoverFile {path, file, server?, session, client, seq} is what the desktop app's crash recovery runs for the copy of an online file (the last three come from the note beside the copy): it joins the file as its owner and keeps the copy until the file arrives, then discards it (the server has everything), applies its unconfirmed changes on top as one synced edit, or opens it beside the file as " (recovered copy)"; a file that can't be joined opens the copy as an unsaved document. Returns {session, status: "connecting"}.
  • collab.manageFile {file, action, server?, owner?, …} manages a file published without the key, with its owner token. server and owner default to what is recorded for one of your online files (collab.files); give both for a file that isn't recorded here. links → {links: [{id, access, label, created}]}; createLink {access?: view|edit, label?} → {id, access, link} (a new share link, shown once); revoke {link} (whoever joined with it is disconnected); share → {shared: true, code, viewCode, editLink, viewLink} (new links and join codes for others; the same ones when it is shared already) and unshare → {shared: false, …} (the recorded links and codes are revoked and whoever joined with them is disconnected; the file stays yours on the server); rename {name} (its name on the server and in your list) and remove (the file is deleted from the server, whoever is in it is disconnected, it leaves your list and a session open on it here ends, its document staying open as a copy) → {done}. A wrong token, or another file's, is refused.
  • collab.report {reason} (at most 500 characters) reports the file you joined on a server to its operator → {sent}; the session's message says when the server kept it, or why not (ten an hour per address). Servers older than this version don't take reports (hosting is null).
  • collab.peers {} → the active document's session: {available, canHost (false in the web build), session (its id; null when the document isn't in a session: then statusis"off"and the rest is null or empty), status (off|connecting|live|reconnecting|failed), reason?, attempt?, role (host|guest), me, name, color, fileName, pages, link, url, address, network, access (edit|view), readOnly (the active document can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view, owner, expires}), code (the join code to give others when the document was shared withcollab.share, else null), share ({via: online|local, access, shared, editCode, viewCode, editLink, viewLink}, or null; shared: false: your file on a server, not shared now), hosting (what the server of the file you're in says: {open, operator, terms, idleDays, maxFileMb, expires}, or null), follow, signals (the host passes cursor chat and spotlights on), chat (your cursor chat), spotlight (who spotlights, or null), spotlighting (you do), syncing (the document arrived), document (the index of the synced open document), editing (the text layer you are typing in: {node, layer, caret, selection}, or null), peers: [{id, name, color, editing (the text layer they are typing in, same shape, or null), chat (their open cursor chat message, or null), presence: {cursor: [page, x, y], selection: [ids], viewport: [page, x, y, w, h], editing_text: {node, caret, selection?}}}], sessions: [{session, document (the index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others are in it)}]}. sessions is every session of the app, whichever document is active. Carets and selections count characters; someone's typing syncs when they finish, so their caret may be past the end of your copy until then. Starting text.edit.begin in a layer someone else is typing in sets the message " is editing this text too: the version finished last is kept" (both edits sync as whole values when each person finishes; the last one wins).
  • collab.cursor {x, y} puts your cursor at a page position (omit both to hide it): an agent's pointer. Your selection, page and view go out by themselves when they change.
  • collab.follow {client} follows that person's viewport; collab.follow {} stops (so does any pan or zoom of your own, or Escape).
  • collab.cursorChat {text} shows text next to your cursor for everyone in the session (put the cursor somewhere first with collab.cursor): at most 100 characters, control characters become spaces, "" closes it. Others see it fade 5 seconds after its last change, so send it again to keep it up. Without text it opens the chat box in the app (/ on the canvas), which a programmatic call never does: it returns {open}. Others' messages are each peer's chat in collab.peers.
  • collab.spotlight {on?} (no on: switch) asks everyone in the session to follow your view and page; people who join later follow too, and anyone who pans or zooms stops following (the spotlight goes on; they can follow again). Someone else's spotlight takes over yours. Returns {spotlighting}. Both commands need a live session whose host is this version or newer (signals in collab.peers); otherwise they say why.
  • Styles, variables, collections, modes and comments you create on the session's document take ids from your own id space (S:<n>, VariableID:<n>, … with large numbers), so two people creating them at once never overwrite each other's; outside a session ids look as before (S:1, VariableID:1…).
  • On the session's document every command (or batch, or gesture) is one step of your own undo history: edit.undo / edit.redo take back only your steps and leave what others changed since. An edit the host refuses (someone deleted the layer) is rolled back and the reason is the session's message. While the connection is reconnecting, editing goes on; the edits go out when it is back (the same client, catching up with what it missed).
  • collab.registerLinks {on?} → {on, registered} makes the desktop app the opener of figcraft:// invite links, or stops (no on: switch). Only where the app registers them itself (Windows, per user); elsewhere it reports the command unavailable. It also sets the Preferences checkbox. Starting the app with an invite link (figcraft "figcraft://join?…") joins that session.

Plugins

Sandboxed WebAssembly plugins act through the same commands (see plugins.md for the manifest, the ABI and the Rust SDK). Driven with run_command: plugin.import {path} (a figcraft-plugin.json or its folder) or plugin.import {manifest, wasm} (the manifest as an object or JSON text and the module as base64, for this session only), plugin.list, plugin.remove {id}, and plugin.run {id, command?, params?, fuel?}, which returns {plugin, command, output, logs, calls, fuel, panel}. A run is one undo step and is rolled back when it fails; a plugin only reads the document unless its manifest asks for write, may be limited to some commands, and can never touch files, other documents, the undo history, dialogs or other plugins. A headless MCP session starts with no plugins: import them first. plugins.runLast runs the last plugin command again.

A plugin that shows a panel (API 2) can be used headless too: plugin.ui {id?} returns plugin id's open panel, or without id the one opened last ({plugin, command, title, controls, values}, or null; several plugins can have panels open at once), plugin.event {id, control, value?} presses a button or sets a field as a click would (the plugin is called back; one undo step; returns like plugin.run, with the updated panel), and plugin.close {id} ends the session. Plugin settings are kept per plugin with the preferences (in a headless session, for the session).

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.run","params":{"id":"org.figcraft.examples.swatch-grid","command":"panel"}}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.event","params":{"id":"org.figcraft.examples.swatch-grid","control":"palette","value":"Forest"}}}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.event","params":{"id":"org.figcraft.examples.swatch-grid","control":"generate"}}}}

Widgets (API 3, layers on the canvas a plugin draws) are driven the same way: widget.insert {plugin, x?, y?} puts one on the page (centred in the view without a position) and returns {id, plugin, state, targets, output, logs, calls, fuel}; widget.click {id, target} clicks one of its targets as a click on the canvas would (the plugin redraws it; one undo step; returns the same); widget.list lists every widget in the file with its plugin, page, state and targets. A widget's state is layer data, so it is saved, undone and synced with the file.

{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.insert","params":{"plugin":"org.figcraft.examples.counter"}}}}
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.click","params":{"id":12,"target":"increment"}}}}
{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.list","params":{}}}}

Plugins from a figcraft-server's plugin directory (plugins.md): plugin.directory.list {server?, query?} lists the latest version of each plugin with the permissions it asks for (access, commands), its size and SHA-256, and installed / update; plugin.directory.install {id, version?, confirm: true, sha256?} downloads it, checks the SHA-256 and installs it for the session (an agent must pass confirm: true, after showing its user the permissions: without it nothing is downloaded); plugin.directory.publish {path | plugin, server} and plugin.directory.unpublish {id, version?, server} need the server's key in server (ws://host:port/?key=…).

{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.directory.list","params":{"server":"ws://127.0.0.1:47900/"}}}}
{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.directory.install","params":{"id":"org.figcraft.examples.swatch-grid","confirm":true}}}}

Dev Mode

  • Component code: inspect.code {id, language: react|swiftui|compose} on a component, a component set or a variant writes the component as a reusable unit: React (TSX) with a props interface, SwiftUI with a View struct, Compose with a @Composable function. Its component properties are the parameters, with their defaults: variant properties become string unions (React) or enums, booleans become flags around the layers they show, text properties strings, instance swaps views (React.ReactNode, AnyView, a composable lambda) and slots optional content. Every variant of a set shares one definition; in React the other variants' differences become CSS rules on data-* attributes. On an instance (or a layer tree containing instances) each instance is a call passing only the values it changes, e.g. <Button state="Hover" label="Buy" />, Button(state: .hover, label: "Buy"), Button(state = ButtonState.Hover, label = "Buy"). Names are escaped and unique (keywords get a suffix, default → defaultValue; names that would shadow framework types get one too, Text → TextComponent). inspect.componentCode {id?, language?} returns the parts: {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances)}.
  • Code connect: link a component (a variant links its set) to the code component it is built as, per language (react, swiftui, compose): codeConnect.set {id?, language?, importPath?, name?, template?} (undoable; fields left out keep their value; the defaults are the generated name and a call passing every property) → {component, language, code, unknown}. Templates are text, never executed: {property} (its name or parameter name) is the instance's value escaped for the language (React keeps JSX's braces, so size={size} gives size="Large" and disabled={disabled} gives disabled={true}), {property ? "a" : "b"} inserts one of two strings, any other {…} stays as written (unknown lists placeholder names that aren't properties). Then inspect.code on an instance or the component returns the import line and the snippet instead of generated code (connected: true), connected instances inside a generated layer tree are their snippet, and inspect.componentCode returns connected: {importPath, name, template, import, code} with the snippet as usage. Example: template <Button variant="primary" icon="{Has icon ? "cart" : "none"}">{label}</Button> with import path @acme/ui gives import { Button } from "@acme/ui"; then <Button variant="primary" icon="none">{"Buy now"}</Button>. codeConnect.remove {id?, language?} (no language: all). codeConnect.export {path?} → {text, links} (or writes the file): {version: 1, links: [{component, language, importPath, name, template}]}, sorted, meant to live in the code repository as figcraft.connect.json; codeConnect.import {json | path} → {linked, warnings} links each entry to every component or set with that name (one undo step; at most 5,000 links and 4 MB; templates up to 4,000 characters, names 100, import paths 300; invalid entries are skipped with a warning). get_node lists a component's links as codeLinks. In the app, the Inspect tab's Connect to code section edits them in the Code section's language, with a live preview and a Connected badge. An editor extension isn't needed: agents and editors read the same snippets through this server. Plugins whose manifest asks only for read also run from the Inspect tab.
  • Dev resources: links for developers (docs, code, tickets) on any layer, each a URL and a name. devResource.add {id?, url, name?} → {id, index} (no name: the URL without its scheme, www. and a trailing slash; no page title is looked up), devResource.edit {id?, index, url?, name?}, devResource.remove {id?, index?} (no index: all of the layer's own), all undoable, and devResource.list {id?} → [{name, url, index? (its own), from? (the layer it comes from)}]: an instance lists its own, then its main component's and that component's set's (read-only); a layer inside an instance shows its main layer's and takes none of its own. URLs must be http:// or https:// with a host, without spaces or control characters, at most 2,048 characters; names are clipped to 200 characters; a layer holds at most 32 links. Links are data: never fetched or opened by the engine (the app opens one when it is clicked). get_node and inspect_document list them as devResources; the Inspect tab lists, adds, edits and removes them.
  • Annotations: notes for developers on any layer, each with optional pinned properties that Dev Mode shows with their current values (and the style or variable behind them). annotation.add {id?, label?, properties?} (a note, pinned properties or both; returns {id, index}), annotation.edit {id?, index, label?, properties?} (properties replaces the list), annotation.remove {id?, index?} (no index: all of the layer's), all undoable, and annotation.list {ids? | id? | page? ("all")} → {annotations: [{id, name, index, label, properties: [{property, label, value, token?, color?}]}]} (value is null when the layer doesn't have that property). Pinnable ids: width, height, top, left, rotation, opacity, cornerRadius, direction, gap, padding, alignment, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, fill, stroke, strokeWeight, effects. A layer holds at most 64 annotations; notes are capped at 5,000 characters. They are saved with the file (get_node lists them as annotations); instances don't inherit their main's annotations and keep their own across main edits. In the app the Inspect tab draws them as callouts beside their layers and edits them in its Annotations section.
  • Annotation categories: every annotation can have a category, drawn as the colour of its callout: the defaults are Development, Interaction, Accessibility and Content, and a file can add its own. annotation.add / annotation.edit take category (an id or name; null clears it) and annotation.list returns category: {id, name, color} and filters with {category: id | name | "none"}. annotation.categories {} → {categories: [{id, name, color, count}]}; undoable annotation.addCategory {name, color?} → {id} (a new one takes the first unused colour), annotation.editCategory {id, name?, color?} (rename, recolour; ids stay) and annotation.removeCategory {id} (its annotations stay, uncategorised). Names are unique (ignoring case), up to 64 characters; at most 64 categories. Files keep the list only once it differs from the defaults (old files open with the defaults). In the app, with nothing selected, the Inspect tab filters the callouts by category and edits the categories (colour, name, delete, add); each note has a category menu.
  • Ready for dev: frames at the top of a page or section, and sections, carry a handoff status (node.set {devStatus: none|readyForDev|completed}). inspect.readyForDev {status?: readyForDev|completed|any, page?} lists them across pages (or on one page) in page and layer order: {items: [{id, name, page, status}]}. The Inspect tab sets the status of the selected frames and, with nothing selected, lists them with a status filter (click one to go to it, or its focus button to open it in the focus view).
  • Compare changes: when a layer marked Ready for dev first changes (it turns "Changed"), the file keeps a copy of it and everything in it as it was just before that edit (saved in the .figcraft file only while there is a change; resolving the change, marking it again, another status or deleting it drops the copy, so the next change compares with that state). dev.compare {id?, show?} → {id, name, changed, snapshot, added, removed, modified, truncated, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]}: layers matched by id, added and removed ones listed as a whole (layers counts what's inside), changed ones with their properties formatted like Inspect (width 120 → 136, fill #FF0000 → #E03030, text “Welcome” → “Hello”, parent Badge → Screen, order 3 → 1, component Card → Card 2, auto layout, typography, strokes, effects, corner radius, opacity, visibility, name), nested layers and instance contents included. snapshot: false means the frame changed before copies were kept (an older file). show: true opens the compare view in the app: before and now side by side (or overlaid with a slider), changed layers outlined (added green, changed orange, removed red), the list of changes and Resolve change.
  • Focus view: dev.focus {id? | direction: next|previous} → {id, name, page, index, count} shows one top-level frame or section alone on the canvas (fitted, selected, other layers neither drawn nor hit), switching page when needed; direction steps through the ready-for-dev frames across pages, wrapping around. dev.unfocus {} ends it, as do Escape (once the frame or nothing is selected), leaving the page or deleting the frame. The app shows a bar with the frame's name and status, Compare changes, previous / next and exit; inspect_ui reports devFocus.

Effects

component.playground {id?, properties? {name: value}, language?, path?, scale?} tries a component with other variant, boolean, text (and instance-swap) property values on a copy and returns its properties with their values, its code and, with path, a PNG of it. The document doesn't change.

style.bind {style, field, variable?} binds a variable inside a shared style (no variable: unbinds): fill or fills/<i>/color and fills/<i>/stops/<j>/color on a paint style, effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius> on an effect style, layoutGrids/<i>/<size|count|gutter|offset> on a grid style, fontSize, lineHeight, letterSpacing, fontFamily, fontStyle on a text style. Each layer using the style shows the field in its own modes and stays linked; style.list gives a style's bound map.

effect.add {ids?, type? | effect?} adds an effect (dropShadow, innerShadow, layerBlur, backgroundBlur, noise, texture, glass) with its defaults, or the full effect object; effect.set {ids?, index, effect} replaces one. A layer paints its drop shadows first, then its fills, inner shadows, children and strokes (strokes draw over inner shadows), with layer blur, noise and texture over the result. A group has no outline of its own: its inner shadows are cast from around what its layers draw together, over them, and its background blur and glass show through the outlines of its layers.

  • Blurs (layerBlur, backgroundBlur) are uniform ({"radius": 8}) or progressive: {"type": "layerBlur", "blurType": "progressive", "startRadius": 0, "radius": 24, "startOffset": [0.5, 0], "endOffset": [0.5, 1]} ramps the radius linearly from startRadius at startOffset to radius at endOffset, points in the layer's unit box (0–1 across and down, like a gradient's handles; before the start and past the end the radius stays). Radii above 10,000 draw as 10,000. In the app, the Design panel's blur row picks Uniform or Progressive, and while a progressive blur's settings are open its start and end points are handles on the canvas. Generated CSS leaves progressive blurs out (CSS blurs are uniform); Inspect lists the ramp.
  • Exports: PNG, PDF and SVG draw the same pixels as the canvas. PDF and SVG keep layers vector and embed images for what vectors can't show: background blur and glass (from what lies under the layer: on the page in PDF, among the exported layers in SVG), layer blur, noise and texture in PDF; noise grains (an image over the vector layer), texture and progressive layer blur (the layer whole) in SVG, where uniform layer blur and shadows stay SVG filters.
  • PDF for print: a bleed grows each page by that much on every side and shows what lies past the frame there (the frame's fills reach out to the bleed edge with gradients and images in place, its children are clipped to the bleed instead of the frame, nothing is drawn past it); a page of one frame is trimmed to the frame's box (its shadows go into the bleed). Crop marks (at the trim's corners) and bleed marks (at the bleed's) are 0.25 pt hairlines 18 pt long, starting 3 pt outside the bleed, in registration colour. MediaBox is the sheet, BleedBox the trim grown by the bleed, TrimBox the frame. CMYK converts every colour (fills, strokes, text, gradient stops, images and rasterised effects) from sRGB with K = 1 − max(R, G, B), C = (1 − R − K) / (1 − K) (M and Y alike), deterministic and profile-free: the file is DeviceCMYK without an output intent, because embedding one needs a CMYK ICC profile and none ships with FigCraft. Soft masks stay RGB (they aren't printed) and transparency groups blend in RGB, as the PDF writer declares them.

Brushes and pen pressure

  • stroke.brush {ids?, preset, spacing?, sizeJitter?, rotationJitter?, scatter?, seed?} draws a layer's stroke with a brush (preset: null back to a plain stroke; a layer without a stroke gets a black one); node.set {brush} takes the same (a preset id, an object or null) and get_node shows brush. Stretch brushes bend one shape along each subpath: taper, ink, ribbon, bristle. Scatter brushes drop copies along it every spacing stroke weights (default 1.5; at most 5,000 copies a path): dots, stars, confetti, leaves, varied by sizeJitter (0–1), rotationJitter (0–1, up to half a turn) and scatter (0–4 weights off the path), repeatably from seed. The stroke weight sets the brush's size and a width profile scales it along the path. Brushes draw on the canvas, in PNG, SVG and PDF (as filled outlines) and vector.outlineStroke flattens them; dashes don't apply to brushes.
  • Pen pressure: the Pencil records pressure into the stroke's width profile (up to 32 points by length; medium pressure, 0.5, draws the stroke weight, from a tenth of it to twice it); without pressure (a mouse) the stroke stays uniform. pointer takes pressure and, for drags, pressureTo. tool.pencilBrush {preset?, …} sets the brush new Pencil strokes get (null clears it; no preset reads it). The desktop app reads pressure from pen input on Windows; in the browser from stylus touch events that report a force; macOS and Linux tablets report none.

Paint, effect and layout grid lists

A layer's fills, strokes, effects and layout grids are lists kept bottom first: index 0 paints first, the last one is on top (the Design panel lists them the other way round, topmost first).

  • Reorder: fill.move, stroke.move, effect.move and grid.move {ids?, from, to} move one item (one undo step for every layer given; out-of-range or equal indices change nothing, negative or fractional ones are refused). The item keeps everything (an image fill its image, scale mode and adjustments, a gradient its stops and handles), and a paint, effect or grid style linked to the list is unlinked (the list no longer matches it). In the app, drag a row by its grip (the empty strip left of its fields); a line shows where it lands.
  • Variable bindings follow their items when a list is reordered or an item removed (fill.remove, stroke.remove, effect.remove, grid.remove); a removed item's bindings go with it. Binding fields (variable.bind {field}): fill / stroke (the first paint's colour) or fills/<i>/color / strokes/<i>/color, effects/<i>/<offsetX|offsetY|radius|startRadius|spread|color>, layoutGrids/<i>/<size|count|gutter|offset> (number variables; count is kept to 1–1000, size and gutter are never negative). Moving a bound paint rewrites its key (fill becomes fills/2/color).
  • Instance properties: variable.bind {ids: [instance], field: "componentProperties/<name>", variable} drives a boolean property from a boolean variable and a text property from a string variable (other types are refused); the value follows the variable's modes like any binding, the binding follows renames of the property, and deleting the property, resetting the instance or detaching it drops it. The instance's properties panel offers the same with its variable button.
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"fill.move","params":{"ids":[2],"from":0,"to":2}}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"variable.bind","params":{"ids":[5],"field":"layoutGrids/0/gutter","variable":"gutter"}}}}

Ruler guides

guide.add {vertical, offset, frame? | page?} (returns {index}), guide.move {index, offset, …}, guide.remove {index, …} and guide.clear {…} edit the guides of the current page, another page, or a top-level frame (frames, components and instances on the page or in a section; nested frames are refused). A frame's guides are in its own coordinates: they move with the frame, span only it, are copied with it and snap the layers inside it. A frame moved into another frame keeps its guides, but they stop showing, snapping and measuring until it's top-level again. On a rotated frame they run along its own axes, and the layers inside it snap to them (and to their siblings and the frame's edges) along those axes, keeping whole pixels in the frame's coordinates.

guide.select {index, frame? | page?} selects a guide (and deselects the layers; {} deselects it), as clicking one does in the app; guide.remove {} without an index removes the selected one (the context menu's Remove guide). While a guide is selected and no layer is, Delete removes it, the arrows across it nudge it (Shift: the big nudge) and Escape deselects it. In the app, a guide dragged out of a ruler and dropped on a frame becomes the guide of the top-level frame there, along the frame's axis nearer the ruler's; Alt-dragging a guide drops a copy, and holding Alt while dragging one shows its distances to the nearest edges inside the frame it crosses (the frame selected before the drag, else its own).

Layout grids: a columns or rows grid with a fixed width (left, centre, right) and a count of 0 is Auto, as many as fit (a stretched grid shows at least one); grids don't show or snap on a frame turned off 0°.

Measuring, the Scale tool, arc handles and selecting

  • Measure (what Alt/Option-hover shows): measure.distances {ids? (default the selection), to? (a layer; default the selection's parent)} returns {lines: [{x0, y0, x1, y1, label, dashed}]} in page coordinates, between bounding boxes (several layers by their union, rotated ones by their bounds): one box inside the other measures to each edge, boxes apart measure the gaps (dashed lines extend a gap line to a box it passes beside), overlapping boxes measure between matching edges. measure.padding {id? (an auto layout frame or a child)} returns {frame, lines}: the frame's padding and the gaps between neighbouring children (what Shift+Alt-hover shows). With pointer, alt: true hovers measure (add cmd for nested layers, shift for padding) and alt drags show live distances to the neighbours; inspect_ui reports them under tool.overlay (guides, and measure_box for the box measured to).
  • Scale tool (tool.scale, K): dragging a selection handle with pointer scales everything by one factor (sizes and positions whatever the constraints, strokes, effects, corner radii, auto layout spacing, min/max sizes, layout grids, guides, tiled images, text), one undo step per drag, about the anchor point tool.scaleAnchor {anchor?: tl|t|tr|l|c|r|bl|b|br} (or, when the anchor lies on the side being dragged, the opposite handle; alt: true scales from the centre), with the factor and pivot in tool.overlay.scale while dragging. Without dragging, transform.scale {ids?, factor, cx?, cy?} does the same numerically.
  • Arc handles: with the Move tool, a single selected ellipse (at least 40 px on screen, unlocked) shows its arc handles, listed by inspect_ui under tool.arcHandles ({id, sweep: [x, y], start, ratio} in page coordinates; start once there is an arc, ratio once there is an arc or a ring). Drag them with pointer. Sweep starts inside the right edge. Dragged round the ellipse, it opens an arc: positive up from the right edge, negative down. It closes again back at the start. Start turns the arc and keeps its sweep. Ratio (the centre, or the inner edge halfway round a ring) drags out to make a ring, and into the gap to show the other part. shift: true snaps angles to 15° steps and the ratio to 5% steps. Each drag is one undo step and writes node.set's arcStart / arcSweep (degrees) and arcInner (0–0.99). The Design panel's Arc fields (Start °, Sweep %, Ratio %) write the same keys; number variables bound to arcSweep and arcInner (variable.bind) hold percentages, as the fields show them, and ones bound to arcStart degrees. tool.overlay.arc holds the readout while dragging ({handle, label: "Sweep 75%", at}), and tool.overlay.arc_hover holds the handle under the pointer.
  • Corner radius and star ratio handles: a selected rectangle or frame (at least 48 px on screen) has a handle inside each corner, a polygon or star one inside its top point, and a star a ratio handle on its first inner point; inspect_ui lists them under tool.shapeHandles ({handle: {kind: radius, corner} | {kind: ratio}, at}). Dragging a radius handle along its corner's bisector sets cornerRadius (stopping where the corners meet); alt: true sets only that corner of a rectangle or frame (topLeftRadius…). Dragging the ratio handle sets innerRadius. One undo step per drag; tool.overlay.shape holds the readout ({handle, value, at}). node.set {pointCount} keeps stars to 3–60 points; miterAngle (degrees, the Design panel's Miter angle) sets the miter limit (limit = 1 / sin(angle / 2)); get_node reports a join other than miter (strokeJoin) or a miter angle other than the default ~28.96° (miterAngle).
  • Select: select.layersAt {x, y} lists every layer under a page point, front-most first with its depth (the context menu's Select layer); select.set, select.children / select.parent (Enter / Shift+Enter), select.next / select.previous (Tab / Shift+Tab). With pointer, cmd: true clicks select the nested layer, cmd drags from empty canvas select the nested layers they touch, and shift drags toggle what they touch. A press on the background of a top-level frame with layers in it (the frame not selected) starts a marquee inside it, as on the canvas; an empty frame is picked and dragged. Locked layers (selectable with select.set) get no handles and stay put when the rest of the selection is dragged. select.all / select.inverse skip hidden and locked layers.
  • Transform gestures: with pointer, shift keeps proportions, alt resizes from the centre (both together too), dragging past the opposite edge flips (several layers mirror and flip); groups and several layers scale each layer along its own axes, keeping its rotation and flip; cmd while resizing a frame ignores its children's constraints (they stay where they are) and, as in every drag, turns snapping off. Moves snap to edges, centres and equal spacing (the gaps show in tool.overlay.guides with labels); with the pixel grid on, moves and resizes land on whole pixels.

Tool failures come back as results with isError: true and a message; protocol problems (unknown tool, malformed JSON, wrong parameter types) are JSON-RPC errors. Nothing crashes the server.

Resources

URI Content
figcraft://document The active document (like inspect_document at depth 8).
figcraft://commands The command catalog.
figcraft://node/{id} (template) One layer with children to depth 2.
figcraft://command/{id} (template) One command's docs, e.g. figcraft://command/layout.add.

Prompts

Prompt Arguments
mobile_screen screen (required), device (default "iPhone 16"), brand_color
design_system name, primary
landing_page product (required), width (default 1440), accent
prototype_flow screens (comma-separated), transition

Each returns a step-by-step plan naming the exact tools and commands to use and how to verify.

Examples

Create a card with auto layout and check it:

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_node","arguments":{
  "type":"frame","name":"Card","x":100,"y":100,"width":320,
  "props":{"fill":"#FFFFFF","cornerRadius":16,"layout":"vertical","padding":24,"gap":12,"sizingV":"hug",
           "effects":[{"type":"dropShadow","color":"#00000026","offset":[0,4],"radius":16}]}}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"create_node","arguments":{
  "type":"text","parent":2,"text":"Ada Lovelace","props":{"fontSize":24,"fontStyle":"Bold"}}}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_node","arguments":{"id":2,"depth":2}}}
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"screenshot","arguments":{"ids":[2]}}}

A component with an instance whose text is overridden (ids are the ones earlier calls returned; read the instance's text layer id with get_node):

{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"component.create","params":{"ids":[2]}}}}
{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"instance.create","params":{"component":2,"x":500,"y":100}}}}
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"type_text","arguments":{"id":7,"text":"Grace Hopper"}}}

Align and distribute as one undo step:

{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"batch","arguments":{"label":"Tidy","commands":[
  {"command":"align.top","params":{"ids":[3,4,5]}},
  {"command":"distribute.horizontal","params":{"ids":[3,4,5]}}]}}}

The end-to-end tests in crates/mcp/tests/agent_tasks.rs are worked examples of ten realistic tasks (auto layout, components and overrides, gradients and shadows, paint styles, Light/Dark variables, align/distribute/group, boolean subtract + flatten, export, undo/redo, drawing with tools).