Agent API
window.figpea is Figpea's curated, versioned, JSON-serializable command surface for agents and other external tools. It wraps the editor's design primitives (layer creation, mutation, history, export) behind a small, stable API, and it is available only when the ?agent=1 query parameter is present on the editor URL -- open https://editor.figpea.com/?agent=1 (or your local dev build with the same flag) and the API attaches shortly after the app's initial render.
Every method returns a structured result -- { ok: true, value } on success, or { ok: false, code, message } on failure -- and never throws across the boundary, so always check ok before touching value. Layers are addressed by string id; there are no live proxy references, so every returned value is plain, JSON-serializable data you can log, diff, or hand back to a model.
Documents window.figpea contract version 2.38.0. Looking for the MCP setup guide or client configuration? Visit AI Agents.
Tier-1 recipe
The end-to-end loop for driving Figpea programmatically: open the editor with ?agent=1, call figpea.describe() to discover the live surface, author and refine layers, take a screenshot with your browser-automation MCP (Playwright MCP, Chrome DevTools MCP, or similar) and read figpea.session.layerTree() back to see what actually changed, then iterate.
The block below is the canonical, executable Tier-1 recipe. Wait for window.figpea to exist, then run await figpeaTier1Recipe(window.figpea):
// Figpea agent Tier-1 recipe — runs in a live editor tab opened with ?agent=1.
// Each call returns { ok, value } | { ok:false, code, message }; we throw on
// failure so any drift between these docs and the live API surfaces loudly.
async function figpeaTier1Recipe(figpea) {
const must = (r, label) => {
if (!r || r.ok !== true) {
throw new Error(`${label} failed: ${r && r.code ? r.code : "no result"}`);
}
return r.value;
};
// 1. Discover the surface — an agent's first call.
const manifest = figpea.describe();
if (!manifest || !manifest.session || !manifest.layer) {
throw new Error("describe() did not return the expected surface groups");
}
// 2. Author: a fresh project with a page, a rounded rectangle, and a caption.
await figpea.session.newProject();
const page = must(await figpea.layer.create("page", {
name: "Recipe Page", pageWidth: 400, pageHeight: 300,
}), "create(page)");
const rect = must(await figpea.layer.create("rect", {
parentId: page.id, rwidth: 160, rheight: 90,
style: { fill: "#4f46e5", cornerRadius: 8 },
}), "create(rect)");
must(await figpea.layer.setPosition(rect.id, { x: 40, y: 40 }), "setPosition(rect)");
const label = must(await figpea.layer.create("text", {
parentId: page.id, transform: [1, 0, 0, 1, 40, 160], text: "Made by an agent",
}), "create(text)");
// 3. Refine.
must(await figpea.layer.move(rect.id, 20, 0), "move(rect)");
must(await figpea.layer.stylePatch(label.id, { fontSize: 24, fill: "#111827" }), "stylePatch(label)");
// 4. Read the tree back — this is what you'd screenshot with your MCP, then iterate on.
const tree = must(await figpea.session.layerTree(), "layerTree()");
return { pageId: page.id, rectId: rect.id, labelId: label.id, tree };
}
The recipe returns { pageId, rectId, labelId, tree }. tree is the same shape as figpea.session.layerTree()'s result -- walk it to confirm the three created layers are present, then continue the screenshot-and-mutate loop from there. For an isolated visual check without leaving your browser-automation MCP's own screenshot step, figpea.canvas.screenshot() is the in-contract feedback-loop alternative documented under figpea.canvas below.
API reference
The full surface: figpea.version and figpea.describe(), then the eight groups -- figpea.session, figpea.layer, figpea.history, figpea.export, figpea.canvas, figpea.report, figpea.interaction, and figpea.component.
figpea.version
Type: string
Current API contract version (semver), independent of the product version. Value: 2.38.0.
Breaking changes by contract version
- 0.16.0 (AGENT_API_CONTRACT_VERSION):
figpea.describe()delivery shape change — bare call returns compact index; dotted selector drills into full schemas. - 0.31.0 (AGENT_API_CONTRACT_VERSION):
setPositionandrotatemechanics realigned from raw matrix translate to visibleboundstop-left and center pivot. - 1.0.0 (AGENT_API_CONTRACT_VERSION): MAJOR:
layer.createshape-origin parameters (x,yfor rects;cx,cyfor ellipses) removed from creation schemas. Callers must pass position viasetPosition(id, {x, y})ortransform. - 1.2.0 (AGENT_API_CONTRACT_VERSION): Declared mask model verbs (
setMask,clearMask) and property read-backs (mask,maskedBy). - 1.3.0 (AGENT_API_CONTRACT_VERSION): Parameter validation for
openFile(non-hanging fast failure), error-code catalog alignment, and asset harvest discovery sync. - 1.5.0 (AGENT_API_CONTRACT_VERSION): Added PSD layer effect support for gradient overlay, pattern overlay, outer glow, and inner glow via style patch keys.
- 1.6.0 (AGENT_API_CONTRACT_VERSION): Widened the
blendModeenum from 16 to 27 members (PSD blend-mode-engine compositor operators) — see theBlend mode:bullet understylePatchbelow for the full accepted list. - 1.7.0 (AGENT_API_CONTRACT_VERSION): Completed
.figdiagnostics coverage across decoders and retired the partial-coverage caveat. - 1.8.0 (AGENT_API_CONTRACT_VERSION): Multi-stroke layer and gradient stroke agent API support — see the
strokesbullet understylePatchbelow. - 1.9.0 (AGENT_API_CONTRACT_VERSION): On-demand Google Fonts auto-resolution when
fontFamilyis set viacreate/stylePatch,session.waitForIdle()awaits in-flight font fetches, and truthfulsubstitutedfield inreport.summary()/report.fonts()(REQ-716). - 1.10.0 (AGENT_API_CONTRACT_VERSION): Additive — adds
figpea.SKILL(selector?), the runtime craft-guidance reference surface, plus one additiveskillkey ondescribe()'s bare compact index advertising it. No existing member changes (REQ-705). - 1.11.0 (AGENT_API_CONTRACT_VERSION):
layer.create()now validates geometry prop values, not just which keys apply to a kind — a presentrwidth/rheight/rx/ry/x2/y2/pageWidth/pageHeightthat isn't a finite number, orpointsnot shaped as a non-empty array of{x,y}objects with finitex/y, is rejected with the newinvalid_geometryerror code, naming the offending prop and the expected shape — nothing is created. A successfulcreate()(ok: true) now guarantees finitebounds. - 1.12.0 (AGENT_API_CONTRACT_VERSION): Additive —
figpea.layer.create()defaults shape layers ('rect', 'ellipse', 'polygon', 'path') to a visible neutral grey fill (#d9d9d9), matching the UI shape tools and resolving the invisible-on-white-artboard default-fill defect. Re-pointed from this branch's original1.11.0claim at post-PASS rebase — REQ-718 (above) landed its own site sync tomainfirst from the same1.10.0baseline, so1.12.0is the next-free additive minor (REQ-715). - 1.13.0 (AGENT_API_CONTRACT_VERSION): Two halves of one fix. (1)
session.layerTree()/layerById()never throw across the boundary anymore — a layer whose bounds cannot be computed reportsbounds: nullplus aboundsErrordiagnostic instead, and every other layer in the project keeps serializing normally (previously, one such layer permanently made both methods throw for the whole project). (2)layer.create('path', ...)now validates that a presentpathvalue actually parses to usable SVG geometry — a malformeddstring is rejected with the existinginvalid_geometryerror code (no new code), naming"path", before anything is created; an empty string and any validdkeep working exactly as before. Re-pointed from this branch's original1.12.0claim at post-PASS rebase — REQ-715 (above) landed its own site sync tomainfirst from the same1.11.0baseline, so1.13.0is the next-free additive minor (REQ-719). - 1.14.0 (AGENT_API_CONTRACT_VERSION): Additive — three new
figpea.layer.*methods for the Paint tool's region-fill model:getRegionFills(id),setRegionFill(id, regionKey, fill),clearRegionFill(id, regionKey)— see the dedicated section below. Plus two new error codes,not_a_path_layerandinvalid_region_key(REQ-649). - 1.15.0 (AGENT_API_CONTRACT_VERSION): Additive —
session.renameProject({ name })added, so the active project can be renamed through the agent API (REQ-721). - 1.16.0 (AGENT_API_CONTRACT_VERSION): Additive — one new
figpea.layer.setAutoLayout(id, patch)mutator - 2.25.0 (AGENT_API_CONTRACT_VERSION): Additive — 8 bumps since 2.17.0: nested numerics (
pageWidth/rwidth/pos/matrix/batchargs) nownumber/arraywith coercion, batch contract{method, args}documented +invalid_paramsper-op, imagekind=image url:dataURIcreates,openFile({url,type})honourstype, vector surface expansion, plus Figma-parity selection (selectRelative,findMatching,same*Askeys, auto-layout). All additive,envVersionstays 2. (REQ-749,750,752,753,754,758,770,773,803,812,869-873,1011,1021). for group stack layout. Takes a partial object patching the group'sautoLayoutfield:mode: "none" | "horizontal" | "vertical"(clear a stack withmode: "none"),itemSpacing?: number,padding?: { top?, right?, bottom?, left? },primaryAxisAlign?: "min" | "center" | "max" | "space_between" | "space_evenly",counterAxisAlign?: "min" | "center" | "max",primarySizing?: "fixed" | "resize_to_fit",counterSizing?: "fixed" | "resize_to_fit". Validates before mutating — unknown alignment, negativeitemSpacing, or a non-group (LayerType.folder) target returns the standarderr()shape with an actionable message and leaves the layer unmodified. No mutator is exposed for deferred fields (wrap,childGrow,positioning,counterAxisSpacing, grid) that REQ-743's engine does not honour. (REQ-745; seesetAutoLayoutbelow.) - 1.17.0 (AGENT_API_CONTRACT_VERSION): Additive — one new
figpea.session.selectRelative(relative)— moves the selection relative to the current selection, mirroring the keyboard Enter / Shift+Enter / Tab / Shift+Tab paths, including Q2 composite (common-parent else no-op; Enter/Tab from first-member collapse), Q3 top-of-tree no-op (never selects Page), and Q4 same-parent panel-order wrapping, with the same lastChildMap drill memory and panel-sync via chooseLayer (REQ-750). - 1.18.0 (AGENT_API_CONTRACT_VERSION): Additive —
figpea.component.placeInstance(componentId, position?)now accepts an optionalposition?: { x: number, y: number }(finite numbers, canvas/world coordinates at the drop point). When supplied, the instance is parented per the active-page convention and positioned so the drop point lands at the instance's centre, selected on creation, as one undo step, without moving the camera. When omitted, behavior is byte-identical to the pre-REQ placement (same target page — the master's own page viaresolveInstancePlacementParent— same cascade offset, same selection, same no-camera-move) (REQ-741). - 1.19.0 (AGENT_API_CONTRACT_VERSION): Additive — one new
figpea.session.layersAtPoint(x, y)— returns every visible layer whose precise geometry contains the given world-space point, ordered front-most first in Layers-panel display order; the programmatic oracle for the canvas right-click "Select layer" submenu (both surfaces read the same enumeration, so API output and menu stack are identical by construction). Locked layers are included, hidden subtrees are excluded, and an empty array is returned when nothing contains the point. Re-pointed from this branch's original1.18.0claim at post-PASS rebase — REQ-741 (above) landed its own site sync tomainfirst from the same1.17.0baseline, so1.19.0is the next-free additive minor (REQ-751). - 1.20.0 (AGENT_API_CONTRACT_VERSION): Entry-time argument validation across every registered method. A supplied argument whose top-level type diverges from the method's declared schema now returns a structured
{ ok: false, code: "invalid_params" }result naming the method, parameter, expected type, and received type — e.g.setSelection(): ids must be array (got string)— before any implementation code runs. Previously a wrong-typed argument threw a raw TypeError inside the implementation body, which the bridge degraded to an undebuggablebridge_client_error("e.map is not a function"). The never-throws contract above is unchanged in wording and now enforced everywhere it applies: methods whose descriptors declare no parameter types behave exactly as before (pass-through),undefinedarguments stay "not supplied", valid calls are unaffected, and nested content validation remains each method's own concern (REQ-770). - 2.1.0 (AGENT_API_CONTRACT_VERSION): Additive — one new
figpea.session.findMatching(layerId)— returns every layer on the seed's page matching the Figma-parity "Select matching layers" rule: two instances of one component master always match; otherwise same layer type + exact name + size within ±1 project unit + offset-from-top-level-ancestor within ±1 + identical relative path of sibling indices from the top-level ancestor. Locked and hidden layers never match, the search never crosses a page boundary, and the seed itself is always included. Reads the same core collector the UI command (Edit menu / Opt+Cmd+A) selects through, so the returned ids are exactly the UI selection, by construction. Unknown id →not_found; an ineligible seed (top-level layer with no containing frame/group) → empty array. (Provenance: originally pinned1.22.0; re-pinned2.1.0at the pre-merge rebase because v3 mainline consumed2.0.0BREAKING for REQ-754 while this branch was in flight.) - 1.20.0 (AGENT_API_CONTRACT_VERSION): Entry-time argument validation across every registered method. A supplied argument whose top-level type diverges from the method's declared schema now returns a structured
{ ok: false, code: "invalid_params" }result naming the method, parameter, expected type, and received type — e.g.setSelection(): ids must be array (got string)— before any implementation code runs. Previously a wrong-typed argument threw a raw TypeError inside the implementation body, which the bridge degraded to an undebuggablebridge_client_error("e.map is not a function"). The never-throws contract above is unchanged in wording and now enforced everywhere it applies: methods whose descriptors declare no parameter types behave exactly as before (pass-through),undefinedarguments stay "not supplied", and nested content validation remains each method's own concern (REQ-770). - 2.2.0 (AGENT_API_CONTRACT_VERSION): Additive —
figpea.session.find's selector gains seven reference-id keys —samePropertiesAs,sameFillAs,sameStrokeAs,sameEffectAs,sameTextPropertiesAs,sameFontAs,sameComponentMasterAs— each naming a layer whose property becomes the reference value, matching every layer that shares it under Figma's "Select all with same…" semantics with the exact comparators, whole-document scope, and locked/hidden exclusions of the Edit ▸ Select All with Same commands. Keys AND-combine; a non-string value or an unresolvable id returnsinvalid_selector; unknown selector keys stay rejected; no new error code. (Provenance: originally pinned1.21.0; re-pinned2.2.0at the merge-time rebase because v3 mainline consumed1.21.0-2.1.0(REQ-771/754/757) while this branch was in flight.) (REQ-758 — see thefindsection above for the full semantics). - 2.14.0 (AGENT_API_CONTRACT_VERSION): Re-seed
public/figpea-agent/SKILL.mdbyte-identical tov3/docs/figpea-agent/SKILL.mdand syncpublic/llms.txtcontract stamp (site drift re-pin, REQ-1002 T3). - 2.17.0 (AGENT_API_CONTRACT_VERSION): Additive —
layer.create("image")gains deferred placeholder support ({deferred: true}creates a solid placeholder thatsetImageFillcan target). (REQ-865). - 2.27.0 (AGENT_API_CONTRACT_VERSION): Additive — new
figpea.layer.setLocalPosition(id, {x, y})parent-local counterpart tosetPositionfor page-local intent on auto-placed pages;layer.createandlayer.setPositiondocs now warn about auto-placement trap and cross-referencesetLocalPosition. (REQ-1042). - 2.28.0 (AGENT_API_CONTRACT_VERSION): Patch — restores rect
x/yand ellipsecx/cyas construction-only defaults folded intotransformto unblock callers pending coordinatedsetPositionmigration; no new method, surface hash22fa29c780d3d06cc3a5ba15ac182d653e553687ae27944977a60e9714444407. (Janitor 2026-09-01). - 2.29.0 (AGENT_API_CONTRACT_VERSION): Additive —
layer.stylePatch/layerTree()/layerById()style surface gainsfillOpacity(Photoshop fill opacity — scales only the layer's own fill/pixels, never its effects; alongsideopacity) — see theTransparency:bullet understylePatchbelow. (v3 REQ-529; Janitor 2026-09-04 successor-sync). - 2.30.0 (AGENT_API_CONTRACT_VERSION): Changed —
layer.setPositionandlayer.rotateoperate on a layer's visible box (itsbounds) rather than its raw transform translate. Coordinate space:setPositionis world coordinates (not parent-local) — contrastcreate'stransformwhich is parent-local;setPositionalso acceptsspace:"local"for parent-local placement (same assetLocalPosition);createnow echoes{id, name}. (v3 REQ-857; Janitor 2026-09-05 successor-sync). - 2.31.0 (AGENT_API_CONTRACT_VERSION): Patch —
report.summary()/fonts()and the Assets panel Text Styles section resolvemissingLocallythrough one shared predicate, so user-uploaded, built-in/bundled, and Google-manifest-matched families all read as available and both surfaces always agree;summary/fontsdocs now state the built-in meaning. No new method, no new error code. (v3 REQ-514; Janitor 2026-09-06 successor-sync). - 2.32.0 (AGENT_API_CONTRACT_VERSION): Additive —
layer.create("arc")gains ring mode: a presentsweepselects{rx, ry, startAngle, sweep}intent authoring (fractions of a turn, 0 = 12 o'clock clockwise, stroked centerline, sweep 1 = closed ring); absent keeps the legacy degree donut unchanged;endAngle+sweepis rejected. (v3 REQ-816; Janitor 2026-09-06 successor-sync). - 2.33.0 (AGENT_API_CONTRACT_VERSION): Additive —
layer.create("icon")promoted to the curated 30-glyph set withsizescaling to rendered pixels; unknown names rejected withinvalid_params. (v3 REQ-817; Janitor 2026-09-07 successor-sync). - 2.34.0 (AGENT_API_CONTRACT_VERSION): Doc-only —
session.waitForIdletimeoutdocumented explicitly in milliseconds (default 10000 ms); wire semantics unchanged. (v3 REQ-822; Janitor 2026-09-07 successor-sync). - 2.35.0 (AGENT_API_CONTRACT_VERSION): Additive — first-class page background fill:
stylePatchon a page id routesfill/fillTypethrough page-fill validation,setPageFillrejects unparseable colors withinvalid_style_value. (v3 REQ-827; Janitor 2026-09-07 successor-sync). - 2.36.0 (AGENT_API_CONTRACT_VERSION): Additive — error + doc teach prop placement:
layer.create'sinvalid_transformrejection appendsDid you mean style.<key>?for offenders that are known style keys, andlayer.stylePatch'sunsupported_style_keyrejection appends acreate()top-level pointer for known create-level props;layer.createdoc states the placement rule with a workedstyle.fontFamilytext example. No new method, no new error code. (v3 REQ-807; Janitor 2026-09-08 successor-sync). - 2.37.0 (AGENT_API_CONTRACT_VERSION): Additive —
canvas.setViewportgains the{x, y, width, height}frame-a-rect form (exact contain, no padding) alongside{x, y, zoom}(unchanged); pre-write validation returnsinvalid_paramswith the camera untouched instead of NaN-poisoning it, andgetViewportreports degenerate camera state asinvalid_geometry. No new method, no new error code. (v3 REQ-830; Janitor 2026-09-08 successor-sync). - 2.38.0 (AGENT_API_CONTRACT_VERSION): Additive — text
tabStopsaligned-stop style key ({position, align: left|center|right}[]) onstylePatch/create/serialize, rendered by shared tab-stop layout across measure/canvas-draw/SVG-export; malformed stops rejected withinvalid_params. Style surface grows to the full 44-key round-trip. (v3 REQ-832; Janitor 2026-09-08 successor-sync).
figpea.describe()
Signature: describe(): DescribeIndex and describe(selector: string): GroupDescriptor | MethodDescriptor | Record<string, string> | undefined
Works like a CLI's --help — call it bare to see what exists (a compact index of every group, each method's name and one-line doc), then drill with a selector to see how one thing works (its full parameter schema).
Bare describe(): returns a compact index — every group name, each method's name and one-line doc, plus version and the enumerable errorCodes keys; it deliberately omits params/byKind/result schema trees so the mandated first call stays small as the surface grows.
Drill shapes: describe("layer.create") returns one method's full { doc, params, result }; describe("layer") returns every method in a group; describe("errorCodes") returns the complete catalog. Unknown/malformed/non-string selector returns undefined — never throws.
The honesty beat: each drilled schema is byte-for-byte identical to the machine-readable schema it always was — a valid call is still constructible from describe() alone, no external docs; you just fetch the detail when you need it. A consumer that wants the whole surface (e.g. an MCP bridge building tool definitions) simply drills every group at startup, paying the full cost once, intentionally.
Prefer reading describe() over this page when you need the authoritative, up-to-the-minute shape.
// Discover what methods exist
const index = figpea.describe();
console.log(index.layer.create); // "Creates a new layer"
// Drill the full schema for one method
const method = figpea.describe("layer.create");
console.log(method.params, method.result);
Static Contract Artifact (/agent/contract.json)
Figpea publishes its agent API contract as a static JSON artifact at /agent/contract.json on the editor origin (https://editor.figpea.com/agent/contract.json in production, or your local dev server URL).
Top-level structure:
{
"surfaceVersion": "1.8.0", // AGENT_API_CONTRACT_VERSION
"envVersion": 2,
"manifest": { ... },
"errorCodes": { ... }
}
surfaceVersion: matchesfigpea.versionand tracks the agent API surface version.envVersion: the protocol envelope version (1= legacy baredescribe(),2= dotted selector drill). Separates surface updates (which happen frequently) from wire protocol changes (which change rarely).
figpea.SKILL(selector?)
Signature: SKILL(selector?: string): Promise<string | undefined>
The runtime craft-guidance reference surface (REQ-705) — everything an agent needs to use window.figpea well: the authoring loop, recreating a reference faithfully, wiring interactions, the screenshot feedback loop, undo etiquette, entitlement boundaries, and the canonical Tier-1 recipe.
Two-tier architecture. The downloadable SKILL.md bootstrap (see "Download the Skill" below) is transport-only — it says how to reach the runtime (open with ?agent=1, call describe(), then call SKILL()) and names the three transports an agent may arrive through. The actual reference body lives in the runtime itself, and as a byte-identical static artifact at /agent/skill.md on the editor origin — both generated from the same on-disk source, so the two surfaces cannot disagree by construction. This means the bootstrap can no longer go stale about craft guidance, because it doesn't carry any.
Selector semantics mirror describe()'s exactly: bare SKILL() resolves the full reference body (including its trailing identity stamp); SKILL("wiring-interactions") (or any other section key — authoring-loop, recreating-reference, wiring-interactions, screenshot-loop, undo-etiquette, entitlement-boundaries, canonical-recipe) resolves just that section; an unknown, malformed, or non-string selector resolves to undefined — never throws, the same miss contract as describe().
const full = await figpea.SKILL();
const undoSection = await figpea.SKILL("undo-etiquette");
const miss = await figpea.SKILL("nope"); // undefined, no throw
Consumers by transport:
| Consumer | Surface | Why |
|---|---|---|
| Chrome DevTools MCP / Playwright MCP | figpea.SKILL() | already holds the page; zero network hop |
figpea-mcp | figpea_skill MCP tool, sourced from GET /agent/skill.md | has no page — must work before any tab pairs |
| Any HTTP client | GET <editor-origin>/agent/skill.md | no MCP at all |
figpea.session -- Project and Layer-Tree Operations
newProject()
Signature: newProject(): Promise<{ ok: true, value: { id: string } } | { ok: false, code: string, message: string }>
Creates a new, empty design project and makes it the active session. Result: the new project's id.
openFile(options)
Signature: openFile(options: { url: string, type: string, fileName?: string } | { bytes: ArrayBuffer | number[], fileName: string, type?: string }): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Opens a design file (PSD, XD, Figma, SVG, or PDF) into the active session, either by URL ({ url, type, fileName? }) or by raw bytes ({ bytes, fileName, type? }) -- provide one variant, not a mix of both. After opening, the resulting layer tree is available via layerTree() or getSelection().
layerTree()
Signature: layerTree(): Promise<{ ok: true, value: FigpeaLayerNode } | { ok: false, code: string, message: string }>
Returns the curated, JSON-serializable layer tree from the active project's root, including hidden layers. (REQ-719) Never throws across this boundary: a layer whose bounds cannot be computed reports bounds: null plus a boundsError diagnostic instead of aborting the call — every other layer in the project, including that layer's own siblings and children, keeps serializing normally. Previously, one such layer permanently made both layerTree() and layerById() throw for the entire project, for as long as it existed in the document. Every node has this shape:
{
id: string;
name?: string;
layerType: string; // e.g. "page", "folder", "rect", "text", "image"
shape?: string; // present only for shape layers
visible: boolean;
transform: [a, b, c, d, e, f]; // 2D affine matrix
bounds: { // calculated bounding box -- or null (REQ-719), see boundsError
left: number;
top: number;
right: number;
bottom: number;
} | null;
boundsError?: string; // present only when bounds is null (REQ-719) -- explains why geometry is unavailable
style?: {
fill?: string | { ... };
gradient?: { type, stops, geometry, transform };
fillType?: string;
strokeEnabled?: boolean;
strokeColor?: string;
strokeWidth?: number;
opacity?: number;
cornerRadius?: number | [number, number, number, number];
fontFamily?: string; // text layers only
fontSize?: number; // text layers only
fontWeight?: string; // text layers only
fontItalic?: boolean; // text layers only
align?: string; // text layers only
case?: string; // text layers only
patternScaleType?: string; // image/pattern fills only
patternRepeat?: string; // image/pattern fills only
};
constraints?: { horizontal?: string, vertical?: string };
resizeMode?: string;
effectiveResizeMode?: string;
mask?: { type: "alpha" | "vector" }; // present only on declared mask layers
maskedBy?: string; // present only on layers covered by a mask
autoLayout?: { mode, itemSpacing, padding, primaryAxisAlign, counterAxisAlign, primarySizing, counterSizing, wrap, childGrow, positioning, counterAxisSpacing, childAlignSelf, counterAxisAlignContent, reverseZIndex } // present only when authored (since REQ-586); writable since 1.16.0 (AGENT_API_CONTRACT_VERSION) via `layer.setAutoLayout` — see that method for accepted values. Deferred fields (wrap, childGrow, positioning, counterAxisSpacing, grid) may appear when they arrived from a `.fig` import but are not currently writable.
rawText?: string; // present only for text layers
children?: FigpeaLayerNode[]; // present only for container layers
}
Geometry is always matrix-first (transform + bounds), never rect-based, and style only ever carries the whitelisted keys documented under stylePatch below -- the full 44-key round-trip, read from the same internal fields the write side writes to (a key can't be settable without also being readable). Only keys actually set on the layer are present in a given node. All nodes are plain JSON -- no live proxy references. Honesty boundaries: for gradient-filled layers, style contains the entire gradient object (type, stops, geometry, transform) because gradients are small pure JSON with nothing held back — the deliberate opposite of the image-fill raster boundary stated below. For image/pattern-filled layers, style reports only the current fit/tile mode (patternScaleType/patternRepeat) -- the underlying image bytes (and the "free"-mode matrix) are deliberately never serialized back.
layerById(id)
Signature: layerById(id: string): Promise<{ ok: true, value: FigpeaLayerNode } | { ok: false, code: string, message: string }>
Same node shape as layerTree(), but for a single layer id (with its subtree if it's a container). Returns { ok: false, code: "not_found" } if the id doesn't exist. (REQ-719) Same fault-containment guarantee as layerTree(): never throws across this boundary — a layer whose bounds cannot be computed reports bounds: null plus a boundsError diagnostic instead.
getSelection() / setSelection(ids)
Signatures: getSelection(): Promise<{ ok: true, value: string[] } | { ok: false, code: string, message: string }> and setSelection(ids: string[]): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Read or set the ids of the layer(s) currently selected in the active session. Pass [] to setSelection to clear the selection; unknown ids are silently ignored.
find(selector?)
Signature: find(selector?: object): Promise<{ ok: true, value: FigpeaLayerNode[] } | { ok: false, code: string, message: string }>
Queries the layer tree by attribute, returning all matching nodes in document order (depth-first pre-order). A pure read that does not mutate the current selection. All selector fields are optional and AND-combined; empty/omitted selector matches every node (returns the entire tree as a flat array). Selector fields: type?, name?, nameRegex? + nameRegexFlags?, text? (text layers only), textRegex? + textRegexFlags?, visible?, hasChildren?. Returns { ok: false, code: "invalid_selector" } if the selector contains an unknown key or an uncompilable regex pattern/flags.
Same-property keys (REQ-758): seven additional reference-id keys mirror the Edit ▸ Select All with Same commands — samePropertiesAs, sameFillAs, sameStrokeAs, sameEffectAs, sameTextPropertiesAs, sameFontAs, sameComponentMasterAs. Each takes the id of a reference layer, and every layer sharing that property with it is matched — using the exact comparators, whole-document scope, and locked/hidden exclusions of the UI commands, so both surfaces return identical id sets by construction. Fill and stroke compare the full paint stack structurally (same number of paints, equal type and parameters, in order; gradients on type + stops + geometry, image paints on asset reference); effects compare as a complete set; Text Properties / Font are text-layers-only; Instance matches instances of one component master; samePropertiesAs is the appearance conjunction fill + stroke + effect + opacity + blend mode (never geometry or name). Multiple same*As keys AND-combine with each other and with the plain fields. A non-string value, or an id that resolves to no layer, returns { ok: false, code: "invalid_selector" }.
// Find all text layers
const textLayers = await figpea.session.find({ type: "text" });
// Find layers matching a regex name, case-insensitive
const headers = await figpea.session.find({ nameRegex: "^header", nameRegexFlags: "i" });
// Find every layer sharing the selected button's fill (whole document)
const reds = await figpea.session.find({ sameFillAs: "some-layer-id" });
layersAtPoint(x, y)
Signature: layersAtPoint(x: number, y: number): Promise<{ ok: true, value: FigpeaLayerNode[] } | { ok: false, code: string, message: string }>
Returns every visible layer whose precise geometry contains the given world-space point — the same coordinates bounds uses — ordered front-most first in Layers-panel display order. This is the programmatic oracle for the editor's right-click Select layer context menu: both surfaces read the same enumeration, so the returned array is exactly the stack the menu renders. Locked layers are included (this is how an agent reaches locked artwork by point — plain canvas clicks cannot); hidden subtrees are excluded wholesale. Returns an empty array when nothing contains the point. Read-only; never mutates the selection.
// Every layer under a world-space point, front-most first
const { value: stack } = await figpea.session.layersAtPoint(120, 80);
// Pick the front-most one — including a locked layer no click could reach
if (stack.length > 0) {
await figpea.session.setSelection([stack[0].id]);
}
findMatching(layerId)
Signature: findMatching(layerId: string): Promise<{ ok: true, value: FigpeaLayerNode[] } | { ok: false, code: string, message: string }>
Returns every layer on the seed's page matching the editor's Select matching layers rule (Edit menu → Select matching layers, or Opt+Cmd+A / Alt+Ctrl+A), in panel order, seed included. Two layers match when either rule fires: (A) both are instances of the same component master — always, regardless of name, size, or position; or (B) same layer type + exact same name + size within ±1 project unit + offset from the top-level ancestor within ±1 + an identical relative path of sibling indices from the top-level ancestor (the "these frames are variations of one screen" model). Locked and hidden layers never match, and the search never crosses a page boundary. Fill/stroke/text content is deliberately not compared. This reads the same core collector the UI command selects through, so the returned ids are exactly what the UI would select — use it as the programmatic oracle for the bulk-select workflow. Unknown layerId → { ok: false, code: "not_found" }; an ineligible seed (a top-level layer with no containing frame/group) → an empty array. Read-only; never mutates the selection.
// Select a Header bar, then select every matching Header across sibling frames
const { value: seed } = await figpea.session.find({ name: "Header" });
if (seed.length === 1) {
const { value: matches } = await figpea.session.findMatching(seed[0].id);
await figpea.session.setSelection(matches.map((l) => l.id));
}
waitForIdle(options?)
Signature: waitForIdle(options?: { timeout?: number }): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Waits for the active session to become idle -- the layer model populated, any in-flight font fetches completed, and the canvas has drawn a settled frame -- the render-settle signal for an agent's mutate -> settle -> observe loop. Bounded by a timeout (default 10 seconds); resolves promptly if already idle, is safe to call repeatedly, and never rejects or hangs. Result: void on success; { ok: false, code: "timeout" } if the deadline elapses before a settled frame is drawn.


await figpea.layer.create("rect", { rwidth: 100, rheight: 50 });
const settled = await figpea.session.waitForIdle();
if (settled.ok) {
const shot = await figpea.canvas.screenshot(); // canvas has finished drawing
}
figpea.layer -- Layer Authoring Operations
All layer operations apply to the active session and share the same undo/redo history as the UI -- each call is recorded as exactly one undo step, regardless of wall-clock timing.
create(kind, props)
Signature: create(kind: string, props: object): Promise<{ ok: true, value: { id: string } } | { ok: false, code: string, message: string }>
Creates a new layer of the given kind -- one of "rect", "ellipse", "line", "polygon", "path", "text", "folder", "page", "image" -- and attaches it under props.parentId (or the project root if omitted). Common props fields: parentId?, index?, name?, transform?: [a,b,c,d,e,f], style? (whitelisted keys only, see stylePatch — optional style overrides. When style.fill is omitted for shape layers ('rect', 'ellipse', 'polygon', 'path'), the layer defaults to a visible neutral fill (matching the UI shape tools) so it is immediately visible on default white artboards). Every kind has its own real geometry fields -- there is no generic width/height:
"rect":rwidth?(default100),rheight?(default50)"ellipse":rx?(default50),ry?(default50)"line":x2?(default100),y2?(default0) -- runs from the local origin to(x2, y2)"polygon":points?: { x: number, y: number }[]"path":path?: string(SVG path data, default"M0 0") — a present value must parse to usable geometry (see the validation note below)"text":text?: string(default"") -- position text viatransformorsetPositionafter creation"folder": no kind-specific fields"page":pageWidth?(default300),pageHeight?(default150);namesets the page title. Pages are auto-placed clear of existing pages when no explicit transform is given."image":url?orbytes?(exactly one), optionalmimeType?, plusrwidth?/rheight?like"rect", plus optionalpatternScaleType?: "fit" | "cover" | "fill" | "free"(default"cover") andpatternRepeat?: "no-repeat" | "repeat" | "repeat-x" | "repeat-y"(default"no-repeat") to control image fit/tile mode
Validation & rejection (REQ-718): every geometry prop above is validated, not just accepted — a present rwidth/rheight/rx/ry/x2/y2/pageWidth/pageHeight must be a finite number, and points must be a non-empty array of {x,y} objects with finite x/y (an array of [x,y] pairs, an empty array, or non-numeric coordinates are all rejected). A prop that fails this check rejects the whole call with { ok: false, code: "invalid_geometry", message } — the message names the offending prop and the expected shape — and creates nothing. This closes a silent-failure hole where a bad value used to be accepted with {ok:true} and a real layer id whose bounds were NaN/null, invisible on the canvas: a successful create() now guarantees finite bounds.
"path" value validation (REQ-719): a present "path" value is additionally required to parse to usable SVG geometry (the same parser bounds-computation uses) — a malformed, unparseable d string rejects the whole call with { ok: false, code: "invalid_geometry", message }, naming "path", and creates nothing. An empty string ("", the documented 0x0 case) and any valid d keep working exactly as before. This closes the everyday route where one syntactically-wrong d string used to silently poison the whole project's layerTree()/layerById() for the rest of the session (see below).
Result: the newly created layer's id. Note: create() specifies extent only; use setPosition(id, { x, y }) after creation to place the layer precisely.
duplicate(id)
Signature: duplicate(id: string): Promise<{ ok: true, value: { id: string } } | { ok: false, code: string, message: string }>
Duplicates an existing layer and attaches the clone as a sibling immediately after id. Returns the new layer's id.
move(id, dx, dy) / resize(id, size) / rotate(id, deg) / flipX(id) / flipY(id)
Pan a layer by a local delta (move), resize it to an absolute local target { width?, height? } (resize), rotate it by degrees around its visible box center (rotate), or flip it in place (flipX/flipY). All return Promise<{ ok: true } | { ok: false, code: string, message: string }>.
setPosition(id, pos) / setTransform(id, matrix)
Signatures: setPosition(id: string, pos: { x: number, y: number }): Promise<{ ok: true } | { ok: false, code: string, message: string }> and setTransform(id: string, matrix: [a, b, c, d, e, f]): Promise<{ ok: true } | { ok: false, code: string, message: string }>
setPosition sets a layer's absolute local position — placing its visible bounding box (bounds) top-left corner at (x, y) — leaving rotation/scale untouched; repeating the same call is idempotent. setTransform sets the full affine matrix verbatim (no decompose/recompose), so a read immediately afterward deep-equals exactly what was set. Result: void on success; setPosition returns { ok: false, code: "invalid_position" } if x/y aren't both finite numbers, setTransform returns { ok: false, code: "invalid_transform" } if matrix isn't an array of exactly 6 finite numbers.
await figpea.layer.setPosition(layerId, { x: 100, y: 100 });
await figpea.layer.setTransform(layerId, [5, 0, 1.5, 1, 40, 30]); // scale x5, shear, translate
setMask(id, type) / clearMask(id)
Signatures: setMask(id: string, type: "alpha" | "vector"): Promise<{ ok: true } | { ok: false, code: string, message: string }> and clearMask(id: string): Promise<{ ok: true } | { ok: false, code: string, message: string }>
setMask declares a layer to be a mask over its preceding sibling / container, specifying whether masking uses "alpha" or "vector" mode. clearMask un-declares mask behavior. Returns { ok: false, code: "invalid_mask_type" } if type is invalid.
getRegionFills(id) / setRegionFill(id, regionKey, fill) / clearRegionFill(id, regionKey)
Signatures: getRegionFills(id: string): Promise<{ ok: true, value: { regions: { regionKey: string, fill: { fillType: "solid", fill: string } | null }[] } } | { ok: false, code: string, message: string }>, setRegionFill(id: string, regionKey: string, fill: string): Promise<{ ok: true } | { ok: false, code: string, message: string }>, clearRegionFill(id: string, regionKey: string): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Registers the Paint tool's region-fill model — filling individual closed regions of a path layer, including the regions formed where two or more subpaths overlap — on the agent API.
getRegionFills(id) lists every closed region derivable from a path layer's own subpath geometry, each with its regionKey and current fill ({fillType:"solid", fill} or null if unfilled). regionKey is the sorted, comma-joined list of the region's contributing subpath indices (e.g. "0", "1", "0,1" for the lens where two overlapping subpaths meet) — the only way to discover a valid regionKey, since no other method exposes a path layer's svg/subpath structure. It is stable across an edit that reshapes a subpath in place (an anchor move), but not guaranteed stable across an edit that adds, removes, or reorders subpaths.
setRegionFill(id, regionKey, fill) fills or re-colors one region with a plain CSS color string (solid fill only). If the layer still carries a single layer-wide fill, that color is first decomposed into an equal per-region entry for every currently-derived region, then the requested region is overwritten — all in one undo step. clearRegionFill(id, regionKey) removes one region's fill, leaving every other region (including the non-overlapping parents of an overlap region) untouched; a value-identical call is a genuine no-op.
Both region-addressed methods return { ok: false, code: "not_a_path_layer" } if the target isn't a path-shaped layer; setRegionFill additionally returns { ok: false, code: "invalid_region_key" } if regionKey doesn't match a region derivable from the layer's current geometry.
const listing = await figpea.layer.getRegionFills(pathId);
// listing.value.regions -> [{regionKey:"0", fill:null}, {regionKey:"1", fill:null}, {regionKey:"0,1", fill:null}]
await figpea.layer.setRegionFill(pathId, listing.value.regions[0].regionKey, "#3d63dd");
await figpea.layer.clearRegionFill(pathId, listing.value.regions[2].regionKey);
setConstraints(id, patch) / setResizeMode(id, mode)
Signatures: setConstraints(id: string, patch: { horizontal?: "left" | "right" | "leftAndRight" | "center" | "scale", vertical?: "top" | "bottom" | "topAndBottom" | "center" | "scale" }): Promise<{ ok: true } | { ok: false, code: string, message: string }> and setResizeMode(id: string, mode: "content" | "viewport" | "constraint"): Promise<{ ok: true } | { ok: false, code: string, message: string }>
setConstraints sets a layer's per-child constraint axes (used when its parent is in constraint mode); the patch merges against the layer's current/default constraints, so a partial {horizontal?}- or {vertical?}-only call preserves the other axis. setResizeMode sets a container's (page or group/folder) resize mode: "content" hugs to its children, "viewport"/"constraint" give it a fixed frame that clips overflow; a page cannot use "content" mode. Returns { ok: false, code: "invalid_constraint" } if an axis/mode value is outside its enum, if "content" mode is applied to a page, or if the target is neither a page nor a folder.
setAutoLayout(id, patch)
Signature: setAutoLayout(id: string, patch: { mode?: "none" | "horizontal" | "vertical", itemSpacing?: number, padding?: { top?: number, right?: number, bottom?: number, left?: number }, primaryAxisAlign?: "min" | "center" | "max" | "space_between" | "space_evenly", counterAxisAlign?: "min" | "center" | "max", primarySizing?: "fixed" | "resize_to_fit", counterSizing?: "fixed" | "resize_to_fit" }): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Sets a group's stack layout by patching its autoLayout field — one call, one undo step (ILayerProxy mutation path, history boundary via commitHistory). The patch merges against the group's current autoLayout (so { itemSpacing: 16 } preserves an already-set padding and mode). Passing mode: "none" clears the stack and restores free layout; remaining keys in the same patch are ignored when mode is "none".
mode—none(clear) /horizontal/vertical. Required on first enable; omit to tune an already-active stack.itemSpacing— gap between adjacent children on the stack axis. Must be a finite, non-negative number.padding— insets the content box on four sides; partial{ top?, right?, bottom?, left? }merges against the existing padding (omit to leave a side unchanged). Each side must be a finite, non-negative number.primaryAxisAlign/counterAxisAlign— distribution along / across the stack axis (enums above).primarySizing/counterSizing—fixed(container size authoritative) vsresize_to_fit(hug contents + padding; container size derived from children, inner-first for nested stacks).
Validation & rejection: before mutating, the call rejects with the standard err() shape ({ ok: false, code, message } — see errorCodes) and leaves the layer unmodified when: mode/primaryAxisAlign/counterAxisAlign/primarySizing/counterSizing is outside its enum; itemSpacing or any padding side is not a finite non-negative number; or id does not resolve to a group (LayerType.folder). No new dedicated error code — the existing invalid_params / not_a_group family is returned (use the exact literal the implementation publishes; do not invent invalid_auto_layout).
Deferred fields: wrap, childGrow, positioning, counterAxisSpacing, grid, and counterAxisAlign: "baseline" | "stretch" are not settable through this method. They remain on the stored model and are preserved untouched by every setAutoLayout call — a group that arrived from .fig with wrap: true keeps that field byte-identically after tuning itemSpacing or padding.
Read-back: autoLayout is already exposed on FigpeaLayerNode via session.layerTree() / session.layerById() (since REQ-586). After setAutoLayout, the new values are observable there; solved child geometry is the subsequent per-layer bounds / transform that REQ-743's solver recomputes — no separate solved-geometry accessor.
Worked example — horizontal stack with gap and padding (AC-6):
// Turn a group into a horizontal stack with a 12px gap and 16px padding.
const r = await figpea.layer.setAutoLayout(groupId, {
mode: "horizontal",
itemSpacing: 12,
padding: { top: 16, right: 16, bottom: 16, left: 16 },
primaryAxisAlign: "min",
counterAxisAlign: "center",
primarySizing: "resize_to_fit",
counterSizing: "fixed",
});
if (!r.ok) throw new Error(r.message);
// Verify — stored model and solved geometry are already readable.
const node = await figpea.session.layerById(groupId);
console.log(node.value.autoLayout); // { mode: "horizontal", itemSpacing: 12, padding: {…}, … }
console.log(node.value.children.map(c => c.bounds)); // solved positions incl. gap + padding
Cross-links: See Auto Layout for the designer-facing panel controls that drive the same fields (REQ-744). Saved with the Figpea project (.fp); not written back into the .psd / .xd / .fig you opened.
placeProjectImage(hash)
Signature: placeProjectImage(hash: string): Promise<{ ok: true, value: { id: string } } | { ok: false, code: string, message: string }>
Places an image already in the project -- identified by the sha256 content hash figpea.report.projectImages() reports -- onto the canvas as a new rect layer, sized to the image's intrinsic pixel dimensions. Placement is fully automatic (there is no parentId/x/y param): it parents into the selected page/layer's page, or the project root at the viewport centre when nothing is selected, and positions at the viewport centre when that point lies on the target page (otherwise the centre of the page's on-screen region, or the page's own centre if off-screen). The camera never moves. Selected on creation. One undo step. Reuses the library's existing image instance rather than decoding a second copy. Returns { ok: false, code: "not_found" } for an unknown hash or when no session is active.
stylePatch(id, patch)
Signature: stylePatch(id: string, patch: object): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Patches a layer's style with an explicit whitelist of public keys -- any key not on the list is rejected with { ok: false, code: "unsupported_style_key" }:
- Fill & stroke:
fill,fillType,strokeEnabled,strokeColor,strokeWidth,strokes({ enabled?: boolean, strokeColor?: string, strokeWidth?: number, strokePosition?: "inside" | "center" | "outside", lineCap?: string, lineJoin?: string, miterLimit?: number, dashArray?: number[], dashOffset?: number, paint?: { type: "solid" | "gradient" | "image", color?, gradient?, pattern?, patternScaleType?, patternRepeat?, patternTransform? } }[]— supports multi-stroke layer and gradient stroke authoring) - Gradient fill:
gradient—type: "linear" | "radial" | "conic", required non-emptystops: [{ offset, color }], optional per-type geometry (linearX1/Y1/X2/Y2for linear;innerCircleX/Y/R+outterCircleX/Y/Rfor radial;startAngle/centerX/centerYfor conic) and optionaltransformmatrix (a 6-number affine applied in the gradient's paint space; its translate components are in normalized layer-local units, 0..1 across the layer, the same space as the geometry fields); bothtypeandstopsmust be supplied in the same patch asfillType: "gradient"(settingfillTypealone is not valid). Malformed gradient →invalid_style_value, with the layer's prior fill left completely unchanged. - Stroke controls:
strokePosition("inside" | "center" | "outside"),lineCap("butt" | "round" | "square"),lineJoin("bevel" | "miter" | "round"),dashArray(number array, e.g.[4, 2]),dashOffset(number),miterLimit(number) - Effects:
dropShadow/innerShadow({ enabled, dx, dy, r, color }),objectBlur({ enabled, r }),backgroundBlur({ enabled, r, brightness, opacity }),overlay({ enabled, color }),gradientOverlay({ enabled: boolean, gradient?: { type: "linear" | "radial" | "conic", stops: Array<{ offset: number, color: string }> }, blendMode?: string, opacity?: number, reverse?: boolean }—reverseflips the gradient's stop order at render time),patternOverlay({ enabled: boolean, pattern?: object, scale?: number, origin?: { x: number, y: number }, blendMode?: string, opacity?: number }—patternis the decoded PSD pattern bitmap set by the file's own decoder; it is not independently authorable through this call, so apatternOverlaypatch is effectively limited to togglingenabled/scale/origin/blendMode/opacityon an already-decoded pattern),outerGlow({ enabled: boolean, color: string, size: number, spread?: number, blendMode?: string, opacity?: number }—spread(0-100) narrows the effective blur as it increases),innerGlow({ enabled: boolean, color: string, size: number, choke?: number, blendMode?: string, opacity?: number, source?: "edge" | "center" }—choke(0-100) is inner glow's equivalent ofspread;sourcepicks whether the glow hugs the shape's silhouette ("edge", default) or radiates from its local-bounds center ("center")) - Blend mode:
blendMode(accepted:"normal" | "dissolve" | "darken" | "multiply" | "color-burn" | "linear-burn" | "darker-color" | "lighten" | "screen" | "color-dodge" | "linear-dodge" | "lighter-color" | "overlay" | "soft-light" | "hard-light" | "vivid-light" | "linear-light" | "pin-light" | "hard-mix" | "difference" | "exclusion" | "subtract" | "divide" | "hue" | "saturation" | "color" | "luminosity") — composites correctly across the layer's own opacity (0–100%) and any vector/raster mask alpha channel, even over transparent or semi-transparent background stacks - Transparency:
opacity(layer incl. effects),fillOpacity(Photoshop fill opacity — scales only the layer's own fill/pixels, never its effects; 0..1, effects stay atopacity) - Geometry:
cornerRadius(number, or a[tl, tr, br, bl]tuple) - Text style (text layers only):
fontFamily(automatically resolves and loads matching Google Fonts on demand;session.waitForIdle()awaits in-flight font fetches),fontSize,fontWeight,fontItalic,align,case("upper" | "lower" | "title"— layer-level default only, no ranged authoring; out-of-enum →invalid_style_value)

- Text-frame sizing:
fixWidth/fixHeight(numbers; omit for auto-width / auto-height).valign("top" | "center" | "bottom") — only visible whenfixHeightis set. - Image fill mode (image/pattern layers only):
patternScaleType("fit" | "cover" | "fill" | "free"),patternRepeat("no-repeat" | "repeat" | "repeat-x" | "repeat-y")
This is the full 44-key round-trip -- the same keys layerTree()/layerById() read back under style above.
await figpea.layer.stylePatch(layerId, {
fill: "#ff0000",
opacity: 0.8,
strokeWidth: 2,
cornerRadius: [4, 4, 8, 8],
blendMode: "luminosity",
});
setImageFill(id, source)
Signature: setImageFill(id: string, source: { url: string, mimeType?: string, patternScaleType?: "fit" | "cover" | "fill" | "free", patternRepeat?: "no-repeat" | "repeat" | "repeat-x" | "repeat-y" } | { bytes: ArrayBuffer | Uint8Array | number[], mimeType?: string, patternScaleType?: "fit" | "cover" | "fill" | "free", patternRepeat?: "no-repeat" | "repeat" | "repeat-x" | "repeat-y" }): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Attaches an image fill to an existing layer (any kind), choosing the fit/tile mode in the same call. Validate-before-mutate: any supplied patternScaleType/patternRepeat is checked first (out-of-enum -- invalid_style_value, nothing touched), then the source is resolved to a loaded image (fetch/decode failure -- invalid_image_source, nothing touched).
Parameters:
id(string) — Layer id.source(object) — Image source (exactly one ofurl/bytesis required):url?: string— HTTP(S) URL to an image.bytes?: ArrayBuffer | Uint8Array | number[]— Raw image bytes.mimeType?: string— used when constructing frombytes.patternScaleType?: "fit" | "cover" | "fill" | "free"(default"cover") andpatternRepeat?: "no-repeat" | "repeat" | "repeat-x" | "repeat-y"(default"no-repeat") — same enums asstylePatch/create("image"), to control image fit/tile mode.
Result: Void on success; { ok: false, code: "not_found" } for an unknown layer id; { ok: false, code: "invalid_style_value" } if patternScaleType/patternRepeat is outside its enum; { ok: false, code: "invalid_image_source" } if the source (url fetch or bytes decode) failed to resolve to a usable image.
await figpea.layer.setImageFill(layerId, { url: "https://example.com/image.png" });
// Read it back:
const node = await figpea.session.layerById(layerId);
if (node.ok && node.value.style?.fillType === "pattern") {
console.log(`Fit mode: ${node.value.style.patternScaleType}`);
}
setText(id, text) / setVisible(id, visible) / delete(id)
Replace a text layer's content (setText), toggle visibility (setVisible), or remove a layer from its parent (delete). All return Promise<{ ok: true } | { ok: false, code: string, message: string }>.
setName(id, name)
Signature: setName(id: string, name: string): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Renames a layer or page by id. Validates name is a non-empty string (after trim); trims before storing. Returns {ok:false, code:"not_found"} for an unknown id, {ok:false, code:"invalid_params"} for a missing / non-string / empty-or-whitespace-only name. One undo step via the shared layer-mutator transaction. Also available as a batch op ({method:"setName", args:[id, name]}) with the same semantics and single-undo coalescing.
await figpea.layer.setName(layerId, "Press Kit");
await figpea.layer.setName(pageId, "Homepage");
// Batch:
await figpea.layer.batch([{ method: "setName", args: [layerId, "Header"] }]);
group(ids) / ungroup(id)
Signatures: group(ids: string[]): Promise<{ ok: true, value: { id: string } } | { ok: false, code: string, message: string }> and ungroup(id: string): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Group the given layer ids into a new folder layer at the first id's position, or ungroup a folder layer, promoting its children to its own parent.
reorder(id, targetId, pos) / reparent(id, parentId, index?)
Move a layer to a sibling position relative to targetId (pos is "before", "on", or "after"), or move it into a new parent's children at an optional index (defaults to appending at the end). Both return Promise<{ ok: true } | { ok: false, code: string, message: string }>.
clip(id) / unclip(id)
Remove a layer from its parent's children and turn it into the clip mask for the sibling immediately behind it (the previous entry in the parent's children array) (clip), or restore a clip mask as a visible sibling again (unclip). Both legacy clip helpers execute as exactly one undo step each when called via batching.
batch(ops)
Signature: batch(ops: { method: string, args: unknown[] }[]): Promise<{ ok: true, value: { results: OpResult[] } } | { ok: false, code: string, message: string, failedIndex: number, results: OpResult[] }>
Applies a sequence of other figpea.layer.* ops as one undo step -- a single figpea.history.undo() reverses the whole batch. All-or-nothing: if any op fails, the whole batch rolls back (no partial application, no undo step created) and the result reports the failing op's own code/message plus its failedIndex. method: "batch" (recursion) or any unrecognized method name is rejected per-op with { ok: false, code: "unsupported_op" }.
// Build a 2-rect layout as ONE undo step.
const result = await figpea.layer.batch([
{ method: "create", args: ["rect", { parentId: pageId, x: 0, y: 0, rwidth: 50, rheight: 50 }] },
{ method: "create", args: ["rect", { parentId: pageId, x: 60, y: 0, rwidth: 50, rheight: 50 }] },
]);
if (result.ok) {
const ids = result.value.results.map((r) => r.value.id);
await figpea.history.undo(); // removes both rects
} else {
console.log(`batch failed at op ${result.failedIndex}: ${result.code}`);
}
select(target)
Signature: select(target: string | string[]): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Sets the active session's current selection to the given layer id(s), rendering the transform widget for them -- the ergonomic authoring-surface wrapper over the same selection path figpea.session.setSelection uses, accepting either a single id or an array for multi-select. Result: void on success; { ok: false, code: "not_found" } if any given id doesn't resolve to a layer.
// Select one layer, then read where its transform widget rendered:
await figpea.layer.select(rectId);
const geometry = await figpea.canvas.widgetGeometry();
figpea.history -- Undo/Redo
All figpea.layer calls are recorded in a shared history with the UI's undo/redo -- one API call, one undo step.
undo() / redo()
Signatures: undo(): Promise<{ ok: true } | { ok: false, code: string, message: string }> and redo(): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Revert or re-apply the most recent step on the active session's history. Prefer undo() over hand-computing an inverse mutation while iterating -- it's cheaper and safer, and it shares the same stack as the UI.
figpea.interaction -- Interaction / Prototype Authoring
Author and read back prototype interactions (tap, time, keys-gamepad, hover triggers; navigate, artboard-transition, auto-animate, overlay-transition, previous-artboard, scroll-transition, hyperlink, state-transition actions). Each operation is one undo step and free model mutation; authoring does not enable Pro-gated Present-mode playback.
create(layerId, input)
Signature: create(layerId: string, input: { trigger: "tap" | "time" | "keys-gamepad" | "hover", action: "navigate" | "artboard-transition" | "auto-animate" | "overlay-transition" | "previous-artboard" | "scroll-transition" | "hyperlink" | "state-transition", targetId?: string, properties?: object }): Promise<{ ok: true, value: { id: string } } | { ok: false, code: string, message: string }>
Authors an interaction on a layer — a trigger that navigates or transitions to a target. Validation runs before any mutation: unsupported trigger/action, missing/invalid targetId, or non-page target all reject with invalid_interaction.
Result: the newly created interaction's id on success, not_found if the layer or target page don't exist, or invalid_interaction on validation failure.
const result = await figpea.interaction.create(rectId, {
trigger: "tap",
action: "navigate",
targetId: pageId,
properties: { duration: 0.3 }
});
if (result.ok) {
console.log("Interaction created:", result.value.id);
}
list(layerId)
Signature: list(layerId: string): { ok: true, value: { id: string, trigger: string, action: string, targetId?: string, properties?: object }[] } | { ok: false, code: string, message: string } (synchronous -- no Promise)
Lists all interactions on a layer. Returns an empty array for layers with no interactions.
const interactions = figpea.interaction.list(rectId);
if (interactions.ok) {
for (const interaction of interactions.value) {
console.log(`${interaction.trigger} → ${interaction.action} to ${interaction.targetId}`);
}
}
remove(interactionId)
Signature: remove(interactionId: string): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Removes an interaction by id from the active project.
figpea.component -- Component State Authoring, Instance Creation & Overrides
Author component instances, manage component states, convert layers to reusable components, and inspect/reset instance overrides. All methods are free -- no entitlement gating. For the UI concept guide on component masters and instances, see Components and Instances.
The eleven supported methods in this group are:
component.create(layerId)component.placeInstance(componentId)component.addState(componentId, input?)component.setActiveState(componentId, input?)component.getStates(componentId)component.getChildOverrides(childId)component.getOverrides(instanceId)component.resetOverride(childId, input)component.resetAllOverrides(instanceId)component.removeChild(childId)component.restoreChild(instanceId, input)
Every signature and undo claim below is copied verbatim from v3's live component.descriptor.ts -- never assume undo behavior by analogy between methods, each one is independently measured.
create(layerId)
Signature: create(layerId: string): Promise<{ ok: true, value: { id: string } } | { ok: false, code: string, message: string }>
Converts a plain layer to a component and registers it in the project's component library. The layer stays in its current page position; selects the new component. One undo step: a single figpea.history.undo() fully reverses the whole conversion -- com/states are cleared and the layer is deregistered from the component library; figpea.history.redo() re-applies it.
placeInstance(componentId, position?)
Signature: placeInstance(componentId: string, position?: { x: number, y: number }): Promise<{ ok: true, value: { id: string } } | { ok: false, code: string, message: string }>
Creates an instance of a component. Two placement paths:
- With
position(e.g.{ x: 240, y: 120 }): the instance is added to the active page and positioned so the drop point lands at its centre. Selected on creation, as one undo step (withUndoBatchpath), and the camera does not move. Supplying non-finitex/yrejects with the standarderr()shape before any layer is created. - Without
position(omit the argument): the call is byte-identical to the pre-REQ behavior — same target page viaresolveInstancePlacementParent(the master's own page), same cascade offset viaplacedInstanceCount+instancePlacementTransform, same selection viaonChooseSingleLayer, same camera-stays-put contract — and that path remains NOT undoable via the app's undo stack (a repo-level layer addition outside history, the same deliberate exclusion asaddState).
Result: the newly-created instance's id.
addState(componentId, input?)
Signature: addState(componentId: string, input?: { name?: string }): Promise<{ ok: true, value: { id: string, name: string } } | { ok: false, code: string, message: string }>
Adds a new state to a component and makes it the active state. Omit input.name (or the whole input object) for a default "State {n}" name. NOT undoable via the app's undo stack -- creating the state is a repo-level layer mutation, the same deliberate history exclusion as placeInstance's no-position path (the with-position path, by contrast, is one undo step).
setActiveState(componentId, input?)
Signature: setActiveState(componentId: string, input?: { stateId?: string }): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Switches a component's active state by id, or clears it to Base (omit input.stateId, or the whole input object). One undo step for ANY state transition -- named-to-named or to/from Base alike: a single figpea.history.undo() reverses the switch either way.
getStates(componentId)
Signature: getStates(componentId: string): { ok: true, value: { states: [{ id: string, name: string, active: boolean }], activeStateId?: string } } | { ok: false, code: string, message: string } (synchronous -- no Promise)
Returns the list of available states on a component. Imported component states (from Adobe XD or Figma files) are fully surfaced by getStates() and retain their original source file state names.
getChildOverrides(childId)
Signature: getChildOverrides(childId: string): { ok: true, value: { instanceId?: string, groups: ("transform" | "fill" | "text" | "visibility")[] } } | { ok: false, code: string, message: string } (synchronous -- no Promise)
Reconcile-and-read: recomputes which override groups childId currently diverges from its enclosing instance's master, syncing the instance's record as a side effect. A layer with no enclosing instance (plain layer, master child, or the instance root itself) is not an error -- returns { instanceId: undefined, groups: [] }.
getOverrides(instanceId)
Signature: getOverrides(instanceId: string): { ok: true, value: { changedProps: { childId: string, groups: string[] }[], removedChildren: { baseChildId: string, name: string, icon: string }[] } } | { ok: false, code: string, message: string } (synchronous -- no Promise)
Full read of one instance's Overrides block: every child with a currently-recorded overridden-groups entry, plus every removed child resolved via its recorded base id.
resetOverride(childId, input)
Signature: resetOverride(childId: string, input: { group: "transform" | "fill" | "text" | "visibility" }): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Clears one overridden group on a child -- its own value for that group's keys is removed (falls back to tracking the master live) or copied from the master's current value. Other overridden groups on the same child are left untouched. A childId with no enclosing instance, or a group outside the four recognized names, is a safe no-op (ok(undefined), no mutation), never an error. One undo step, faithfully reversible in both directions.
resetAllOverrides(instanceId)
Signature: resetAllOverrides(instanceId: string): Promise<{ ok: true } | { ok: false, code: string, message: string }>
"Reset all changes" on an instance: clears every overridden group plus the other inspector-editable style keys on every descendant with a resolvable master child, and restores every removed child -- bounded to one instance's subtree. One undo step whenever the instance has at least one overridden group to clear or at least one removed child to restore; zero undo steps only when there is genuinely nothing to reset.
removeChild(childId)
Signature: removeChild(childId: string): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Removes a child from its enclosing instance only (the master and every sibling instance are untouched) -- records the child's base id so it can be restored later via restoreChild. A no-op if childId has no enclosing instance. One undo step.
restoreChild(instanceId, input)
Signature: restoreChild(instanceId: string, input: { baseChildId: string }): Promise<{ ok: true, value: { id: string } } | { ok: false, code: string, message: string }>
Restores a previously-removed child on an instance by its recorded base id -- re-instances it from the master child at the master's child-order index (restore always re-instances; it never re-attaches the original detached child, which would not survive a project serialize/restore round-trip). One undo step.
const states = figpea.component.getStates(componentId);
if (states.ok) {
console.log(`Active: ${states.value.activeStateId || "Base"}`);
}
figpea.export -- Layer and Project Export
UI soft graces do not apply to the Agent API. The interactive editor UI includes session-based soft graces (such as 6 free scaled exports per session) for human trial sampling. The Agent API does not grant these soft graces — all programmatic export calls (
scale ≠ 1, PDF, ZIP, spec bundles, tokens, originals, asset harvest, Figma Migration Kit, contact sheet, flow poster) strictly enforce claim checks (hasClaim(...)) and require an active Pro plan or Rescue Pass.
All export operations honor the same entitlement gating as the UI (via hasClaim(...)), returning { ok: false, code: "entitlement_required" } instead of popping an upgrade modal when access is denied. Authoring under figpea.session, figpea.layer, and figpea.history is always free; export is the one boundary that can require a plan.
layer(id, input) / artboard(id, input)
Signature: layer(id: string, input: { format: "png" | "jpeg" | "webp" | "svg", scale?: number, suffix?: string }): Promise<{ ok: true, value: { bytes: string, mime: string, filename: string } } | { ok: false, code: string, message: string }>
Exports a single layer as a raster image or SVG. scale ≠ 1 requires hasClaim("extract.scale"); format === "svg" requires hasClaim("extract.svg"). artboard(id, input) has the identical signature and gating -- an "artboard" is just a page/folder layer addressed like any other layer id.
Result: the exported bytes as a base64 string, a MIME type, and a suggested filename. Decode the bytes with Uint8Array.from(atob(bytes), c => c.charCodeAt(0)) -- not new Blob([atob(bytes)], ...), which corrupts anything outside the Latin-1 range because atob produces a binary string, not raw bytes:
const result = await figpea.export.layer(layerId, { format: "png", scale: 2 });
if (result.ok) {
const { bytes, mime, filename } = result.value;
const byteArray = Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0));
const blob = new Blob([byteArray], { type: mime });
// download or process blob...
} else if (result.code === "entitlement_required") {
console.log("User must upgrade to export at this scale");
}
project(input)
Signature: project(input: { format: "pdf" | "zip" | "figpea", size?: number, imageFormat?: "png" | "jpeg" | "webp" }): Promise<{ ok: true } | { ok: false, code: string, message: string }>
Exports the whole active project as PDF (requires hasClaim("export.pdf")), ZIP of per-layer rasters (requires hasClaim("export.zip")), or the native Figpea format (ungated). size/imageFormat apply to the ZIP format's raster output.
const result = await figpea.export.project({ format: "pdf" });
if (!result.ok && result.code === "entitlement_required") {
console.log("User must upgrade to export PDF");
}
specBundle() / tokens()
Signatures: specBundle(): Promise<{ ok: true, value: { html: string } } | { ok: false, code: string, message: string }> and tokens(): Promise<{ ok: true, value: { css: string, json: string } } | { ok: false, code: string, message: string }>
Developer-handoff exports. specBundle() returns a self-contained, offline HTML string (zero external references) covering every artboard's inspectable elements -- position, size, fills, typography, copyable CSS. tokens() returns the active project's distinct fill colors and text styles as CSS custom properties (css) plus a documented JSON subset (json), with deterministic naming across consecutive exports. Both take no arguments and return their text directly rather than triggering a download. Both require hasClaim("export.zip") -- the same claim project({ format: "zip" }) gates on. They are not free, the same "authoring free, export gated" boundary above applies to them too.
const bundle = await figpea.export.specBundle();
const tokens = await figpea.export.tokens();
if (bundle.ok && tokens.ok) {
console.log(bundle.value.html.length, tokens.value.css);
} else if (!bundle.ok && bundle.code === "entitlement_required") {
console.log("User must upgrade to export the spec bundle / tokens");
}
originals()
Signature: originals(): Promise<{ ok: true, value: { bytes: string, mime: "application/zip", filename: string } } | { ok: false, code: string, message: string }>
Recovers every distinct (by content hash) original embedded image in the active project as a ZIP of the verbatim original bytes -- no re-encode, byte-identical to the source -- the agent-API mirror of "Recover Original Images." Takes no arguments and returns the ZIP as base64 bytes rather than triggering a download.
Result: ZIP bytes as a base64 string, mime: "application/zip", and a suggested filename.
const result = await figpea.export.originals();
if (result.ok) {
const { bytes, filename } = result.value;
const blob = new Blob([Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0))], { type: "application/zip" });
// save or process blob...
} else if (result.code === "entitlement_required") {
console.log("User must upgrade to recover original images");
}
assetHarvest(options?)
Signature: assetHarvest(options?: { preset?: "web" | "ios" | "android" }): Promise<{ ok: true, value: { bytes: string, mime: "application/zip", filename: string } } | { ok: false, code: string, message: string }>
Harvests every detected asset (embedded images and icon-sized vector groups) from the active project as a preset-formatted ZIP with platform-appropriate scales, formats, and folder layout -- the agent-API mirror of the "Harvest Assets" gallery modal. preset defaults to "web"; the agent-API call exports all detected assets (selection is a UI-only affordance). Browsing the gallery is free -- only the export call itself is gated.
Result: ZIP bytes as a base64 string, mime: "application/zip", and a suggested filename.
const result = await figpea.export.assetHarvest({ preset: "ios" });
if (result.ok) {
const { bytes, filename } = result.value;
// save or process the ZIP bytes...
} else if (result.code === "entitlement_required") {
console.log("User must upgrade to export assets");
}
// Harvest with the default (web) preset:
const webAssets = await figpea.export.assetHarvest();
figmaKit()
Signature: figmaKit(): Promise<{ ok: true, value: { bytes: string, mime: "application/zip", filename: string } } | { ok: false, code: string, message: string }>
Assembles the Figma Migration Kit: a Figma-tuned SVG for every top-level artboard (text stays editable, named layers/groups keep their names) plus an assets/ folder, fonts.md, copy-deck.md, and README.md, all in one ZIP -- the agent-API mirror of the "Figma Migration Kit" export option. This emits new Figma-tuned output; it does not save back into an existing Figma file. Takes no arguments.
Result: ZIP bytes as a base64 string, mime: "application/zip", and a suggested filename.
const result = await figpea.export.figmaKit();
if (result.ok) {
const { bytes, filename } = result.value;
// save or process the ZIP bytes...
} else if (result.code === "entitlement_required") {
console.log("User must upgrade to export the Figma Migration Kit");
}
Entitlement note: like specBundle()/tokens() above, originals(), assetHarvest(), and figmaKit() all require hasClaim("export.zip") -- they are not free -- the same claim project({ format: "zip" }) gates on. The "authoring free, export gated" boundary applies to every developer-handoff export in this section, with no exceptions.
contactSheet()
Signature: contactSheet(): Promise<{ ok: true, value: { bytes: string, mime: "application/pdf", filename: string } } | { ok: false, code: string, message: string }>
Exports the whole active project's contact-sheet PDF — a cover page (project name, stats, font manifest) plus one titled page per artboard in document order. Takes no arguments.
Gating: Requires hasClaim("export.pdf") -- the same claim project({ format: "pdf" }) above gates on (the contact sheet is a PDF; no new claim key).
Result: PDF bytes as a base64 string, mime: "application/pdf", and a suggested filename.
const result = await figpea.export.contactSheet();
if (result.ok) {
const { bytes, filename } = result.value;
const blob = new Blob([Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0))], { type: "application/pdf" });
// save or process blob...
} else if (result.code === "entitlement_required") {
console.log("User must upgrade to export the contact sheet");
}
flowPoster(input?)
Signature: flowPoster(input?: { renderFormat?: "svg" | "png" | "pdf" }): Promise<{ ok: true, value: { bytes: string, mime: string, filename: string } } | { ok: false, code: string, message: string }>
Exports the project's prototype flow poster — one node per artboard (thumbnail + name), one edge per parsed prototype transition — as SVG (default) or a PNG/PDF render of that same SVG. GATED on hasClaim("export.pdf").
Result: Bytes as a base64 string, MIME type (matching renderFormat), and a suggested filename.
const result = await figpea.export.flowPoster({ renderFormat: "png" });
if (result.ok) {
const { bytes, mime, filename } = result.value;
const blob = new Blob([Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0))], { type: mime });
// save or process blob...
} else if (result.code === "entitlement_required") {
console.log("User must upgrade to export the flow poster");
}
figpea.canvas -- Camera Control, Live Screenshot, Layer Geometry & Widget Geometry
A visual-feedback aid for driving the editor programmatically: camera (viewport) control, a live capture of what's actually on the canvas right now, and a geometry read-out of the currently-rendered transform widget. All of figpea.canvas is ungated -- none of it routes through the entitled export encoder path, because none of it is an export artifact; it's the agent's own equivalent of looking at the screen (and moving its own eyes to the right place first).
Recommended feedback loop: mutate (figpea.layer.*) -> figpea.canvas.fit(pageId) (bring the new/changed content into frame, mirroring the UI's zoom-to-fit) -> your browser-automation MCP's own native screenshot -> iterate. figpea.canvas.screenshot() below is the explicit no-camera-move fallback, for an isolated single-layer or off-screen check that doesn't disturb whatever the camera is currently framing.
fit(id?)
Signature: fit(id?: string): Promise<{ ok: true, value: FigpeaViewport } | { ok: false, code: string, message: string }>
Pans/zooms the editor camera so the given layer/page/artboard (or the whole design when id is omitted) is framed within the visible viewport, centered with ~10% padding. A degenerate/zero-area target (e.g. an empty new project) is a no-op that returns the current viewport unchanged (ok, not an error).
const rect = await figpea.layer.create("rect", { rwidth: 100, rheight: 50, style: { fill: "#ff0000" } });
await figpea.canvas.fit(rect.value.id); // frame the new shape
getViewport() / setViewport(viewport)
Signatures: getViewport(): Promise<{ ok: true, value: FigpeaViewport } | { ok: false, code: string, message: string }> and setViewport(viewport: { x: number, y: number, zoom: number }): Promise<{ ok: true, value: FigpeaViewport } | { ok: false, code: string, message: string }>
getViewport reads the current editor camera as a world-space visible rectangle -- FigpeaViewport is { x, y, width, height, zoom }, where { x, y } is the world coordinate at the DOM viewport's top-left corner, zoom is the stage scale factor, and width/height are derived, read-only world extent. This is the same world basis figpea.session.layerTree()/layerById()'s bounds use, so a layer's bounds can be compared directly against the viewport. setViewport is the exact inverse: it accepts only { x, y, zoom } (width/height are outputs of getViewport/fit, never accepted here), and setViewport(v) followed by getViewport() round-trips v (within floating-point tolerance).
await figpea.canvas.setViewport({ x: 0, y: 0, zoom: 1 });
const viewport = await figpea.canvas.getViewport();
screenshot(options?)
Signature: screenshot(options?: { id?: string, pixelRatio?: number }): Promise<{ ok: true, value: { bytes: string, mime: "image/png", width: number, height: number } } | { ok: false, code: string, message: string }>
Captures the live canvas as a PNG -- the whole visible stage by default, or (via options.id) a single layer/page/artboard's own on-canvas region; options.pixelRatio controls the capture's pixel density. bytes is base64-encoded PNG data with the identical decode gotcha as figpea.export.layer's bytes above -- see that section if you need raw bytes rather than a data:image/png;base64,... URL.
const shot = await figpea.canvas.screenshot();
if (shot.ok) {
const img = new Image();
img.src = `data:image/png;base64,${shot.value.bytes}`;
}
Off-band image returns (returnAs: "path", figpea-mcp only)
A full-page screenshot runs 500 KB – 2 MB raw, which costs ~500K – 2M tokens when figpea-mcp inlines it as base64 in an MCP image block. When you call figpea.canvas.screenshot, figpea.export.layer, or figpea.export.artboard through figpea-mcp (not through window.figpea directly), you may pass the reserved returnAs: "path" key: instead of inline base64, the bridge writes the bytes to a per-session file and returns a single text block {ok: true, path, mime, width, height, bytes, url}. Open the file with your host's own file-reading tool — url is the bridge's token-gated alias, path is the absolute session path. Omit returnAs (or pass "inline") for today's inline behavior, byte-identical. This is a figpea-mcp envelope feature: the window.figpea contract, its version, and its error codes are untouched.
// Worked example: for >1 MB screenshots, pass returnAs: 'path' and read the
// file with your host's file tool; saves ~1.3 tokens/raw byte.
const res = await mcp.callTool("canvas_screenshot", { returnAs: "path" });
// -> { content: [{ type: "text", text: '{"ok":true,"path":"/tmp/figpea-mcp/<session>/canvas_screenshot-<ts>-<uuid>.png","mime":"image/png","width":1440,"height":900,"bytes":812345,"url":"http://127.0.0.1:<port>/blob/<token>"}' }], isError: false }
const bytes = host.readFile(res.path); // bytes never cross the MCP wire as base64
widgetGeometry() / gradientWidgetGeometry()
Signature: widgetGeometry(): { ok: true, value: { handles: { leftTop, top, rightTop, left, right, leftBottom, bottom, rightBottom, rotate, body }, corners: { leftTop, rightTop, rightBottom, leftBottom } } } | { ok: false, code: string, message: string } (each point is { x: number, y: number }; synchronous -- no Promise)
Returns the current on-screen (client/viewport CSS-pixel) coordinates of the selected layer's transform-widget interaction points -- the 8 resize handles, the rotate handle, and the body-move region -- plus the selected layer's on-canvas rect corners. gradientWidgetGeometry() returns the active gradient handle points when editing gradient fills. Reads the widget's own rendered DOM, so every point matches exactly where that handle renders.
await figpea.layer.select(rectId);
const geometry = await figpea.canvas.widgetGeometry();
if (geometry.ok) {
const { x, y } = geometry.value.handles.rightBottom;
// drive a real pointer drag on the bottom-right resize handle...
}
layerRect(id)
Signature: layerRect(id: string): { ok: true, value: { x: number, y: number, width: number, height: number } } | { ok: false, code: string, message: string } (synchronous -- no Promise)
Returns the world-space bounding box (axis-aligned) of any layer, selected or not -- a pure model-level geometry read, the same bounds/calcBounds() basis layerTree()/layerById()/fit() use. Not a rendered measurement -- it excludes rendered stroke/shadow overflow; use figpea.canvas.screenshot() if you need actual pixel extent. Selection-preserving: a read-only call that never touches the current selection.
Result: { x, y, width, height } in world px (x/y are the left/top edge). Hidden or zero-area layers resolve ok with their (possibly zero-area) rect -- visibility is a separate axis, never a throw; only an unknown layer id returns not_found.
// Get the bounding box of a layer without selecting it.
const rect = await figpea.canvas.layerRect(layerId);
if (rect.ok) {
console.log(`${rect.value.width}x${rect.value.height} at (${rect.value.x}, ${rect.value.y})`);
}
figpea.report -- Project Health Report, Copy Deck, Font Manifest & Soft Diagnostics
All methods are FREE -- ungated -- inspection, like figpea.canvas above. Each mirrors the editor's Project Report modal and its "Export Copy Deck" button.
summary()
Signature: summary(): Promise<{ ok: true, value: ProjectReportData } | { ok: false, code: string, message: string }>
Builds the full project health report for the active session: artboard names, component count, font manifest, interaction count, distinct-by-content-hash image count/total bytes, and text stats. Takes no arguments.
{
projectName: string;
artboards: { name: string }[];
componentCount: number;
fonts: { name: string, missingLocally: boolean, googleFontsMatch?: string, substituted: boolean }[];
interactionCount: number;
images: { count: number, totalBytes: number };
text: { layerCount: number, totalCharacters: number, wordCount: number };
}
const result = await figpea.report.summary();
if (result.ok) {
console.log(`${result.value.artboards.length} artboards, ${result.value.images.count} distinct images`);
}
copyDeck()
Signature: copyDeck(): Promise<{ ok: true, value: { markdown: string, csv: string } } | { ok: false, code: string, message: string }>
Builds the copy deck: every non-empty text-layer string, grouped by top-level artboard, as both Markdown and RFC-4180-quoted CSV. Same data source as the UI's "Export Copy Deck" button, returned inline instead of downloaded. Takes no arguments.
const result = await figpea.report.copyDeck();
if (result.ok) {
console.log(result.value.markdown);
}
fonts()
Signature: fonts(): Promise<{ ok: true, value: { name: string, missingLocally: boolean, googleFontsMatch?: string, substituted: boolean }[] } | { ok: false, code: string, message: string }>
Returns just the font manifest -- a convenience projection of summary()'s fonts field, for agents that only need font data. Takes no arguments; the result is sorted by name. missingLocally indicates whether the font binary is installed in the local OS library (it does not mean substituted, as Google Fonts are resolved and loaded on demand); substituted is the truthful rendering signal (true when the requested family could not be resolved and a fallback face was rendered instead).
const result = await figpea.report.fonts();
if (result.ok) {
const missing = result.value.filter((f) => f.missingLocally);
console.log(`${missing.length} fonts missing locally`);
}
projectImages()
Signature: projectImages(): Promise<{ ok: true, value: { hash: string, name: string, mime: string, size: number, width: number, height: number, usedBy: string[] }[] } | { ok: false, code: string, message: string }>
Returns the active project's derived image library: one entry per distinct image (content-hash deduplicated) that the project uses as a layer fill, each carrying its sha256 content hash, name, mime type, byte size, intrinsic pixel width/height, and the ids of every layer using it (usedBy). Ordering is by content hash ascending. Pass a returned hash to figpea.layer.placeProjectImage(hash) to put that image on the canvas. PSD raster layers and masks are not listed -- they decode to ImageData, not an image blob.
diagnostics()
Signature: diagnostics(): Promise<{ ok: true, value: { severity: 'info' | 'warning' | 'error', code: string, message: string, layerId?: string, count?: number }[] } | { ok: false, code: string, message: string }>
Returns soft decode/render diagnostics accumulated for the active project since its last open (reset on openFile/newProject). An empty result means no detected soft issues by the checks this version runs, never a fidelity guarantee. Takes no arguments.
Diagnostic codes (all formats unless noted):
'font_missing_local'— a font family used in the project is not available locally (fallback to Google Fonts may be available).'unsupported_node'— SVG, XD, and Figma. A source node/reference Figpea could not fully realize on open — a<use>whose target is missing, or an<image>whosehrefis unsupported (SVG), or an unsupported shape/element/gradient/fill (XD) — and dropped or emptied instead.'rasterized_fallback'— SVG, XD, and .fig. A vector construct Figpea could not reproduce faithfully, replaced with a simpler approximation — currently an unresolvable SVG<pattern>fill falling back to a solid fill, an XD pattern image fill that couldn't load falling back to flat grey, or a.figSTRETCH image fill missing its original dimensions.'layer_decode_failed'— PSD and .fig, severityerror. A layer whose decode threw during import — dropped entirely on PSD (the document still opens, but that layer's content is missing), kept as an empty placeholder group or degraded folder on .fig.'interaction_lost'— XD error. Unreadable prototype interaction.
Calibration Framing: Diagnostic coverage breadth reflects wiring order across decoders, not overall parser fidelity — it grows per format over time, and a format surfacing more codes today is not inspected more faithfully than one surfacing fewer. The Figma (.fig) decoder now routes its remaining degradation sites — instance post-processing, image paint, boolean combine mode and mask post-processing — onto the shared ctx.warn channel, retiring the partial-coverage caveat that used to apply to .fig. That closes a reporting gap, not a fidelity one. PDF maintains pure channel-based zero-coverage framing.
The editor's own Project Health Report reads from this same channel, so what your agent gets back is what a designer sees. Below, a real .fig file reports the image-fill case four times — the count field aggregates repeats rather than emitting one entry per layer:

