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): setPosition and rotate mechanics realigned from raw matrix translate to visible bounds top-left and center pivot.
  • 1.0.0 (AGENT_API_CONTRACT_VERSION): MAJOR: layer.create shape-origin parameters (x, y for rects; cx, cy for ellipses) removed from creation schemas. Callers must pass position via setPosition(id, {x, y}) or transform.
  • 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 blendMode enum from 16 to 27 members (PSD blend-mode-engine compositor operators) — see the Blend mode: bullet under stylePatch below for the full accepted list.
  • 1.7.0 (AGENT_API_CONTRACT_VERSION): Completed .fig diagnostics 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 strokes bullet under stylePatch below.
  • 1.9.0 (AGENT_API_CONTRACT_VERSION): On-demand Google Fonts auto-resolution when fontFamily is set via create/stylePatch, session.waitForIdle() awaits in-flight font fetches, and truthful substituted field in report.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 additive skill key on describe()'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 present rwidth/rheight/rx/ry/x2/y2/pageWidth/pageHeight that isn't a finite number, or points not shaped as a non-empty array of {x,y} objects with finite x/y, is rejected with the new invalid_geometry error code, naming the offending prop and the expected shape — nothing is created. A successful create() (ok: true) now guarantees finite bounds.
  • 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 original 1.11.0 claim at post-PASS rebase — REQ-718 (above) landed its own site sync to main first from the same 1.10.0 baseline, so 1.12.0 is 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 reports bounds: null plus a boundsError diagnostic 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 present path value actually parses to usable SVG geometry — a malformed d string is rejected with the existing invalid_geometry error code (no new code), naming "path", before anything is created; an empty string and any valid d keep working exactly as before. Re-pointed from this branch's original 1.12.0 claim at post-PASS rebase — REQ-715 (above) landed its own site sync to main first from the same 1.11.0 baseline, so 1.13.0 is 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_layer and invalid_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/batch args) now number/array with coercion, batch contract {method, args} documented + invalid_params per-op, image kind=image url:dataURI creates, openFile({url,type}) honours type, vector surface expansion, plus Figma-parity selection (selectRelative, findMatching, same*As keys, auto-layout). All additive, envVersion stays 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's autoLayout field: mode: "none" | "horizontal" | "vertical" (clear a stack with mode: "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, negative itemSpacing, or a non-group (LayerType.folder) target returns the standard err() 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; see setAutoLayout below.)
  • 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 optional position?: { 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 via resolveInstancePlacementParent — 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 original 1.18.0 claim at post-PASS rebase — REQ-741 (above) landed its own site sync to main first from the same 1.17.0 baseline, so 1.19.0 is 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 undebuggable bridge_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), undefined arguments 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 pinned 1.22.0; re-pinned 2.1.0 at the pre-merge rebase because v3 mainline consumed 2.0.0 BREAKING 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 undebuggable bridge_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), undefined arguments 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 returns invalid_selector; unknown selector keys stay rejected; no new error code. (Provenance: originally pinned 1.21.0; re-pinned 2.2.0 at the merge-time rebase because v3 mainline consumed 1.21.0-2.1.0 (REQ-771/754/757) while this branch was in flight.) (REQ-758 — see the find section above for the full semantics).
  • 2.14.0 (AGENT_API_CONTRACT_VERSION): Re-seed public/figpea-agent/SKILL.md byte-identical to v3/docs/figpea-agent/SKILL.md and sync public/llms.txt contract 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 that setImageFill can target). (REQ-865).
  • 2.27.0 (AGENT_API_CONTRACT_VERSION): Additive — new figpea.layer.setLocalPosition(id, {x, y}) parent-local counterpart to setPosition for page-local intent on auto-placed pages; layer.create and layer.setPosition docs now warn about auto-placement trap and cross-reference setLocalPosition. (REQ-1042).
  • 2.28.0 (AGENT_API_CONTRACT_VERSION): Patch — restores rect x/y and ellipse cx/cy as construction-only defaults folded into transform to unblock callers pending coordinated setPosition migration; no new method, surface hash 22fa29c780d3d06cc3a5ba15ac182d653e553687ae27944977a60e9714444407. (Janitor 2026-09-01).
  • 2.29.0 (AGENT_API_CONTRACT_VERSION): Additive — layer.stylePatch/layerTree()/layerById() style surface gains fillOpacity (Photoshop fill opacity — scales only the layer's own fill/pixels, never its effects; alongside opacity) — see the Transparency: bullet under stylePatch below. (v3 REQ-529; Janitor 2026-09-04 successor-sync).
  • 2.30.0 (AGENT_API_CONTRACT_VERSION): Changed — layer.setPosition and layer.rotate operate on a layer's visible box (its bounds) rather than its raw transform translate. Coordinate space: setPosition is world coordinates (not parent-local) — contrast create's transform which is parent-local; setPosition also accepts space:"local" for parent-local placement (same as setLocalPosition); create now 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 resolve missingLocally through one shared predicate, so user-uploaded, built-in/bundled, and Google-manifest-matched families all read as available and both surfaces always agree; summary/fonts docs 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 present sweep selects {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+sweep is 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 with size scaling to rendered pixels; unknown names rejected with invalid_params. (v3 REQ-817; Janitor 2026-09-07 successor-sync).
  • 2.34.0 (AGENT_API_CONTRACT_VERSION): Doc-only — session.waitForIdle timeout documented 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: stylePatch on a page id routes fill/fillType through page-fill validation, setPageFill rejects unparseable colors with invalid_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's invalid_transform rejection appends Did you mean style.<key>? for offenders that are known style keys, and layer.stylePatch's unsupported_style_key rejection appends a create() top-level pointer for known create-level props; layer.create doc states the placement rule with a worked style.fontFamily text 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.setViewport gains the {x, y, width, height} frame-a-rect form (exact contain, no padding) alongside {x, y, zoom} (unchanged); pre-write validation returns invalid_params with the camera untouched instead of NaN-poisoning it, and getViewport reports degenerate camera state as invalid_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 tabStops aligned-stop style key ({position, align: left|center|right}[]) on stylePatch/create/serialize, rendered by shared tab-stop layout across measure/canvas-draw/SVG-export; malformed stops rejected with invalid_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: matches figpea.version and tracks the agent API surface version.
  • envVersion: the protocol envelope version (1 = legacy bare describe(), 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:

ConsumerSurfaceWhy
Chrome DevTools MCP / Playwright MCPfigpea.SKILL()already holds the page; zero network hop
figpea-mcpfigpea_skill MCP tool, sourced from GET /agent/skill.mdhas no page — must work before any tab pairs
Any HTTP clientGET <editor-origin>/agent/skill.mdno 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.

Animated WebP showing figpea.layer.stylePatch updating a text layer's font family to Playfair Display, awaiting font load with waitForIdle, and rendering the typeface on canvas.

End-state still showing the text layer rendered in Playfair Display serif face on the Figpea canvas after stylePatch and waitForIdle.

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? (default 100), rheight? (default 50)
  • "ellipse": rx? (default 50), ry? (default 50)
  • "line": x2? (default 100), y2? (default 0) -- 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 via transform or setPosition after creation
  • "folder": no kind-specific fields
  • "page": pageWidth? (default 300), pageHeight? (default 150); name sets the page title. Pages are auto-placed clear of existing pages when no explicit transform is given.
  • "image": url? or bytes? (exactly one), optional mimeType?, plus rwidth?/rheight? like "rect", plus optional patternScaleType?: "fit" | "cover" | "fill" | "free" (default "cover") and patternRepeat?: "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".

  • modenone (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 / counterSizingfixed (container size authoritative) vs resize_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: gradienttype: "linear" | "radial" | "conic", required non-empty stops: [{ offset, color }], optional per-type geometry (linearX1/Y1/X2/Y2 for linear; innerCircleX/Y/R + outterCircleX/Y/R for radial; startAngle/centerX/centerY for conic) and optional transform matrix (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); both type and stops must be supplied in the same patch as fillType: "gradient" (setting fillType alone 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 }reverse flips the gradient's stop order at render time), patternOverlay ({ enabled: boolean, pattern?: object, scale?: number, origin?: { x: number, y: number }, blendMode?: string, opacity?: number }pattern is the decoded PSD pattern bitmap set by the file's own decoder; it is not independently authorable through this call, so a patternOverlay patch is effectively limited to toggling enabled/scale/origin/blendMode/opacity on 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 of spread; source picks 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 at opacity)
  • 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)

Figpea editor canvas rendering text layers in Inter and Playfair Display created via the Agent API, with DevTools console showing font loading and report.fonts() status.

  • Text-frame sizing: fixWidth / fixHeight (numbers; omit for auto-width / auto-height). valign ("top" | "center" | "bottom") — only visible when fixHeight is 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 of url/bytes is required):
    • url?: string — HTTP(S) URL to an image.
    • bytes?: ArrayBuffer | Uint8Array | number[] — Raw image bytes.
    • mimeType?: string — used when constructing from bytes.
    • patternScaleType?: "fit" | "cover" | "fill" | "free" (default "cover") and patternRepeat?: "no-repeat" | "repeat" | "repeat-x" | "repeat-y" (default "no-repeat") — same enums as stylePatch/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 (withUndoBatch path), and the camera does not move. Supplying non-finite x/y rejects with the standard err() shape before any layer is created.
  • Without position (omit the argument): the call is byte-identical to the pre-REQ behavior — same target page via resolveInstancePlacementParent (the master's own page), same cascade offset via placedInstanceCount + instancePlacementTransform, same selection via onChooseSingleLayer, 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 as addState).

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> whose href is 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 .fig STRETCH image fill missing its original dimensions.
  • 'layer_decode_failed'PSD and .fig, severity error. 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:

Figpea's Project Health Report dialog for the imported Figma file ios15kit, showing an Import issues row that reads "An image fill is missing its original size — shown at a default position instead" with a ×4 occurrence badge

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:

CodeMeaning
not_foundResource (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_keyStyle patch includes a key not in the whitelist
entitlement_requiredUser 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_kindlayer.create's kind argument isn't a recognized layer kind
not_flippableThe target layer's mutator doesn't implement flipping
not_a_text_layerThe target layer isn't a text layer (setText)
not_a_groupThe target layer isn't a folder/group layer (ungroup)
group_failedGrouping the given selection produced no group
open_failedThe file failed to decode/open (session.openFile)
export_failedAn unexpected error was thrown during export encoding
invalid_formatexport.project's format isn't "pdf" / "zip" / "figpea"
invalid_positionsetPosition's { x, y } isn't both finite numbers
invalid_transformsetTransform's matrix isn't an array of exactly 6 finite numbers
unsupported_opbatch's op method is "batch" itself (recursion) or not a recognized layer.* method name
timeoutThe operation did not complete before the given deadline (session.waitForIdle)
invalid_style_valueA 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_sourceThe image source (url fetch or bytes decode) failed to resolve to a usable image (layer.setImageFill, layer.create)
invalid_selectorThe selector object includes an unknown key or an uncompilable regex pattern/flags (session.find)
invalid_interactioninteraction.create validation failure: unsupported trigger/action, missing/invalid targetId, or non-page target
invalid_mask_typeInvalid declared mask type (layer.setMask)
invalid_constraintInvalid responsive constraint or resize mode (layer.setConstraints, layer.setResizeMode)
not_duplicableThe target layer has no parent and cannot be duplicated (layer.duplicate)
invalid_paramsMethod 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_geometryA 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_layerThe target layer isn't a path-shaped layer (layer.getRegionFills, layer.setRegionFill, layer.clearRegionFill)
invalid_region_keysetRegionFill'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.

Animated capture of the pairing sequence in Figpea's editor: the "Connect to figpea-mcp?" notice showing the bridge address ws://127.0.0.1:17706, the Connect button darkening as it is activated, the notice switching to a waiting-for-permission state with Connect disabled, and finally an emerald "Figpea agent connected · 127.0.0.1:17706" pill in the lower-right corner

End state of the pairing sequence: Figpea's editor paired with the local figpea-mcp bridge, the notice gone and an emerald "Figpea agent connected · 127.0.0.1:17706" pill in the lower-right corner

  1. Actionable no_tab error: If a contract tool call is made while no editor tab is paired, the tool returns an explicit no_tab error 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=17707 prints:

    [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.

  2. 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 is denied or 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>).

    Figpea's editor with the "Connect to figpea-mcp?" notice in the lower-right corner, showing the bridge address ws://127.0.0.1:17706, an explainer about the browser's local-network permission, and an enabled Connect button

    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:

    Figpea's editor after pairing, with the connect notice gone and an emerald "Figpea agent connected · 127.0.0.1:17706" pill in the lower-right corner

  3. 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.

    The same Figpea connect notice in its denied state: a warning that local network access is blocked for this site, recovery steps pointing at the browser's Site settings, and a greyed-out Connect button

  4. 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's LocalNetworkAccessAllowedForUrls enterprise policy.
    • With LNA pre-granted (CDP Browser.grantPermissions, a pre-granted profile, or Chrome's LocalNetworkAccessAllowedForUrls enterprise policy), the editor connects automatically — no interaction needed. Otherwise, programmatically click the Connect button on the editor notice.
    • Localhost exemption: Editors served from http://localhost are same-address-space and exempt from LNA entirely.

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.

Download the Skill (SKILL.md)