const result = await figpea.report.diagnostics();
if (result.ok && result.value.length === 0) {
console.log("File opened clean — no detected soft issues");
} else if (result.ok) {
for (const diag of result.value) {
console.warn(`[${diag.code}] ${diag.message}`);
}
}
Error codes
The complete set of codes returned across the surface:
| Code | Meaning |
|---|---|
not_found | Resource (layer, project, active session, component) does not exist -- including an unselectable id (layer.select), no rendered transform widget (canvas.widgetGeometry), an unknown layer id (canvas.layerRect), no active session (report.summary / copyDeck / fonts / diagnostics), or unknown component/layer id in figpea.component.* lookups |
unsupported_style_key | Style patch includes a key not in the whitelist |
entitlement_required | User does not have the required claim for an export (including export.specBundle / export.tokens / export.originals / export.assetHarvest / export.figmaKit / export.contactSheet / export.flowPoster) |
unsupported_kind | layer.create's kind argument isn't a recognized layer kind |
not_flippable | The target layer's mutator doesn't implement flipping |
not_a_text_layer | The target layer isn't a text layer (setText) |
not_a_group | The target layer isn't a folder/group layer (ungroup) |
group_failed | Grouping the given selection produced no group |
open_failed | The file failed to decode/open (session.openFile) |
export_failed | An unexpected error was thrown during export encoding |
invalid_format | export.project's format isn't "pdf" / "zip" / "figpea" |
invalid_position | setPosition's { x, y } isn't both finite numbers |
invalid_transform | setTransform's matrix isn't an array of exactly 6 finite numbers |
unsupported_op | batch's op method is "batch" itself (recursion) or not a recognized layer.* method name |
timeout | The operation did not complete before the given deadline (session.waitForIdle) |
invalid_style_value | A whitelisted style key is present but its value is outside the enum it allows (patternScaleType/patternRepeat, malformed gradient with bad type/empty/out-of-range stops/non-string/empty color, out-of-enum case) |
invalid_image_source | The image source (url fetch or bytes decode) failed to resolve to a usable image (layer.setImageFill, layer.create) |
invalid_selector | The selector object includes an unknown key or an uncompilable regex pattern/flags (session.find) |
invalid_interaction | interaction.create validation failure: unsupported trigger/action, missing/invalid targetId, or non-page target |
invalid_mask_type | Invalid declared mask type (layer.setMask) |
invalid_constraint | Invalid responsive constraint or resize mode (layer.setConstraints, layer.setResizeMode) |
not_duplicable | The target layer has no parent and cannot be duplicated (layer.duplicate) |
invalid_params | Method parameter validation failure -- missing required fields, invalid types, or unknown keys (session.openFile); plus entry-time type validation across all methods: a supplied argument whose top-level type diverges from the method's declared schema returns this code before any implementation code runs, naming method, parameter, expected type, and received type (e.g. setSelection(): ids must be array (got string)) (REQ-770) |
invalid_geometry | A layer.create geometry prop (points/rwidth/rheight/rx/ry/x2/y2/pageWidth/pageHeight/path) is present but its value isn't finite, doesn't parse (path), or isn't the declared shape -- the call is rejected and nothing is created |
not_a_path_layer | The target layer isn't a path-shaped layer (layer.getRegionFills, layer.setRegionFill, layer.clearRegionFill) |
invalid_region_key | setRegionFill's regionKey doesn't match any region derivable from the layer's current geometry -- call getRegionFills again (layer.setRegionFill) |
figpea-mcp & Local Network Access (LNA) Guided Pairing
When using figpea-mcp to drive Figpea via AI agents, pairing is guided, consented, and transparent. Nothing connects when the page loads: the editor names the bridge it has been asked to attach to, and waits for you to agree.


-
Actionable
no_taberror: If a contract tool call is made while no editor tab is paired, the tool returns an explicitno_taberror response carrying the full pairing URL and human-readable next step:{"ok":false,"code":"no_tab","message":"No editor tab paired. Open this URL in your browser to connect an editor tab:","url":"https://editor.figpea.com/?agent=1&bridgePort=8080&bridgeToken=abc123token"}The same details reach your terminal the moment the server starts, so the URL exists before any tool is called. Running
npx figpea-mcp --port=17707prints:[figpea-mcp] bridge listening on 127.0.0.1:17707 [figpea-mcp] pairing token: 6b7c5237-6b57-476f-ae3f-61bbbfbabb7a [figpea-mcp] open this URL in a browser to connect an editor tab: [figpea-mcp] https://editor.figpea.com/?agent=1&bridgePort=17707&bridgeToken=6b7c5237-6b57-476f-ae3f-61bbbfbabb7a [figpea-mcp] (or call the open_editor tool from the connected MCP client)Both values are per-run: the token is a fresh UUID every time, and the port is assigned by the OS unless you pin it with
--port=<n>. All of this goes to stderr — stdout belongs to the MCP transport. -
Connect Consent Gate: To protect against unauthorized bridge attachments, the editor connects automatically only when your browser has already granted the local-network-access (LNA) permission for this origin — the permission system has already recorded your consent, so no second click is needed. On a first visit (LNA still
prompt), or when the permission isdeniedor not queryable, the editor displays an in-app notice and an explicit Connect button identifying the bridge port (ws://127.0.0.1:<port>); the socket opens only when you press it, and Chrome may show its own LNA prompt at that moment. Once connected, the notice unmounts and the editor displays a bottom-right pill indicator (Figpea agent connected · 127.0.0.1:<port>).
Pressing Connect is what opens the socket on the consent path. The notice then gives way to a pill in the same corner, naming the bridge this tab is attached to:

-
Local Network Access (LNA) Permission States:
prompt: The browser will ask for permission to reach the local network. This prompt is expected and safe. Choose Allow to continue.granted: The permission is remembered for this origin; the editor connects automatically on page load — no Connect click is needed (the permission explainer is likewise omitted from any notice).denied: Blocked by browser settings. Shows recovery instructions naming the browser setting to change, with Connect disabled.

-
Automated Browser & Agent Harness Pairing: Automated or headless browsers cannot answer native permission prompts. To pair in automated test or agent harness environments:
- Grant LNA permission via CDP (
Browser.grantPermissions), a pre-granted browser profile, or Chrome'sLocalNetworkAccessAllowedForUrlsenterprise policy. - With LNA pre-granted (CDP
Browser.grantPermissions, a pre-granted profile, or Chrome'sLocalNetworkAccessAllowedForUrlsenterprise policy), the editor connects automatically — no interaction needed. Otherwise, programmatically click the Connect button on the editor notice. - Localhost exemption: Editors served from
http://localhostare same-address-space and exempt from LNA entirely.
- Grant LNA permission via CDP (
Download the Skill
This is a transport-only bootstrap (REQ-705) you can drop straight into an agent's skills directory: it names how to reach the runtime — open the editor with ?agent=1, call describe(), then call figpea.SKILL() — and gives a working, copy-pasteable snippet per transport. The full craft-guidance reference (the authoring loop, recreating a reference faithfully, wiring interactions, the screenshot feedback loop, undo etiquette, entitlement boundaries, and the canonical Tier-1 recipe) lives in the runtime itself, not in this download, so it can never go stale — see figpea.SKILL(selector?) above.