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 5.4.0, fetched at build time from the deployed editor — so this stamp always states what the editor is actually serving, and the history below documents every version. 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, x: 40, y: 40, rwidth: 160, rheight: 90,
    style: { fill: "#4f46e5", cornerRadius: 8 },
  }), "create(rect)");
  const label = must(await figpea.layer.create("text", {
    parentId: page.id, x: 40, y: 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: 5.4.0.

Breaking changes by contract version

Rows below that sit above the stamp at the top of this page describe versions that are newer than the currently deployed editor and ship with its next release. The rows are hand-written; only the stamp above is generated.

  • 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.)
  • 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 the public SKILL.md bootstrap artifact byte-identical to the v3 SKILL.md source 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).
  • 2.39.0 (AGENT_API_CONTRACT_VERSION): Doc-only — letterSpacing/lineHeight units pinned and published: Tracking in thousandths of em (default 0), Leading in px (must be > 0); wire semantics unchanged. (v3 REQ-1087; Janitor 2026-09-12 successor-sync).
  • 2.40.0 (AGENT_API_CONTRACT_VERSION): Additive — two new figpea.layer.* methods on the existing layer group (no new group): markSheet(id)/unmarkSheet(id) for the sheet registry. (v3 REQ-1133; REQ-1019 T5 successor-sync).
  • 2.41.0 (AGENT_API_CONTRACT_VERSION): Doc-only — agent text-wrap contract published: fixWidth gains the wrap-trigger doc on the stylePatch/create('text')/measureText describe schemas; wire semantics unchanged. (v3 REQ-1109; Janitor 2026-09-13 successor-sync).
  • 2.42.0 (AGENT_API_CONTRACT_VERSION): Additive — figpea.layer.setAutoLayout accepts page targets in addition to groups, with the same validation/merge/no-op/single-undo semantics; targets that are neither group nor page still return not_a_group. No new method, no new error code. (v3 REQ-1150; Janitor 2026-09-21 successor-sync).
  • 2.43.0 (AGENT_API_CONTRACT_VERSION): Additive — style-key widening for two decoded PSD effects: bevelEmboss (ebbl: highlight/shadow color+opacity+blend, inner style, blur/soften, light angle) and satin (ChFX: color, blend, opacity, angle/distance/blur, invert) join STYLE_PATCH_WHITELIST — readable via layerTree()/layerById() under style, writable via stylePatch/create, validated object-with-boolean-enabled like the other effects. Style surface grows to the full 46-key round-trip. No new method, no new error code. (v3 REQ-1124; Janitor 2026-09-22 successor-sync).
  • 2.44.0 (AGENT_API_CONTRACT_VERSION): Additive — gradient fills gain the optional radialMetric key ('ellipse' default, per-axis radii, or 'circle', short-side radius): stamped ONLY by the PSD decoder on radial gradients (Photoshop truth is circular); authored fills and SVG/Figma/XD imports never carry it, so their ellipse behaviour is untouched; unknown values rejected with invalid_style_value. No new method, no new error code. (v3 REQ-1184; Janitor 2026-09-23 successor-sync).
  • 2.45.0 (AGENT_API_CONTRACT_VERSION): Additive — optional per-sheet scope { sheetIds } on the six whole-project export/report methods (export.specBundle/tokens/contactSheet/flowPoster, report.summary/copyDeck): absent scopes the whole file exactly as today, stale ids are skipped, all-stale succeeds with an empty enumeration; report.fonts() stays no-arg global. No new method, no new error code. (v3 REQ-1163; Janitor 2026-09-25 successor-sync).
  • 2.46.0 (AGENT_API_CONTRACT_VERSION): Additive — new figpea.font.* group (listAliases/getAlias/setAlias/clearAlias): read and write the stored missing→replacement font-alias map over the editor's alias store. setAlias validates the replacement through the existing library chain (unresolvable family rejects with the new unknown_font code naming it) and re-resolves so canvas text repaints in the replacement in-session while layers keep the original name; clearAlias is idempotent and restores default substitution on next open. All four methods free, no entitlement gating. (v3 REQ-1200).
  • 2.48.0 (AGENT_API_CONTRACT_VERSION): Documentation only — no behavior change, no new method, no new error code. layer.create's reference now states the per-kind x/y rule it previously only implied: x/y are not common props, and of all kinds only "rect" and "image" declare them, so create("text", {x, y, text}) is rejected with invalid_transform and nothing is created (the same holds for page/path/icon/arc/folder/polygon, where ellipse takes cx/cy and line takes x2/y2). The two exact ways to position a layer are transform: [1,0,0,1,x,y] in the same call (parent-local e,f), or setPosition(id, {x, y}) / setPosition(id, {x, y, space:"local"}) after creating it. (v3 REQ-1302; REQ-1302 T1 successor-sync.)
  • 2.47.0 (AGENT_API_CONTRACT_VERSION): Additive — dropShadow/innerShadow/objectBlur effect objects gain the optional type discriminator in the stylePatch/create describe shapes (single-valued enum per key); the store stamps it when absent, so typeless and explicit-type patches store byte-identical objects, and a mismatched type rejects with the existing invalid_style_value. No new method, no new error code. (v3 REQ-1105; REQ-1165 T5 successor-sync).
  • 2.49.0 (AGENT_API_CONTRACT_VERSION): Documentation only — no behavior change, no new method, no new error code. The figpea.component undo claims are corrected to the measured behaviour after every affected method was measured rather than re-derived by analogy. Behavior change for callers who read the old wording: figpea.component.placeInstance IS undoable on both paths — a single figpea.history.undo() fully reverses it whether or not you passed position (the no-position path's step comes from the parent addChild's children mutation channel). The previous "NOT undoable" wording was an overcorrection of an earlier, also-wrong "one undo step", re-asserted in three earlier releases; workarounds built on it are no longer needed. figpea.component.addState keeps its NOT-undoable claim (re-measured at 0 undo steps; only its rationale changed). figpea.component.removeState is now split by path: removing the active state records one undo step that only reverses the activeStateId fallback and does not restore the deleted state — treat it as one-way — while removing a non-active state records nothing. (v3 REQ-270; REQ-270 T7 successor-sync.)
  • 2.50.0 (AGENT_API_CONTRACT_VERSION): Additive -- session.openFile now returns a specific code when a .fp archive is damaged, instead of the generic open_failed every decode failure shares: fp_entry_damaged when an archive entry's bytes disagree with the archive's own directory (a failed checksum, a declared size that does not match, or a bit-stream that does not decompress), and fp_entry_missing when a required entry (main.fpe / repo.json) is absent -- what a truncated download or a partial copy looks like. The accompanying message names the entry, the size the archive declared for it, and the size that actually came out. Both codes are returned for those two cases only: an unsupported format, an empty archive, a network failure and every other reason keep returning open_failed with its existing message. The .fp writer is untouched -- this is purely about making an existing failure legible. No new method, no new group. (v3 REQ-1304; Janitor 2026-09-29 successor-sync.)
  • 2.51.0 (AGENT_API_CONTRACT_VERSION): Documentation only for the contract surface -- no new method, no new group, no new public code, no entitlement change. The three Photoshop bevel/emboss decode dispositions are now named in report.diagnostics(): a recognised non-inner bevel style, an unrecognised bevel style, and an unrecognised bevel technique. Each still renders and keeps its bounds, painted with the profile the renderer covers -- so this is a reported approximation, not lost content. Two visible consequences: the three cases appear in the diagnostics channel at severity: 'warning' under the existing rasterized_fallback code (no new code -- REQ-437's rule), and rasterized_fallback's scope widens from "SVG, XD and .fig" to include PSD. Because a documented approximation is not a fault, these no longer sit on the internal error channel beside genuine failures, so an err in your console now means something actually went wrong. (v3 REQ-1362; Janitor 2026-09-30 successor-sync.)
  • 2.52.0 (AGENT_API_CONTRACT_VERSION): Additive, behaviour change -- no new method, no new group, no new error code, no ENV_VERSION or entitlement change. layer.create now positions a layer in the same call for six more kinds: x/y are declared in the per-kind schema of "text", "path", "line", "icon", "arc" and "folder", joining "rect"/"image", and one create(kind, {x, y, ...}) lands the new layer's bounds top-left exactly on (x, y) in parent-local coordinates -- the same numbers on the same basis as setLocalPosition(id, {x, y}), so an agent no longer pays a second bridge round trip per non-rect element. Before this, only rect and image could be positioned in the call that created them. Three properties are decisions rather than details. It is the visible box that moves, not the transform's e,f: folding transform[4] = x is wrong for any kind whose local geometry origin is not its bounds top-left (a path whose d starts at 10,10, a glyph scaled by size/24, an arc centred on its local origin), so placement is bounds-relative and happens after the layer is attached. The six new entries carry no default -- default: 0 would be a false claim about where those geometries start, so "no x/y supplied" honestly reads as "no placement applied"; a missing axis is still 0, matching rect/image, and an explicit transform still wins outright. "rect"/"image" are numerically unchanged (their factories already folded x/y in at construction, so the new step is skipped for them -- applying both would double-pan to (2x, 2y)), and that partition is published as CREATE_FACTORY_ORIGIN_KINDS. "ellipse", "polygon" and "page" keep rejecting x/y with the existing invalid_transform: ellipse has its own centre-based cx/cy, polygon.points is out of scope, and page placement is the auto-placement rule x/y would silently disable. One figpea.layer.create remains one undo step -- the placement runs inside the history bucket create already has open, deliberately not through withLayerTransaction, which would close a second bucket mid-call. create's result shape, types.d.ts, move/setPosition/setLocalPosition/setTransform/batch and every factory are untouched. (v3 REQ-1281; Janitor 2026-09-30 successor-sync.)
  • 2.52.1 (AGENT_API_CONTRACT_VERSION): Patch, behaviour change to one error message -- no new method, no new group, no new error code, and the published manifest is byte-identical, which is why this is a patch rather than a minor. session.openFile's open_failed message now names the missing extension instead of blaming corruption. A bridge-served .fp whose extension travelled only as a ?path= query parameter arrived with a dotless name, no decoder claimed it, and the agent was told "file may be corrupt, unsupported, or empty" about a byte-for-byte valid archive -- the one moment that message is read is the moment it is maximally wrong. The message now names the extension found on the path and the name:/type: value that resolves it (the type: clause is resolved from the editor's own decoder registry and omitted when no decoder claims the extension, since the name: form always works). It fires only when the failure reason is one of the two that mean "no decoder claimed this" (unsupported-format, archive-no-supported-content) AND the name carries no extension AND a local path still says which extension it was -- so a genuinely corrupt file keeps the decode-failure wording that says "corrupt", and a remote-url open is untouched. The code is still open_failed and ERROR_CODE_CATALOG is unchanged. The relay half (figpea-mcp preserving the caller's stem plus the path's extension) is the package's own change. (v3 REQ-1283; Janitor 2026-09-30 successor-sync.)
  • 2.53.0 (AGENT_API_CONTRACT_VERSION): Additive, behaviour change -- no new method, no new group, no new error code, no ENV_VERSION or entitlement change, no types.d.ts change (the Diagnostic record already carried its optional layerId). report.diagnostics gains a read-time RENDER report: every layer whose painted extent falls under one device pixel of the canvas being drawn is reported at severity: 'warning' under the existing public rasterized_fallback code, one entry per layer, naming the thin axis, the authored design-px extent and the device extent it actually covers -- so an agent can ask whether the renderer will substitute a layer's geometry instead of discovering it by measuring pixels. The internal code render_sub_pixel_geometry maps onto that existing public code, so per REQ-437's rule no new public code is minted and the generated MCP tool surface is byte-identical. The minimum-geometry guarantee itself -- one device pixel of the canvas being drawn, at the scale being drawn, snapped up and never dropped -- is now part of this document and of report.diagnostics's descriptor doc. The entries are evaluated at the default export scale of 1, so they are camera-independent by construction: a diagnostic that drifted with the editor camera would repeat the very defect it reports. (v3 REQ-1297; Janitor 2026-09-30 successor-sync.)
  • 2.54.0 (AGENT_API_CONTRACT_VERSION): Additive, behaviour change -- no new method, no new group, no new error code, no ENV_VERSION or entitlement change, no types.d.ts change. figpea.component.addState and figpea.component.removeState are both captured as exactly one undo step again, and one figpea.history.undo() fully reverses either gesture. Before this, addState recorded zero steps, removeState of a non-active state recorded zero, and removeState of the active state recorded one step that restored only the activeStateId fallback while leaving the state deleted -- so the Cmd+Z a user pressed after either deletion landed on whatever edit came before it, and the deletion itself was unrecoverable. Any workaround, or "treat this as one-way" assumption, built on the old wording is now wrong. The cause was one seam, not two symptoms: the states array was written raw (push/splice) and a removed state layer was hard-deleted, so neither call entered the mutation channel the history stack reads, and the deleted layer was unreachable by id at replay time. Both writes now route through that channel, the removal unlinks the state layer instead of deleting it (so undo can resolve it by id again), and the two controller methods run inside one undo batch -- a gesture stays one step even though it performs two keyed writes. Deliberately unchanged: adding or removing whole layers is still not captured by the history stack, so this is a claim about the states array and the active pointer -- what a user pressing Cmd+Z observes -- not about the detached layer object. The corrected semantics are published in the addState and removeState entries below and supersede the undo claims made in the 2.49.0 history row. (v3 REQ-1361; Janitor 2026-10-01 successor-sync.)
  • 2.55.0 (AGENT_API_CONTRACT_VERSION): Additive, behaviour change -- no new method, no new group, no new error code, no ENV_VERSION or entitlement change, no types.d.ts change. The text-typography contract stops advertising a vocabulary it does not enforce and stops accepting one it cannot canonicalise. style.fontWeight accepts the two standard weight numbers '400' and '700', inserted at their numeric slots beside the keywords they are the same weight as, so the nine pre-existing values keep their shipped order; both spellings are accepted, as a string or a number, and splitStylePatch canonicalizes every accepted spelling to the CSS keyword so the layer always stores 'normal'/'bold' rather than the number (a stored '400' missed the font resolver's exact weight match and silently resolved to the wrong typeface). fontWeight also gains its first-ever value check: an out-of-vocabulary weight ('reguar', 450, 0, -100) is rejected with invalid_style_value naming the whole accepted list instead of being stored verbatim and mis-rendering. lineHeight refuses the multiplier band: a value below 1.5 is rejected with invalid_style_value naming px, stating that lineHeight is absolute px and never a multiplier, and giving the worked conversion lineHeight = fontSize × multiplier — lineHeight: 1.0 used to be stored as a PIXEL leading, stacking every line onto the one above it while the design still looked finished. 400/700 are numeric spellings of normal/bold, and no other key, renderer or shipped value changes. This row is the successor-sync the site's history was missing: mainline's AGENT_API_CONTRACT_VERSION was 2.54.0 while v3 had already published 2.55.0, so agentContractSync reported a version drift before REQ-1311 landed (REQ-1308).
  • 2.56.0 (AGENT_API_CONTRACT_VERSION): Additive, behaviour change -- no new method, no new group, no new error code, no ENV_VERSION or entitlement change. The Photoshop bevel/emboss TECHNIQUE key is reconciled against the format rather than guessed. bevelEmboss.technique gains the two shipped values preciseMatte (PrBL) and slopeLimitMatte (Slmt), so a file that uses them now round-trips as authored instead of falling back to a matte approximation; its NON-PAINTING render contract (a technique that paints nothing must not be treated as lost content) is documented, and the substitution is published as a rasterized_fallback warning in report.diagnostics rather than being silent. report.diagnostics's descriptor doc string feeds the manifest hash, so the fingerprint entry moves with it (REQ-1286). This row is a successor-sync in its own right: it landed while REQ-1311's branch was in review, which is why this site moves 2.54.0 -> 2.57.0 in one step and carries all three rows together — REQ-1308 for 2.55.0, REQ-1286 for this row, REQ-1311 for 2.57.0.
  • 2.57.0 (AGENT_API_CONTRACT_VERSION): Additive, behaviour change -- no new method, no new group, no new error code, no ENV_VERSION or entitlement change. layer.create("icon") gains a dedicated glyph prop, so an icon can carry a real layer name. name was overloaded: read once as the glyph selector in CREATE_KIND_SCHEMA.icon and again as the generic layer name in the common fields written by create() for every kind. Because the allowed-key set is a UNION, the overload was invisible to validation and the two readers simply fought -- create("icon", {name: "hero-search-icon"}) was rejected as an unknown glyph, so an icon could never carry a semantic name and two same-glyph icons were indistinguishable to find({name}). glyph is the new, unambiguous selector (same curated set, same "heart" default; its enum at params.props.byKind.icon.glyph.enum is the catalog create() documents). name is now the layer's own name and remains a glyph alias only for back-compat -- create("icon", {name: "search"}) still selects the search glyph exactly as before. A non-glyph name is a layer name and leaves the glyph at its default; passing glyph together with a glyph-valued name is rejected with invalid_params naming both props. The new glyph field is published in byKind, so the manifest hash moves and the fingerprint entry moves with it (REQ-1284). This row is a successor-sync: it merged while REQ-1311's branch was in review, which is why this site moves 2.54.0 -> 2.58.0 in one step and carries four rows together -- REQ-1308 for 2.55.0, REQ-1286 for 2.56.0, REQ-1284 for 2.57.0, REQ-1311 for 2.58.0.
  • 2.58.0 (AGENT_API_CONTRACT_VERSION): Documentation only — no behavior change, no new method, no new error code, no ENV_VERSION or entitlement change. The published create() reference now names every kind the schema publishes, and its completeness claim is true. (1) The kind inventory is complete. The create() section's kind sentence named nine kinds while describe("layer.create").params.kind.enum publishes eleven — "icon" and "arc" have shipped for some time — "arc"'s ring mode landed in 2.32.0 and "icon"'s curated 30-glyph set in 2.33.0, so a reader met with a nine-kind list concluded they did not exist and paid a describe() round trip to learn otherwise. The sentence now names all eleven, in schema order, and each has its own per-kind bullet: "icon" states the curated-glyph glyph contract (default "heart", the authoritative list at params.props.byKind.icon.glyph.enum, invalid_params naming the offense on an unknown glyph, size, and x/y placement) and "arc" states both modes (the legacy degree donut {r, startAngle, endAngle, thickness} and the sweep ring mode {rx, ry, startAngle, sweep}, with endAngle + sweep rejected). (2) The exhaustiveness sentence is now true. "image" declares eleven fields and its bullet documented nine — filePath? and deferred? are now documented alongside the rest. The sentence is kept rather than softened into a scope disclaimer, and req-1311-create-kind-inventory.test.ts (v3) plus req-1311-agent-api-kind-inventory.test.ts (site) now check each doc's kind set against CREATE_KIND_SCHEMA, so a future kind or field fails those suites naming itself instead of going stale the way this did. Also: the Position props paragraph above is the narrowed mirror of the published create.doc — it said "declared only in the per-kind schema of 'rect' and 'image' … and no other kind declares a position prop", which has been false for six kinds since REQ-1281; it now names all eight x/y kinds, says top-level rather than top-left (with "polygon"'s points called out as {x, y} vertex data nested one level down, not a placement), and its text/folder/line bullets no longer contradict the rule printed beneath them. No behaviour changed: createSchema.ts and layer.impl.ts are byte-identical, so "ellipse"/"polygon"/"page" still reject x/y with invalid_transform and create nothing, and REQ-1302's assertions pass unmodified (REQ-1311).
  • 2.59.0 (AGENT_API_CONTRACT_VERSION): Additive, behaviour change -- no new method, no new group, no new error code, no ENV_VERSION or entitlement change. layer.setPosition and layer.setLocalPosition accept a one-axis move, so setPosition(id, { y: 936, space: "local" }) is legal. Before this, supplying a single axis came back invalid_position with the message "setPosition(): {x,y} must be finite numbers (got {\"space\":\"local\",\"y\":936})" -- a rejection that accused the ONE finite number in the payload of being non-finite, because the validator conflated an absent axis with an invalid one. Because layer.batch is all-or-nothing, one such op rolled back a whole batch. Either axis may now be omitted and the omitted one is preserved, solved from the layer's world bounds and the parent matrix (x = (b.left - c*Y - tx)/a) rather than read out of the parent-local rect: a local-rect read only zeroes the omitted delta when the parent's linear terms line up, so on a rotated parent it would pan the layer by c*dY and on a mirrored one by a*(L-R), sliding the layer in the world axis the caller never named. The preserved promise is the one published in WORLD bounds -- what both methods return and what layerById/layerTree read back -- which is a DIFFERENT number from the parent-local coordinate on a rotated or mirrored parent. Sending both axes means no solve at all and the existing branches run byte-identically. Still rejected: {}, and any supplied non-finite axis, each with a message now naming the axis and the value it got instead of asserting a finite number is not finite; setPosition(id, { x: 1, space: "bogus" }) is now invalid_params naming space rather than the old wrong invalid_position. New rejection: a parent rotated to within 1e-12 of +/-90 degrees (where Math.cos(pi/2) is 6.12e-17, so the axes are numerically swapped and no one-axis move is admissible) is refused with invalid_position telling the caller to send both axes, rather than silently displacing the layer by ~1e18 world units. pos.shape.x/pos.shape.y become required: false on both methods -- they are published verbatim into /agent/contract.json, which is what figpea-mcp generates its tool schemas from, so leaving them required would have every MCP client refuse the call the implementation accepts. layer.create's missing-axis-is-0 rule is deliberately not unified with this one: a layer that does not exist yet has no current position to preserve. (v3 REQ-1452; Janitor 2026-10-02 successor-sync.)
  • 2.59.1 (AGENT_API_CONTRACT_VERSION): Patch, behaviour change to four methods' call shape -- no new method, no new group, no new error code, and the published manifest is byte-identical, which is why this is a patch rather than a minor. Four implementations had a declared parameter list their own descriptors contradicted: canvas.addGuide(opts), canvas.removeGuide(opts), canvas.addDimension(opts) and export.assetHarvest(input) each took ONE container, while describe() publishes two top-level params / one scalar id / two point params / one scalar preset, and tryUnwrapWrapper (registry.ts) expands the wrapper an agent actually sends into positional order before dispatch. So the documented call arrived as opts === "horizontal" (or undefined) and the four methods were unreachable through their own published shape: addGuide/addDimension were rejected for VALID input, export.project()/addDimension()/addGuide() rejected with a raw TypeError, removeGuide(id) answered ok: true having removed nothing (its filter compared every guide against undefined), and assetHarvest({ preset: "ios" }) silently exported the web preset. The five impl signatures now match the published contract positionally and each guard names the method, the parameter and the expected shape; export.project's absent-input guard is deliberately null/undefined-only so project("pdf") and project({}) keep their exact existing answers, and assetHarvest's preset check stays behind its export.zip entitlement gate so an unentitled caller still sees entitlement_required. No descriptor moved, so no doc/result/params string moved and hashManifest(buildManifest()) does not move. The class is closed by two standing guards rather than by this list: one enumerates the LIVE surface at run time and asserts no registered method rejects on a no-argument call, and one checks descriptor-vs-impl wire shape across all nine groups on two rules -- arity, and the arity-neutral container-vs-scalar-wire case that fails SILENTLY (arity alone finds two of the four; the other two return ok: true and do the wrong thing). (v3 REQ-1381; Janitor 2026-10-02 successor-sync.)
  • 2.60.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch -- layer.batch's doc string and the ERROR_CODE_CATALOG entry both feed the manifest hash, so the published manifest is not byte-identical and the fingerprint entry moved), behaviour change plus a new reserved describe() key -- no new method, no new group, no new error code, no ENV_VERSION or entitlement change. The per-call argument budget is now disclosed, and the refusal that enforces it names the real cause. The harness arg-size guard tested JSON.stringify(args).length > 22_000 -- a condition that says nothing about images -- while its remediation was written entirely for images, so every oversized call was told to "stage bytes on a local CORS origin (http://127.0.0.1:... with ACAO:) or reuse projectImages + placeProjectImage instead of a data: URI"*. One design run's ~89-op layer.batch of rect/path/text layers -- no image, no base64 -- tripped it, and the run spent round trips chasing a phantom image problem before splitting the batch; the same run hit it twice more (a 405-id delete batch, ~24 KB). Two defects, one cause: the 22_000 threshold was a private literal -- one place in code, prose in three -- so nothing an agent could read published it and the boundary was discoverable only by being refused; and the remedy was unconditional. (1) The threshold is now one exported constant read by the guard, both message shapes and describe(), so the disclosure cannot drift from the behaviour. (2) The refusal branches on whether the payload actually carries image bytes: an image-free payload is told the measured size, the limit, that nothing was applied, and to split the call -- with no CORS/data-URI clause; an image-bearing payload gets all of that plus the staging sentence, which is correct advice there and is not lost. (3) The arg_size_exceeded entry in the Error codes table above duplicated that image-only text verbatim and has been added here in its corrected form; the table previously claimed a complete set of codes while omitting this one entirely. (4) describe() publishes limits -- {"argsChars": 22000} on the bare compact index beside version/errorCodes/skill, with a describe("limits") drill selector mirroring describe("errorCodes") -- so an agent can size a batch up front instead of discovering the wall; see Per-call argument budget. The key is argsChars, not argsBytes: the guard measures UTF-16 code units of JSON.stringify(args), and publishing that count as "bytes" would understate a non-ASCII payload's real wire size, steering an agent into the very truncation the guard exists to prevent. (5) layer.batch's own entry now states its budget and the split recipe, since that is the method where an agent actually hits it. The threshold value, the firing condition and the error code are unchanged -- this release changes what the refusal says and what an agent can read, never whether it fires; moving the cap is out of scope. The compact index gained a key but the protocol envelope did not, so ENV_VERSION stays 2. (v3 REQ-1432.)
  • 2.61.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch -- eleven new doc strings plus create's result entry feed the manifest hash, so the published manifest is not byte-identical and the fingerprint entry moved), one documentation change plus one warn-only behaviour change -- no new method, no new group, no new error code, no ENV_VERSION or entitlement change. (1) The numeric style props now name their units. The three unintuitive units -- letterSpacing (thousandths of em), lineHeight (absolute px) and fixWidth (wrap width in px) -- were already documented, but the eleven remaining numeric entries of STYLE_PATCH_SHAPE shipped as bare {type:'number', required:false}: strokeWidth, opacity, fillOpacity, cornerRadius, fontSize, dashArray, dashOffset, miterLimit, fixHeight, tabWidth, tabs. Each now carries a doc naming its unit outright, including the dimensionless ones -- opacity/fillOpacity are 0..1 fractions and say so, miterLimit is a ratio of strokeWidth and says so. report.measureText is the only surface that hand-copies the shape and therefore the only one that could have drifted; its three unit docs are now the same exported constants, byte-identical to the strings REQ-1087 and REQ-1308 shipped. layer.create's method doc also gains the no-fixWidth point-text/clip clause, appended to its last existing sentence rather than replacing it. (2) A measured-leading warning, deliberately NOT a rejection. A text layer created with a style.lineHeight below the text's measured single-line height overprints every line after the first -- and nothing said so: the call returned ok:true and the damage was visible only in a screenshot. layer.create's success result now carries an optional warning?: string naming the measured minimum and the px conversion; the layer is still created and still stores the value it was given. It is a warning and not a refusal on three pieces of shipped evidence: REQ-1308's lineHeight < 1.5 rejection is a published row in this history and stays untouched (req-842-ac1-letter-spacing-lineheight.spec.ts asserts ok:true for 1.8/2.0 at the default size, which any measured minimum would reject); textLayer measures through a canvas and prepareFontCtx falls back to Arial when the named family is not installed locally, so refusing on a fallback metric would be a false refusal; and loudness stays monotonic -- rejected below 1.5, warned below the measured line, silent above it. The warning is silent when there is no leading, when the text is blank, and when the height could not be measured at all. No new method, no new group, no new error code, no ENV_VERSION change. (v3 REQ-1436.)
  • 2.62.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch -- the new fontFamily doc string feeds the manifest hash, so the published manifest is not byte-identical and the fingerprint entry moved), documentation only -- no behaviour change, no new method, no new group, no new error code, no ENV_VERSION or entitlement change, no types.d.ts change. style.fontFamily is ONE installed family name, not a CSS stack. fontFamily shipped as a bare string with no doc at all, and validateStylePatch has no fontFamily branch (its only string validator is validateFontWeight), so a comma-separated stack such as "Archivo Narrow, Archivo, sans-serif" was accepted, stored verbatim, and rendered as the fallback face -- while every call still returned ok:true. The cause is the renderer, which resolves a family as ["'" + name + "'", "Arial"].join(","): a stack becomes ONE quoted family name that happens to contain commas, matches no installed face, and falls through to the trailing Arial. The fix is to say so, not to implement a stack resolver -- rejecting a stack would break shipped behaviour (arbitrary fontFamily strings are asserted ok:true today) and real CSS-stack parsing is out of scope. One exported doc constant is bound to the shared style-patch shape, which is what create and stylePatch both read, so the rule cannot drift between them. The rule is mirrored verbatim into the runtime SKILL() reference and this page, and is frozen once shipped, since four published copies assert it. report.fonts() is unchanged by this release: a value that is not a resolvable family is still reported missingLocally: true (and substituted: true), and a bare installed family is still missingLocally: false. (v3 REQ-1446.)
  • 2.63.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch -- four new result entries plus a changed doc string feed the manifest hash, so the published manifest is not byte-identical and the fingerprint entry moved), warn-only behaviour change -- no new method, no new group, no new error code, no ENV_VERSION or entitlement change. report.measureText now waits for the font it is about to measure, and every result names the face behind its numbers. The call was synchronous and disclosed nothing, so it measured whatever the canvas happened to have: the font stack is "<weight> <size>px '<family>', Arial" and the browser falls through it without error, so four unrelated Google families measured one identical width on a fresh document -- the fallback's -- and nothing in {naturalWidth, naturalHeight, lines} said so. Measurement is how an agent sizes type without guessing, so a silently wrong width from it is worse than no call. Four additive result keys: requestedFamily (what you asked for), measuredFamily (what actually produced the numbers), substituted (true whenever those differ), and an optional warning, present only when substituted is true, naming why. substituted is the term report.fonts() has published for this concept since 1.9.0, deliberately reused rather than a new synonym. The three original keys keep their names, types and values, and no existing call site needs editing. The measure path itself is untouched -- the renderer measures every text layer and stays synchronous -- and only the agent call awaits. It is warn-only, not ok:false, on the same reasoning as 2.61.0's measured-leading diagnostic: refusing would fail every existing consumer that measures in a system family, and a caller that wants the fallback measurement can read substituted. The wait is probe -> join any in-flight load -> scoped load of exactly the one (weight, style) slot -> re-probe, so a twenty-slide deck pays one descriptor fetch per (family, slot) rather than a whole-family fan-out per slide; the re-probe is what makes the reported substituted describe the state the measurement was actually taken in rather than what the loader claimed. A family resolved through a stored alias is reported as the replacement, even though a face is registered under the missing name and every bookkeeping signal says it is present -- so report.fonts().substituted reports it as not substituted while the pixels are the replacement's. The declared fallback entries with no bundled file (Verdana, Times New Roman, Courier New) are reported as substituted even though their metrics are real: the editor cannot tell "the OS rendered this" from "the fallback rendered this", so it reports the fallback rather than claiming a resolution it cannot prove. (v3 REQ-1447.)
  • 2.64.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch -- export.project's doc, its input shape and its result, plus canvas.screenshot's doc, all feed the manifest hash, so the published manifest is not byte-identical and the fingerprint entry moved), one behavioural fix plus one opt-in verification -- no new method, no new group, no new error code (the new signal is a warning field on the success result, so the error-code table above does not move), no ENV_VERSION or entitlement change. (1) canvas.screenshot({id}) is now viewport-independent in DENSITY as well as region. A .fp round trip preserved the layer model and lost the pixels: a 1440x1 filled rule came back visibly thicker, and three stroked marks sharing one scale transform stopped forming the graduated rule they formed before the export -- while layerById read back the correct bounds, transform, shape and paint, because nothing was wrong with the data. The cause was one number at the caller seam: the render call spread its options and then re-applied pixelRatio as a trailing key, which reads like a restatement of the spread above it and is not one; screenshot({id}) is the one caller that passes no pixelRatio, so the layer render option arrived with no scale at all, and the shared device-scale resolver fell through to its third case -- renderResolution * zoom, the live camera -- in a lane that has no camera. The page folder's cache resolution and the leaf sub-pixel gate were both fed that number, so the page subtree was rasterised into a bitmap at the live camera's zoom and blitted up into the 1:1 output canvas. Measured on the card's own fixture in a real headless Chromium: at camera zoom 1 the rule is one row of 1440 full-ink pixels; after the reopen (which re-fits the camera) the same rule is one full row plus 1440 partial pixels on the row below -- "visibly thicker than 1px", measured rather than eyeballed -- and two camera zooms of one session differ by 4011 pixels across the page. screenshot({id}) now returns the same pixels at any editor zoom; omitting pixelRatio (or passing 1) draws at 1x, and an explicit pixelRatio still wins. The returned PNG's width/height/mime are unchanged. The doc string's "viewport-independently" was half true before this -- true of region, false of density -- and is now corrected rather than left standing. The no-id single-page form still crops the live stage, so it remains camera-positioned by design, and the camera never moves. (2) export.project can verify a .fp round trip for you. It gains one optional input key, verifyGeometry (default off), and one optional result field, geometryWarning. With verifyGeometry: true on a format: "figpea" export, a canonicalised geometry fingerprint taken before encode is compared with the one taken after decode, and a mismatch returns geometryWarning: {code, message, layerCount, layers} on the success result, naming the layers whose geometry moved. The fingerprint compares the bounds, the transform's translation, the shape kind, the path's parsed absolute segment list at fixed precision, and the paint keys that change rendered geometry (fill, fillType, strokeEnabled, strokeWidth, strokeColor, roundCornerRadius) -- and deliberately not a hash of the serialized records, because load-time path normalization re-encodes every path and a raw hash would warn on every file. Text runs, images and preRender are out of its scope and are said to be. It is off by default because it costs one extra O(file) read of the archive's repo.json; absent, nothing is read back and the export behaves and costs exactly as before. A good CI recipe: export.project({format:"figpea", verifyGeometry:true}) and fail on value.geometryWarning being present. (v3 REQ-1429.)
  • 2.65.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch -- both layerTree/layerById descriptor doc strings and the types.d.ts widening feed the manifest hash, so the published manifest is not byte-identical and the fingerprint entry moved), behaviour -- no new method, no new group, no new error code, no ENV_VERSION or entitlement change. A malformed transform no longer costs you the whole document, and every bridge_client_error now names the call that failed. layerTree()/layerById()/find() have been documented as never throwing across the boundary since 1.13.0, and they were -- for bounds computation. The serializer read layer.transform unguarded, so a layer carrying a non-array transform threw a raw TypeError out of every serializing call on the surface, on every call, with a minified message naming neither a layer nor a method; the only documented recovery was session.newProject, i.e. rebuilding every layer by hand. Such a layer now reports transform: null plus a transformError diagnostic -- the same shape, and the same per-field-rather-than-blanket strategy, as 1.13.0's bounds/boundsError. Check transform for null before using its numbers. An absent transform is not this case: a layer that was never positioned reports the identity matrix. Separately, the catch-all bridge_client_error message is now always prefixed with the call, e.g. session.layerTree failed: <original error>; it is constructed in exactly one place, so this holds for every method on the surface, present and future, and the envelope's code and field set are unchanged -- treat it as "this call hit an internal defect". (v3 REQ-1466.)
  • 2.66.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch -- two new session.* methods, a new error code, two new FigpeaLayerNode fields and three changed descriptor doc strings all feed the manifest hash, so the published manifest is not byte-identical and the fingerprint entry moved), behaviour + doc -- an ENV_VERSION entitlement surface is unchanged. The active document finally names itself, and a mutation aimed at the wrong one refuses. (1) Identity on every serialized node. FigpeaLayerNode gains documentId and documentName on layerTree(), find(), layerById(), layersAtPoint() and findMatching() -- on every node, not the root alone, because find() returns a filtered array that need not contain the root, so the node you are holding says which design it came from. Both keys are omit-when-unavailable. (2) session.document() -- the same identity read in O(1): two property reads, no tree walk and no serialization, which is what lets figpea-mcp's status report the active document at all, since layerTree() serializes thousands of nodes on a large PSD and status is the call made when something is already wrong. It is per-load: reloading the same file mints a new id, because a reload discards unsaved layers. (3) session.expectDocument(documentId?) -- arms an identifier; until cleared, every mutating method in the named set is refused with the new document_mismatch code when the active document differs, and nothing is created, moved or deleted in either design, because the check runs immediately before dispatch, so the method is never run and there is nothing to roll back. Read methods are never guarded, and expectDocument itself never refuses -- it always answers {expectedDocumentId, activeDocumentId}, so a stale expectation is always recoverable in one call. With no expectation armed, every method behaves exactly as before. (v3 REQ-1451.)
  • 2.67.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch -- layer.create's props.byKind.line schema entries feed the manifest hash, so the published manifest is not byte-identical and the fingerprint entry moved), one behavioural fix -- no new method, no new group, no new error code, no ENV_VERSION or entitlement change. layer.create("line")'s x2/y2 now mean the line's SECOND ENDPOINT, and the offset from the first point is available as dx/dy. Every document described the line you could not build: a line kind that placed only a start point and an x2/y2 that behaved as a size rather than an endpoint, so create("line", { x: 0, y: 0, x2: 100, y2: 50 }) drew a line a tenth of the requested length, and the only way to get the line you described was to compute the endpoint yourself from a length and a slope. x2/y2 are now the second endpoint, matching what the names say and what the layer reads back as, and dx/dy publish the offset for the cases where you meant it. Existing accepted calls keep their stored value rather than being reinterpreted. (v3 REQ-1467.)
  • 2.68.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch -- three session.* descriptor doc strings, three new space param entries and the bounds doc on both type surfaces feed the manifest hash, so the published manifest is not byte-identical and the fingerprint entry moved), documentation plus one new OPT-IN parameter -- no placement behaviour changes at all, no new method, no new group, no new error code, no ENV_VERSION or entitlement change. The read side of this page's geometry never named its coordinate space, and now it does. layerTree(), layerById(id) and find(sel) report bounds in world coordinates, while every placement input except setPosition with space omitted is parent-local (setLocalPosition, setPosition with space:"local", create's x/y, a transform's e,f) -- so a rect at page-local x: 110 on a page whose world origin is x 2580 reads back bounds.left 2690, and handing 2690 straight to setLocalPosition throws the layer 2580px off the right edge of a page that looks finished, returning ok: true. (1) The rule is stated at the point of use -- on the bounds field itself and in full on layerById() -- and one sentence on this page now matches the contract's own exported constant byte for byte. (2) All three reads gain an optional trailing space: "world" (the default, unchanged in output and cost, because the default read still reaches the bounds computation with no options at all) or "local", which returns the very parent-local rect setLocalPosition consumes, so read-then-write needs no arithmetic. On a tree read "local" resolves each node against its own parent, so a folder nested inside a page is a third basis rather than page-local, and prefer the targeted layerById(id, "local") over a whole-tree local read on a large document. Inside a rotated or mirrored parent only "local" is exact -- subtraction is the wrong operation, not an approximation. (3) The setPosition section above no longer calls the world-space default a local position, which it has not been since this page's own 2.30.0 row. This page also stops telling agents to consult layer.calcBounds({relativeTo: parent}), which is not an agent-callable method; layerById(id, "local") returns exactly that rect and you can call it. (v3 REQ-1479.)
  • 2.69.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — layer.create's published doc string changed, so it feeds the manifest hash): create("page") now gives a page a white background, the same value the editor's own page tool writes, so a page made through the scriptable surface no longer exports as a transparent PNG (measured: 38,400 of 38,400 pixels non-opaque on a 240x160 scripted page, against 80,000 of 80,000 opaque white for a page added by clicking in the same project). The default is written at creation time and travels on style.fill, not on pageBackgroundFill; an explicit background at create time wins it, and fillType:'none' still asks for a transparent page. An artboard that arrives from a decoded PSD, XD or .fig is untouched: the white is never resolved at render time, so a file whose artboard carries no fill still opens and exports transparent. No new method, no new prop, no new error code — style was already a common create field; only its default was missing. (REQ-1442)
  • 2.70.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — CREATE_KIND_SCHEMA is published verbatim inside describe("layer.create")'s params.props.byKind, so the new byKind.path.d entry and the corrected descriptor doc strings all feed the manifest hash): documentation plus one accepted alias — d is now a documented alias of path, and every published d/path sentence is corrected. Six published places described the VALUE as a "d string" while the PROP is path, and layer.batch's own worked example used {d:"M0 0 L10 0"} — which the validator rejects. So that example reached /agent/contract.json, describe("layer.batch"), every generated figpea_layer_* tool description and window.figpea.SKILL() while every suite stayed green. create("path", {d: "M0 0 L10 0"}) and create("path", {path: "M0 0 L10 0"}) now create the same layer, and the success result carries pathFromAlias so a caller can see which spelling was used. Byte-identical path+d is legal with path winning; two different values is invalid_params naming both props and nothing is created. The alias is not a way around the geometry gate — a malformed value is still invalid_geometry naming "path" under either spelling. ellipse is deliberately unchanged: cx/cy is a documented centre-based convention and one call already places it. d is disambiguated rather than scrubbed — it is SVG's own name for the value, path is the prop this schema declares. Every published create example is now run through the validator as a test input, so an example can no longer ship unrunnable. No new method, no new group, no new error code, no ENV_VERSION or entitlement change. (REQ-1484)
  • 2.71.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the create and stylePatch descriptor doc strings and the invalid_style_value error-code catalogue entry all feed the manifest hash): behaviour + doc — a fill that will never paint is never accepted, never reported, and never written. Under fillType:"none" the renderer draws nothing at all, so a colour stored beside it is not a paint claim: it survives into the .fp and misleads every later reader while the canvas shows nothing. {fill:<colour>, fillType:"none"} is now refused with the EXISTING invalid_style_value (no new error code), the message naming both keys and the three ways out; validation runs before mutation, so the layer's prior paint is untouched and nothing is created. One branch covers create, stylePatch and every batch op, with a twin for a page background. The supported no-fill spellings — {fill:"none", fillType:"none"} and fillType:"none" alone — are untouched. The inert colour is also omitted on read, by the one serializer every read method builds on, and never written to the .fp at the single per-record funnel, so both the agent export and the UI's Save are covered; export.project({verifyGeometry:true})'s fingerprint canonicalises the same projection, so a cleaned layer does not report a geometry_mismatch that is not one. A page or sheet background deliberately KEEPS its remembered colour in the file — its control shows that colour on purpose while nothing paints; the read omits it, the file keeps it. No new method, no new group, no new field, no new error code, no ENV_VERSION or entitlement change. (REQ-1485)
  • 2.72.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — layer.resize's doc and its size shape, layer.create's byKind.rect.rwidth/.rheight docs and the invalid_params catalogue entry all feed the manifest hash): behaviour + documentation — an unrecognised key inside a nested object param is now REFUSED, naming it, on three more methods, and layer.resize accepts the same rectangle-geometry spelling layer.create teaches. layer.resize declared size: { width?, height? } and validated only the two keys it happened to READ, so resize(id, {rwidth: 200, rheight: 100}) validated nothing, computed scaleX = 1, ran a no-op mutator and returned ok:true with the document unchanged — ok:true with no effect, which is worse than a crash because a design run can record it as proof of work that never happened. (1) layer.resize's size, canvas.setViewport's viewport and export.flowPoster's input now reject an unrecognised key as their first statement, ahead of the session lookup and ahead of the entitlement check, so a misspelling is never hidden behind a plan wall; each reads as <method>() received unknown parameter "<key>". canvas.setViewport's width/height are the frame-a-rect INPUTS and are deliberately NOT renamed — a caller who learned rwidth from create must learn that this method means something else. (2) rwidth/rheight is canonical, and resize(id, size) now accepts it as an EQUAL ALIAS of width/height, resolved per axis; both spellings of one axis ({width: 100, rwidth: 200}) is a NEW refusal naming both keys, because before this that call silently used width and dropped rwidth. create is deliberately not renamed — that would break every shipped create caller. Both spellings resolve as before and the mutation path is byte-identical, so one figpea.history.undo() reverses a resize made either way. The naming rule is ONE exported constant bound into both methods' docs, rather than the same sentence hand-copied five times. The remaining type: "object" params that still ignore an unknown key are enumerated one-per-row with an explicit disposition, so the next one cannot join that list undecided. No new method, no new group, no new error code (invalid_params only), no ENV_VERSION or entitlement change, and a valid call is byte-identical. (REQ-1486)
  • 2.73.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the align-anchor rule and the seed-shape change feed the manifest hash, so the published manifest is not byte-identical): behaviour + documentation — an authored text align is honoured on an auto-width frame, and a stroke-silent shape paints with no outline. With no fixWidth authored the frame collapses to the measured width, so there was no box to align within and center landed where left would; now x is the alignment anchor for a boxless center/right run — opt-in per layer, so every decoded import renders byte-identical. Separately, create("path", …) seeded strokeEnabled:true + strokeColor:"#000000", and a style merged over that seed key-by-key left the pair standing, synthesising a real 1 px black stroke nobody authored; the seed now carries no stroke keys, so no stroke keys authored means no stroke. (REQ-1497)
  • 2.74.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — export.project's doc, input shape and PDF result feed the manifest hash, so the published manifest is not byte-identical): behaviour + a new optional input — PDF export takes a resolution. The exporter handed pixel dimensions to jsPDF as points with nothing declaring the artboard's resolution — a silent 72 DPI declaration that landed print art oversized. export.project gains one optional key, dpi (a whole number from 1 to 2400; omitted, it falls back to the project-level declaration, and an undeclared project means 72 — exactly what every export produced before), and its PDF success result gains one optional field, pageSizes (one entry per page in export order, the box in points and inches; absent for zip/figpea, which have no page box). Two project-level methods beside it — session.getOutputDpi() / session.setOutputDpi(dpi), one value every artboard shares and the same value the export dialog writes, so an agent and the UI cannot disagree. No new entitlement: dpi declares physical size, not fidelity. (REQ-1473)
  • 2.75.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the create and stylePatch descriptor doc strings feed the manifest hash, so the published manifest is not byte-identical): behaviour + documentation — fillType:"none" drops the merged paint claim post-merge. The 2.71.0 refusal closed the half where the caller supplies a colour beside fillType:"none"; the half where nobody supplies one arrived via the merge instead — create merges over a seed carrying fill: #d9d9d9, and stylePatch unions onto the live style — so a bare fillType:"none" stored a colour nobody asked for, which the now-silent read resurrected on a later fillType:"solid". One rule, applied post-merge: fillType:"none" drops the fill colour, the gradient and the pattern image; a fillType:"solid" with no colour present resolves to #d9d9d9, the product's own default shape fill. The {fill:"none", fillType:"none"} sentinel still round-trips, and page backgrounds keep their remembered colour. (REQ-1501)
  • 2.76.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — two doc strings and a new ParamSchema field all feed the manifest hash, so the published manifest is not byte-identical) — describe() now publishes, for layer.setTransform, a matrix an array-collapsing host can actually send. matrix was declared {type:"matrix"} — a six-number tuple such a host can read but cannot nest — so the one representation that worked travelled only inside the {"item": …} error an agent had to hit first. (REQ-1294). ParamSchema gains one optional stringJson field ({type:"string", example, doc?}), declared on setTransform.matrix with the copyable example "[1,0,0,1,48,110]"; it is an alternative beside the runtime type, never a replacement, so type stays "matrix" and every validator is untouched. figpea_describe returns the declaration verbatim, alongside the existing stringJsonParams. The method's doc and the param's doc now name the form and why it survives such a host (a string is a scalar), and REFERENCE.md carries the array-free entries for matrix and for batch's ops. The JSON-string route itself is unchanged and predates this row — what is new is that the manifest says so before the call instead of the agent discovering it by failing. The {"item": …} detection and its message are deliberately unchanged (and this page now documents that refusal as live behaviour rather than a defect). No new method, no new group, no new error code, no ENV_VERSION or entitlement change; a valid call in the array form is byte-identical.
  • 2.77.0 (AGENT_API_CONTRACT_VERSION): Doc-only — letterSpacing's published conversion stops being a rate without a recipe. 2.39.0 pinned the UNIT correctly (thousandths of em) and appended a per-gap figure, but never said how many gaps a string is charged, so the sentence read as a complete way to compute a target width and was not one. What to unlearn: "per gap" does not mean a fixed number of gaps. Measured in real Chromium, a string of N glyphs is charged N gaps — one after every glyph, including the last — so an N-glyph line grows by N x (letterSpacing/1000 x fontSize), not (N-1) x …. The per-gap rate itself did not move: it is 1/1000 em, measured across two installed families and two font sizes. What did vary is the gap count, and it is a property of the reader's browser — where the CSS letterSpacing capability is present the browser's own text engine charges N gaps; where it is absent, Figpea adds the spacing by hand and charges the N-1 gaps between glyphs. No single rate can therefore be published, which is why the contract states the formula and its conditions instead. To fit a line to an exact target measure, measure it: measureText() is the oracle and returns an ADVANCE width, which is not the ink extent — side bearings differ again — and it is what any fitted letterSpacing will be judged against. The trailing gap sits inside the box, so with align: "right" the last glyph lands one gap short of where the box says it does. Nothing changed but the description: tracking semantics are untouched, every existing tracked design renders exactly as before, and the unit did not move — imported files are unaffected, because the Figma/XD/PSD decoders already compute in the documented convention. No renderer, decoder, file-format or wire change. (v3 REQ-1306)
  • 2.78.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the new ERROR_CODE_CATALOG entry and both export.layer/export.artboard doc strings feed the manifest hash, so the surface hash moves by construction and this fingerprint entry is new). Exporting a container with nothing in it now fails with an explicit code instead of returning a plausible picture of nothing. figpea.export.layer (or export.artboard) on a page or folder whose subtree is empty rendered that container's own opaque ground and returned it as a correctly-sized PNG with ok: true, so an agent could not tell "this design is empty" from "my capture came back blank" and had no code to branch on. (1) A NEW code, empty_subtree — not a reuse of export_failed: every code in the catalogue describes a failure of the CALL or the ENCODE, while this one describes a permanent structural property of the CONTENT, and folding it in would make "add content to this subtree" and "the encoder threw, retry" indistinguishable — which is exactly the diagnosability the code exists to buy. (2) Decided on the MODEL, never on the pixels: a container's own ground is opaque, so a "has a non-transparent pixel" test is true for precisely the blank case this catches; the predicate is a pure, memoized read over the layer tree instead. A container's own fill deliberately does NOT count as subtree content (that ground IS the flat single-colour raster being refused), so a filled page with no children is refused, while a leaf is its own content and always exports. The bias rule is the safety property: "empty" is answered only when every descendant is positively determined non-drawing, and anything undecidable answers "has content", because a false refusal breaks a legitimate export with an error the caller cannot act on while a false "has content" merely reproduces today's behaviour. The guard sits AFTER both entitlement checks and immediately before the encode, so every existing error's precedence is byte-for-byte unchanged. Scope: the AGENT surface and CONTAINERS only — the editor's own UI export path is untouched. What did NOT change: "a container export contains the container's children" is true of every build and is published WITHOUT a version qualifier in both v3 mirrors, because qualifying it would imply a rendering change that measurement disproved — the refusal is the only version-gated fact, and a container export remains the export lane's own render (its own device scale, cropped to the layer's bounds, compositing masks its own way), never a screen copy. No renderer, decoder, encoder, file-format or entitlement change, no new method, no new group, and ENV_VERSION stays 2; no figpea-mcp change (it self-syncs from the editor's /agent/contract.json). (REQ-1491)
  • 2.79.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — canvas.screenshot's and session.waitForIdle's doc strings and the screenshot result shape all feed the manifest hash, so the surface hash moves by construction and this fingerprint entry is new). figpea.canvas.screenshot now settles image decoding before it captures, and reports when it could not. Attaching a photograph with figpea.layer.setImageFill (or figpea.layer.create with an image source) and capturing immediately used to return ok: true and a correctly sized PNG over the shape still painted in its flat fallback colour — the renderer decodes a pattern fill asynchronously and nothing an agent called waited for it, while figpea.session.layerById/figpea.report.projectImages report the model and the derived image library, never what was painted. What to do now: nothing — the attach calls resolve only once their bytes have decoded, and screenshot settles any decode still in flight for what it is about to capture, on every capture path, so the waitForIdle-before-every-capture step is no longer required for figpea.canvas.screenshot (calling it first remains correct and costs nothing). What did not change: nothing about rendering or fidelity — the image looks exactly as it did once decoded, only when you may look at it changed; no renderer, decoder, image cache or image data was touched; the export path was already correct and is untouched; no new method, group, entitlement or error code; ENV_VERSION stays 2; no figpea-mcp change (it self-syncs from the editor's /agent/contract.json). New, optional: a screenshot whose decodes could not settle inside its 10-second budget is still returned, with settled: false and a warning naming what had not settled — recovery is figpea.session.waitForIdle() then capture again. Both keys are absent whenever the capture settled, so an existing caller sees exactly the envelope it saw before. figpea.session.waitForIdle additionally covers in-flight image decoding alongside the document swap, corner-radius fillets and font loads, and reports timeout (never a false ok) on a queue that never drains. Baseline re-derived TWICE, the second being the pre-review rebase: first at task time (2026-10-06) from a 2.77.0 mainline, which gave 2.78.0; then again when REQ-1491 merged and shipped that very 2.78.0, so this row re-points to 2.79.0 rather than contending for a version mainline has already shipped. Contiguity is mechanical — REQ-171's A2c check parses these very bullets and fails on a gap — so skipping to a free slot was never available, and whichever branch merges second re-points; nothing about the delivery changes, only the number it is stamped under. Contiguity holds (2.74.0 REQ-1473 -> 2.75.0 REQ-1501 -> 2.76.0 REQ-1294 -> 2.77.0 REQ-1306 -> 2.78.0 REQ-1491 -> 2.79.0 here). (REQ-1510)
  • 2.80.0 (AGENT_API_CONTRACT_VERSION): Doc-only — the text-frame reference now says which way the line-breaking rule cuts for a token too wide for its box, and that lines is the UNCLIPPED count (REQ-1532). fixWidth was documented as "lines wrap to this width instead of extending past it", which is correct and silent about the one case where a word cannot fit at all — so an agent sizing copy to a constrained box had to guess whether a too-wide token clips or overflows, and hard-coded whichever it happened to observe. Measured in real Chromium on the exact path figpea.layer.measureText takes: an over-wide token is broken MID-TOKEN across lines — never clipped, never overflowing — with no character lost or duplicated; token length swept 1..80 at fixWidth: 300 / fontSize: 32 gave 0 of 80 disagreements with a reference greedy wrap, and the count tracks real width rather than any arithmetic. The one permitted exception is a single character wider than fixWidth — it cannot be split further, so it takes a line of its own and that line overflows, which is why the wording says "broken mid-token" and not a blanket "never overflows". The companion rule ships with it: fixHeight clips rather than wraps, so lines is the number of lines the text NEEDS at that fixWidth and can be larger than the box shows. Published in the stylePatch "Text-frame sizing" bullet above, in the measureText method doc and its lines result, and in the in-repo reference. No renderer, decoder, encoder, file-format or entitlement change, no new method, group or error code, ENV_VERSION stays 2 — measureText() and the line-breaking implementation are byte-identical, and a test measures the renderer and grades these sentences against each other so neither can drift from the other silently. RE-POINTED once at the pre-review rebase (it was drafted as 2.79.0, against a mainline whose last row was 2.77.0): v3's REQ-1510 merged first and shipped 2.79.0, so this row takes the next contiguous minor rather than contending for a version mainline has already shipped. Mainline's own 2.79.0 row is kept byte-identical and this one is renumbered, never dropped.
  • 2.81.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the new DIAGNOSTIC_CODES entry and both report.diagnostics doc strings feed the manifest hash, so the published manifest is not byte-identical and the fingerprint entry moved), behaviour + documentation — no new method, no new group, no new error code, no ENV_VERSION or entitlement change. report.diagnostics() now reports an authored-document note, not just decode/render degradation. A layer whose stored style carries a strokeColor with no strokeWidth yields one orphan_stroke_color entry at severity warning with layerId set, because the renderer substitutes a default 1 px width for the missing one — unless the stroke is switched off, in which case nothing is painted and the message says so. The message branches on what the same resolver the painter calls produces, so it can never disagree with what is drawn; a style shadowed by an explicit strokes array is not flagged. The check lives in the shared collectDiagnostics, so the agent and the Project Health Report read one rule. (REQ-1506)
  • 3.0.0 (AGENT_API_CONTRACT_VERSION): BREAKING major — figpea.layer.markSheet and figpea.layer.unmarkSheet are REMOVED from the published contract (replaced by the new figpea.sheet.* group — create/rename/reorder/delete/setBackground/clearBackground/list/ofLayer). A stale call gets {ok:false, code:'method_retired'} naming the replacement. No other method, group or ENV_VERSION change (stays 2). (REQ-1380)
  • 3.0.1 (AGENT_API_CONTRACT_VERSION): Patch (manifest byte-identical) — the arg_size_exceeded guard now measures real payload bytes for binary call args (REQ-1383). session.openFile({bytes})'s documented union (ArrayBuffer | Uint8Array | number[]) used to be measured with JSON.stringify(args).length, so the same file had three outcomes decided purely by container: a Uint8Array paid ~8.6 chars per real byte (whole-file opens refused above ~2.5 KB with image-staging advice that has nothing to do with opening a file), a number[] paid ~2.0, and an ArrayBuffer paid ~0.0 (stringifying to {}), sailing an 80 KB open past a guard whose entire purpose is to protect the frame. The guard now compares a byte-accurate estimate — real byteLength for ArrayBuffer and every view, 1 per element for byte-shaped number[], exact JSON length for everything else — so one byte sequence estimates identically in all three members, and binary-free calls (data: URIs, op batches) measure exactly as before. The refusal now names the firing method (session.openFile), keeping the staging sentence for payloads that actually carry image bytes. Threshold (22,000), code, guard order and ENV_VERSION (2) unchanged; no new method, group or error code. (REQ-1383)
  • 3.1.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — three new session.* methods means three new descriptor entries feed the manifest hash, so the published manifest is not byte-identical) — the agent checkpoint and the observable restore (REQ-1439). session.saveCheckpoint() persists the active project to a local, ungated IndexedDB slot (single latest row), serialized through the same project encoder export.project({ format: "figpea" }) uses — the stored bytes are that export's bytes by construction — returning { documentName, savedAt, byteSize, durable } (durable: false when the store could not persist, rather than a throw). session.checkpointInfo() reads back what is persisted — metadata only, never the bytes — with no active session required. session.restoreInfo() reports the restore transition as { restored, previous, active }: true when the tab reloaded since the agent last anchored a document (via newProject, openFile, or document()) and the live document differs. All three stay outside GUARDED_METHODS (reads and IDB writes, never layer-model mutations). No new error code. (REQ-1439)
  • 3.2.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — history.undo/history.redo refusal branches plus the two new export.project result keys feed the manifest hash through their descriptor doc strings, so the published manifest is not byte-identical) — undo/redo join the expected-document guard, and an export names its design (REQ-1554). history.undo/history.redo are refused with document_mismatch under a stale session.expectDocument — the same code and message shape as every other covered method, with nothing changed in either design — closing the hole where an undo silently deleted a layer from the wrong design. export.project success results (all three formats) carry {documentId, documentName} of the design those bytes came from; the call is not refused under a stale expectation. The expectDocument instruction sentences no longer claim every mutating method refuses — they name the covered set. No new method, no new group, no new error code. (REQ-1554)
  • 3.2.1 (AGENT_API_CONTRACT_VERSION): Patch, behaviour change to setPosition/setLocalPosition -- no new method, no new group, no new error code. A one-axis local move inside a steeply-rotated parent is refused with invalid_position, naming the parent orientation and telling the caller to send both axes, when the compensating slide exceeds 10x the moved layer own parent-local extent; the refusal lands before any transaction, so bounds, transform and history are untouched. (v3 REQ-1462.)
  • 3.3.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the resolver input, two layer.measureText result entries and four doc strings feed the manifest hash, so the published manifest is not byte-identical) — a measurement in a family the editor cannot establish no longer names a face it cannot prove (REQ-1517). Before measuring, an unregistered family is now checked against an explicit fallback measurement of the same string: when the widths agree the result reports the fallback exactly as before; when they differ — the case for the system families the OS supplies but the editor never registers (Verdana, Times New Roman, Courier New, Georgia; measured on the shipped build at 1932 / 1480 / 1959 / 1765 px against Arial's 1651 px for "Reading the Tides" at 192 px bold — one string at one size, never universal constants) — the result reports the face as unverified: measuredFamily: null (never the requested echo), substituted: true, verified: false, and a reworded warning naming the requested family, stating the width differs, and directing to measuredFamily/verified — carrying no warning asserting the number is the fallback's. What to unlearn: the 2.63.0 row's sentence that these families "are reported as substituted … so it reports the fallback" no longer holds for the differing-width case. The result shape is additive: the same three numbers come back with the same meaning and no existing call site needs editing. No new method, no new group, no new error code, no ENV_VERSION change. figpea-mcp self-syncs via runtime contract fetch; site mirrors via agentContractSync. (REQ-1517)
  • 3.4.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — seventeen converted methods' descriptor doc strings plus the two documented-ignore sentences all feed the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: an unrecognised key inside a nested object param is now refused, naming it, on seventeen more methods, and two methods document that unknown keys are ignored (v3 REQ-1543). Six calls returned ok:true while doing less than — or something other than — what was asked. layer.setLocalPosition(id, {x, top}) left y where it was (top is not y); layer.setPosition(id, {x, yy}) left y at its old value (yy is not y); component.addState(id, {stateName}) created a state named "State 2" (stateName is not name); component.renameState(id, {stateId, naem}) left the name unchanged (naem is not name); component.setActiveState(id, {stateIdd}) left the component with no active state at all (stateIdd is not stateId); interaction.create(id, {…, propertys}) stored the default transition properties instead of the ones supplied (propertys is not properties). Each now reads <method>() received unknown parameter "<key>" as its first statement — before the session lookup, so a misspelling is never hidden behind a missing session — and a rejected call changes nothing. The eleven further enumerated params adopt the same guard (canvas.addDimension's two points, canvas.setRulerVisible keeping the value legacy alias, component.placeInstance/removeState/restoreChild, layer.cut/setConstraints/setImageFill/smoothPath, session.waitForIdle); component.resetOverride and layer.repeat take the documented-ignore leg instead — a guard would move resetOverride's published safe-no-op, and repeat's whole object is validated per-mode by the engine, so their method sections now state that unknown keys are ignored. No new method, no new group, no new error code (invalid_params only), no ENV_VERSION or entitlement change, and a valid call is byte-identical — including the partially-populated forms (one-axis positions, nameless add-state, Base-switching setActiveState, property-less interaction create). The top-level validator still checks types only; nested-object keys stay each method's own concern (the 1.20.0 row above is unchanged).
  • 3.5.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the strokeWidth descriptor doc string feeds the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: sub-device-pixel strokes paint device-pixel-aligned, and the painted width is readable without pixels (v3 REQ-1459). A strokeWidth: 1 line on integer geometry straddled two device rows at partial coverage each with no signal anywhere on the surface; the paint funnel now floors a stroke painting at or below one device px at exactly 1 device px and, when axis-aligned, translates it onto the nearest full device pixel — paint-time only, so the model, its fill, corner radius and bounds are untouched. FigpeaLayerNode.renderedStrokeWidth (omit-when-unset, beside style, never patchable) reports the effective device width from the same helper the paint path calls. No new method, no new group, no new error code, no validator/whitelist change. figpea-mcp self-syncs via runtime contract fetch; site mirrors via agentContractSync. (REQ-1459)
  • 3.6.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the new measureText/create/setText result entries plus changed doc strings feed the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: measureText reports per-line widths, and create/setText warn on silent re-wrap (v3 REQ-1458). The measurement API answered the wrong question: it computed the wrapped layout and published only its max (naturalWidth) and count (lines), so a 295px line in a 240px box was invisible until the renderer re-wrapped it. Every result now carries lineWidths: number[] — one ink width per wrapped line, in order (length === lines), read off the already-computed wrapped line boxes and reported verbatim with no rounding; an entry wider than your fixWidth is a manual line the renderer re-wrapped. The creation API answered ok:true while silently re-breaking the caller's lines: create('text') and setText on a fixWidth layer now carry a warn-only warning naming the re-wrapped manual line(s) with measured px vs box px — the layer is still created/set, nothing is rejected. No new method, no new param, no new error code, no ENV_VERSION change, no renderer/decoder/paint change. figpea-mcp self-syncs via runtime contract fetch; site mirrors via agentContractSync. (REQ-1458)
  • 3.7.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR — two new figpea.canvas.* methods making specified work checkable (v3 REQ-1527). canvas.assertGeometry(input) checks a list of {layer, x, y, width, height, tolerance?} assertions — layer by NAME within the page subtree — returning one pass/fail entry per assertion with expected+actual on failure (read-only calcBounds() basis; unknown page/layer yields pass:false with actual:null, never a throw). canvas.capturePages(input?) renders the named pages (omitted = all) at a caller-chosen scale into one labeled PNG contact sheet with a per-tile manifest. Ungated captures, like screenshot — distinct from the gated export.contactSheet PDF. (REQ-1527)
  • 3.8.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the report.diagnostics descriptor doc string feeds the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: report.diagnostics() surfaces the CSS-export gradient-transform approximation (v3 REQ-1155). REQ-1076's css_gradient_transform_inexact warning — a gradient transform CSS cannot express (sheared radial, sheared-or-reflected conic), emitted untransformed on the caller's warnings array — now maps to the existing public rasterized_fallback code at warning severity, so the diagnostics surface shows it instead of silently dropping it. No new method, no new public code, no production caller threading. figpea-mcp self-syncs via runtime contract fetch; site mirrors via agentContractSync. (v3 REQ-1155)
  • 3.9.0 (AGENT_API_CONTRACT_VERSION): Documentation only — no behaviour change, no new method, no new group, no new error code, no ENV_VERSION or entitlement change (v3 REQ-1436). The layer.create("arc") ring-mode sentence contradicted its own validator: {rx, ry, startAngle, sweep} reads as one object literal while the schema declares sweep a number and the implementation reads it as one, so an agent following the prose was refused with invalid_geometry. The sentence now states "sweep" is a number with "rx", "ry" and "startAngle" as its siblings; the wire format is unchanged. RE-POINTED at the pre-merge rebase (was 3.8.0): req/REQ-1155 shipped 3.8.0 first, naming this contention, so this row takes the next contiguous minor rather than contending for a version mainline has already shipped. Mirrored verbatim into the create() reference below (v3 REQ-1436, absorbed REQ-1441). (v3 REQ-1436)
  • 3.9.1 (AGENT_API_CONTRACT_VERSION): Patch, behaviour + documentation — a path that cannot be rendered is refused, and the path convention is finally stated (REQ-1468). A present path/d value that parses as SVG path data but yields no drawable geometry (move-only commands, whitespace-only and kindred fragments) is rejected pre-attach with the existing invalid_geometry naming "path", and nothing is created; "", "M0 0" and drawable data keep working exactly as before. The d is authored in the layer's own local space and stored as given — never rebased: x/y place the path's box top-left in parent-local coordinates and the reported bounds are the drawn shape in world coordinates, so baking the placement into the d puts the mark in the wrong place. A path layer is re-pathed by delete-and-recreate; there is no in-place setPath. Provenance: the parse gate since REQ-719, one-call placement since REQ-1281/2.52.0, the world-bounds promise since REQ-1479/2.68.0 (which the nested-missing case contradicted), the d alias since REQ-1484. Any workaround built on the old behaviour is now wrong. No new method, no new group, no new error code, no ENV_VERSION change, no entitlement change. (REQ-1468)
  • 3.10.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the report.diagnostics descriptor doc string feeds the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: report.diagnostics() surfaces the render-layer typeless-layer degradation (v3 REQ-285). A layer whose layerType is missing or unrecognised no longer aborts the open: the renderer degrades it to an empty folder placeholder (its intact children still render, the rest of the document renders normally), and the read-time walk reports it under the existing public unsupported_node code at warning severity — one folded entry per session (count only when > 1, no layerId), with the blame-free message A layer couldn't be drawn — shown as an empty group — so the diagnostics surface shows it instead of dying with the session. No new method, no new group, no new public code, no ENV_VERSION change, no entitlement change. figpea-mcp self-syncs via runtime contract fetch; site mirrors via agentContractSync. (v3 REQ-285)
  • 3.11.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the report.diagnostics descriptor doc string feeds the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: the Photoshop matte bevel techniques are honoured with their authored profile (v3 REQ-1551). bevelEmboss.technique preciseMatte/slopeLimitMatte on inner bevels now paint a distinct matte band profile — a soft, wide transition with a mid-band ridge — instead of the smooth profile, so setting the technique changes the render. Smooth, the chisel pair, unrecognised values and non-inner styles paint exactly as before. The technique-substitution warning narrows to the still-approximated cases (an enabled chisel value, a matte value on a non-inner style, or an unrecognised technique — matte×inner no longer reports), and the technique render-contract paragraph above is revised in the same change. No new method, no new group, no new error code, no ENV_VERSION change, no entitlement change. figpea-mcp self-syncs via runtime contract fetch; site mirrors via agentContractSync. (REQ-1551)
  • 3.12.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the four gradient doc strings, three placement result strings and types.d.ts widening all feed the manifest hash, so the published manifest is not byte-identical) — documentation plus a typed out-of-clip signal (v3 REQ-1493). linearX1/linearX2/linearY1/linearY2 on the agent-facing gradient shape now state they are normalized fractions of the layer’s own width (X) / height (Y) — 0 the near edge, 1.0 the far edge — instead of shipping as bare numbers whose names read as pixels; a pixel-magnitude value (e.g. linearY2: 50 on a 100 px-tall layer) is legal and paints near-flat stop[0], which is correct behaviour, not a renderer bug. layer.setPosition states its default coordinate space unambiguously (omitted or space:"world" is world coordinates, not parent-local — contrast create, whose transform e,f are parent-local), so the same numbers that create used move the layer back. A placement wholly outside the parent’s clip rect now carries present-and-true outOfClip beside the retained warning string on the ok:true result of setPosition/setLocalPosition/setTransform — branchable without string parsing; no ok:false code is added, so the error-code table is unchanged. No rendering, numeric, or coordinate-model change. (REQ-1493)
  • 3.13.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the altered descriptor doc strings feed the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: a folder viewport frame never silently collapses (v3 REQ-852). The folder content→viewport/constraint frame materialisation is order-independent: an explicitly authored size (stylePatch fixWidth/fixHeight, previously stored-but-unread for folders) wins per-axis over the measured children union, so sizing-before-mode converges to the same frame as mode-before-sizing. The degenerate frame is refused loudly: setResizeMode(id,"viewport"|"constraint") on a folder with no sized frame to materialise (empty/stale union, no authored size) and resize() on a viewport folder whose frame is already zero-area now return the new viewport_frame_required code — naming the reparent-sized-children-first working sequence — with zero mutation, instead of writing/keeping a zero-size frame that claimed success. No new method, no ENV_VERSION change (2), no entitlement change. figpea-mcp self-syncs via runtime contract fetch; site mirrors via agentContractSync. RE-POINTED at the pre-review rebase (was 3.12.0): req/REQ-1493 shipped 3.12.0 first, so this row takes the next contiguous minor. (v3 REQ-852)
  • 3.14.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the new layer.setPath descriptor entry feeds the manifest hash, so the published manifest is not byte-identical and the fingerprint entry moved (re-pointed: live sibling req/REQ-1493 merged first and shipped 3.12.0, so this row takes the next contiguous minor; nothing about the delivery changes. RE-POINTED twice: req/REQ-852 shipped 3.13.0 after req/REQ-1493 shipped 3.12.0) — behaviour + documentation: an existing path layer's outline can be rewritten in place (v3 REQ-1023). figpea.layer.setPath(id, d) replaces the outline with new SVG path data on the SAME layer — id, parent, transform and style preserved, so prototype interactions, component bindings and selection survive; one undo step; batchable as {method:"setPath",args:[id,d]}; no auto-recenter; derived regionFills dropped in the same transaction so getRegionFills re-derives from the new geometry. Validation mirrors create("path") (not_found / not_a_path_layer / invalid_geometry naming "path", empty string valid). The create("path") reference below no longer says re-pathing is delete-and-recreate. No new group, no new error code, no ENV_VERSION change (2), no entitlement change. figpea-mcp self-syncs via runtime contract fetch. (REQ-1023)
  • 3.15.0 (AGENT_API_CONTRACT_VERSION): Additive, behaviour change — no new method, no new group, no new error code, no ENV_VERSION change. layer.create accepts an optional world-space position: {x, y}: the new layer's bounds top-left lands on (x, y) in world coordinates (the setPosition promise) in the same call, including inside layer.batch on pages at non-zero offsets; omission keeps parent-local behaviour byte-identically, and one create remains one undo step. layer.batch placeholder docs now show both "$<index>" and { "opResult": n } forms, and a malformed placeholder is refused with invalid_params naming the op index. (REQ-1029)
  • 3.16.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR — text baseline + ink metrics on the agent API (v3 REQ-1507). figpea.layer.measureText gains ascent / descent / capHeight / baselineOffset (px at the resolved size; bounds.top + baselineOffset lands the first baseline with no render), and layerTree() / layerById() text nodes gain the same four keys on textMetrics plus the additive inkBounds painted-ink rect beside the unchanged layout-box bounds (same space rule). No new method, no new error code. figpea-mcp self-syncs via runtime contract fetch; site mirrors via agentContractSync. (REQ-1507)
  • 3.17.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the new layer.roundCorners descriptor entry feeds the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: an existing path layer's corners can be rounded in place (v3 REQ-1574). figpea.layer.roundCorners(id, radius[, points]) applies the Round Corners fillet on the SAME layer — id, parent, transform and style preserved, so prototype interactions, component bindings and selection survive; one undo step; batchable as {method:"roundCorners",args:[id,radius[,points]]}; point identities reconciled so follow-up anchor edits land correctly. points lists anchor/command indices (omitted/empty = every anchor); only straight-adjacent corners round (curved/collinear/endpoint skipped, rest still applied — the toolbar's Apply semantics); oversize radii clamp per-corner; derived regionFills kept and cornerRadii/widthPoints untouched. Validation: not_found for a missing id, not_a_path_layer for a non-path layer, invalid_params for a non-positive radius, a malformed points list, an explicitly-listed out-of-range index (naming it), or zero eligible corners (carrying the per-corner skip reasons, nothing mutated). No new group, no new error code, no ENV_VERSION change (2), no entitlement change. figpea-mcp self-syncs via runtime contract fetch. RE-POINTED at the pre-review rebase (was 3.16.0): req/REQ-1507 shipped 3.16.0 first, so this row takes the next contiguous minor. (REQ-1574)
  • 3.18.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the new layer.offsetPath descriptor entry feeds the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: an existing path layer's outline can be offset in place (v3 REQ-1573). figpea.layer.offsetPath(id, distance) offsets the outline by distance px with the editor's own offset geometry — the same result as the Inspector's Offset Path operation (positive pushes outward, negative pulls inward); one undo step; batchable as {method:"offsetPath",args:[id,distance]}; derived regionFills dropped in the same transaction so getRegionFills re-derives from the new geometry. Validation is arg-shape-first (invalid_params for a bad id/distance), then not_found for a missing id, then the strict path predicate (not_a_path_layer — non-path layers are rejected, never auto-converted), then the engine gate's fixed-precedence blocks as invalid_params naming compound (flatten to a path first), too-complex (more than 200 commands), or zero-distance; engine failures name their reason (degenerate, empty-input, engine-failed). No new group, no new error code, no ENV_VERSION change (2), no entitlement change. figpea-mcp self-syncs via runtime contract fetch. RE-POINTED twice at the pre-review rebases (was 3.16.0, then 3.17.0): req/REQ-1507 shipped 3.16.0 and req/REQ-1574 shipped 3.17.0 first, so this row takes the next contiguous minor. (REQ-1573)
  • 3.19.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the new layer.toPath descriptor entry feeds the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: a drawn shape converts to an editable path by script (v3 REQ-1575). figpea.layer.toPath(id) turns a rectangle, an ellipse, or similar into an editable path on the SAME layer — id, parent, transform and style preserved — running the editor's own shape-to-path conversion (the same result as the Inspector's Convert to Path control). One undo step; batchable as {method:"toPath",args:[id]}; derived regionFills dropped in the same transaction so getRegionFills re-derives from the new geometry. Arg-shape-first validation: invalid_params for a bad id, not_found for a missing id, a no-op success for an already-path layer (no mutation, no history entry), not_a_path_layer for a non-shape layer, invalid_params naming children for a child-carrying shape (refused, never child-deleted). No new group, no new error code, no ENV_VERSION change (2), no entitlement change. figpea-mcp self-syncs via runtime contract fetch. RE-POINTED at the pre-review rebase (was 3.17.0): req/REQ-1574 shipped 3.17.0 and req/REQ-1573 shipped 3.18.0 first, so this row takes the next contiguous minor. (REQ-1575)
  • 3.20.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the new layer.setVariableWidth / layer.getVariableWidth descriptor entries feed the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: an existing path layer's stroke taper can be written and read back in place (v3 REQ-1577). figpea.layer.setVariableWidth(id, profile) applies a width-point profile through the editor's own variable-width geometry — the same taper the Variable Width tool produces — on the SAME layer (id, parent, transform, style, svg and pointIds-geometry preserved; empty profile clears the taper); one undo step; batchable as {method:"setVariableWidth",args:[id,profile]}; entry ids preserved verbatim and minted when absent; a value-identical write is a genuine no-op with no history entry. figpea.layer.getVariableWidth(id) reads the stored profile back verbatim ([] when no taper — reads are never gate-refused). Validation is arg-shape-first (invalid_params for a bad id, a non-array profile, or a malformed entry), then not_found for a missing id, then the strict path predicate (not_a_path_layer — non-path layers are rejected, never auto-converted), then identity resolution (unknown anchorId → invalid_params naming it), then the tool's own gate (dashed → branching → no-border → zero-width) as invalid_params naming the block, even for a clear. No new group, no new error code, no ENV_VERSION change (2), no entitlement change. figpea-mcp self-syncs via runtime contract fetch. RE-POINTED at the pre-review rebase (was 3.19.0): req/REQ-1575 shipped 3.19.0 first, so this row takes the next contiguous minor. (REQ-1577)
  • 3.21.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the new layerTree includeText param entry plus the widened layerTree/layerById doc strings feed the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: text-layer content reads back from the agent surface (v3 REQ-1469). A text layer's node carries its content — the rawText string plus the textRuns payload, the string slices with their faces resolved against the layer-level font default ({from, to, text, fontFamily?, fontWeight?, fontItalic?, fontSize?}, omit-when-unset per key) — always on session.layerById and on session.layerTree behind the explicit opt-in flag includeText (omitted or false is the unchanged default, byte-identical; a non-boolean flag is refused invalid_params before anything is read). transform is reported at full precision on every read, and a deleted id reads not_found like any other unknown id. No new method, no new group, no new error code, no ENV_VERSION change (2), no entitlement change. figpea-mcp self-syncs via runtime contract fetch. (REQ-1469)
  • 3.22.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the two new text style-key whitelist entries plus their descriptor docs feed the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: per-layer optical-margin trim + ink-align switch for text layers (v3 REQ-1305). Two new text style keys on stylePatch/create with read-back under style: opticalMargin (finite px number >= 0, default 0 — shifts the layer's painted ink toward the box's start edge while the box, reported bounds and selection outline never move) and inkAlign (strict boolean, default false — true resolves the effective trim once, at set time, from the nearest vertical guide/column edge instead of the authored number; no guides nearby resolves to 0). Rejections are invalid_style_value naming the key, nothing applied. The Inspector gains the Optical trim row + ink-align switch at the end of LayerText. Style surface grows to the full 48-key round-trip. No new method, no new group, no new error code, no ENV_VERSION change (2), no entitlement change. figpea-mcp self-syncs via runtime contract fetch. (v3 REQ-1305; Janitor 2026-10-11 successor-sync).
  • 4.0.0 (AGENT_API_CONTRACT_VERSION): Breaking MAJOR (not a minor — the saved-file format and the read-back contract both move, so the published manifest is not byte-identical) — storage + read-back: shape params move from flat top-level layer fields into a namespaced shapeParams (v3 REQ-1403). .fp v5 files migrate on open (PROJECT_SCHEMA_LATEST 5 → 6); FigpeaLayerNode gains shapeParams (the stored namespace verbatim — rwidth/rheight/roundCornerRadius, rx/ry, x2/y2, points, starPoints/innerRadius/outerRadius, the arc record) and the flat top-level points passthrough is removed. figpea.layer.create keeps its flat input spelling. Behaviour-preserving: values are moved, never recomputed. figpea-mcp self-syncs via runtime contract fetch.
  • 5.0.0 (AGENT_API_CONTRACT_VERSION): Breaking MAJOR (not a minor — the published contract is regenerated from a new source, so the manifest is not byte-identical) — generated source: ParamSpec[] generates the inspector, the agent-API schema and the param lists (v3 REQ-1405). CREATE_KIND_SCHEMA is assembled from the shape registry (geometric specs incl. construction-only rect.x/y + ellipse.cx/cy, kind-level createExtra, arc scalar/ring specs); the inspector's arc rows generate from the same specs; create.doc's geometry enumeration and the invalid_geometry catalog line re-pin to the derived extent set. Byte-equivalent by construction and proved live (describe()-diff + five-shape inspector + save-round-trip e2e). figpea-mcp needs no code change (content-level contract change).
  • 5.1.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the new flower schema entry, kind enum, create.doc enumeration and flower bullet all feed the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: new flower create kind, the first shape authored end to end through the shape-definition platform (v3 REQ-1407). layer.create('flower', {m, n, rx, ry}) builds a parametric flower (superellipse-family radial shape, r(θ) = (|cos(mθ/4)|^n + |sin(mθ/4)|^n)^(-1/n)) with schema-generated defaults (m 8, n 4, rx/ry 50); out-of-range or non-finite values are rejected with invalid_geometry naming the prop; the toolbar gains the derived Flower button and the inspector four generated scalar rows; read-back reports params from shapeParams. At n = 2 the shape is a circle of the expected radius (m has no effect — intended, not a bug); n is clamped to its declared range so the n → 0 discontinuity can never produce NaN geometry. A flower is parametric — no editable anchor points; hand-editing its outline is not supported. No new method, no new group, no new error code, no ENV_VERSION change (2), no entitlement change. figpea-mcp needs no code change (content-level contract change, verified by hand).
  • 5.2.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the changed placement doc strings feed the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: a both-axes parent-local placement inside a rotated, mirrored or sheared parent is refused instead of silently relocating the layer (v3 REQ-1541). Reading a layer's parent-local rect and handing those numbers straight back to setLocalPosition (or setPosition with space: "local") moved the layer — 20 px at 30 degrees for a 200x40 rect — while returning success, because a point pushed through the parent's matrix and an axis-aligned box re-expressed in the parent's frame coincide only for scale+translation parents. That round-trip shape is now refused with the existing invalid_position code, naming the calling method, the parent, its orientation matrix and the world-space round trip (bounds ↔ setPosition) that stays exact under any rotation; the contract's read-then-write sentence states the limit in the same sentence. Untransformed, translated and positively-scaled parents keep their exact round trip. No new method, no new group, no new error code, no ENV_VERSION change. figpea-mcp self-syncs via runtime contract fetch; site mirrors via agentContractSync. RE-POINTED at the post-GRP-1403 rebase (was 3.23.0): v3 mainline shipped 4.0.0 (REQ-1403), 5.0.0 (REQ-1405) and 5.1.0 (REQ-1407) first — this row takes the next contiguous minor 5.2.0. (REQ-1541)
  • 5.3.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — five new layer.* descriptor entries feed the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: individual path anchors can be added, removed, moved and smoothed in place (v3 REQ-1576). figpea.layer.insertAnchor(id, point) / deleteAnchor(id, index) / setAnchor(id, index, point) / setHandle(id, index, side, point) / setMirrorMode(id, index, mode) edit one anchor or handle on the SAME layer — id, parent, transform and style preserved, so prototype interactions, component bindings and selection survive; one undo step each; batchable through batch(ops); point identities stable so follow-up anchor edits land on the right points. Anchors are addressed by command index except insertAnchor (point-projection form); setHandle promotes handle-less anchors through the Bend smoothing and mirrors the opposite handle per the anchor's effective mirror mode ("none" / "angle" / "angle-length"). An insert/delete invalidates regionFills (subpath structure changes — re-list regions afterwards); an in-place reshape keeps regionKeys stable. Validation: not_found for a missing id, not_a_path_layer for a non-path layer, invalid_params naming the offending value, and nothing is mutated on refusal. No new group, no new error code, no ENV_VERSION change (2), no entitlement change. figpea-mcp self-syncs via runtime contract fetch. RE-POINTED five times at the pre-merge rebases (was 3.18.0, then 3.21.0, then 3.22.0, then 3.23.0 taken knowingly over live req/REQ-1541's claim): mainline has since shipped the GRP-1403 group + 5.2.0 (REQ-1541), so this row takes the next contiguous minor 5.3.0. (REQ-1576)
  • 5.4.0 (AGENT_API_CONTRACT_VERSION): Additive MINOR (not a patch — the widened format enum plus the changed project doc strings feed the manifest hash, so the published manifest is not byte-identical) — behaviour + documentation: export.project gains format: "ai" — whole-project Illustrator export (v3 REQ-1590). export.project({format: "ai"}) saves the active project as a PDF-compatible Illustrator file whose portable pages open in Illustrator and PDF readers, while Figpea reopens the same file with all layers and editing intact; the bytes return directly as base64 with mime application/pdf and a <name>.ai filename (never a browser download); gated on the existing export.pdf claim exactly like the pdf arm (entitlement_required when unentitled). Export approximations are committed to the live project and read back on report.diagnostics() as unsupported_node warnings (existing code, warning severity). No new method, no new group, no new error code, no ENV_VERSION change (2), no entitlement change beyond the reused export.pdf gate. figpea-mcp self-syncs via runtime contract fetch. RE-POINTED at the pre-merge rebase (was 5.2.0 at task time): v3 mainline shipped 5.2.0 (REQ-1541) and 5.3.0 (REQ-1576) first — this row takes the next contiguous minor 5.4.0. (REQ-1590)

figpea.describe()

figpea.describe()

Signature: describe(): DescribeIndex and describe(selector: string): GroupDescriptor | MethodDescriptor | Record<string, string> | { argsChars: number } | 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, the enumerable errorCodes keys, and limits; 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; describe("limits") returns the published argument budget. 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.

Per-call argument budget

Every contract call carries one budget: the total serialized size of its args. Past it the call is refused with arg_size_exceeded and nothing is applied, so there is no partial result to recover from — a layer.batch past the limit loses the whole batch, not just its tail.

The limit is published, not folklore:

figpea.describe().limits        // { argsChars: 22000 } on the bare index
figpea.describe("limits")       // the same { argsChars: 22000 }, drilled

argsChars is the total serialized size of the call's args in UTF-16 code units of JSON.stringify(args) — the same measurement the editor's own guard compares, read from the same constant, so a call sized under it will not be refused. The name says chars rather than bytes deliberately: a code-unit count published as "bytes" would understate a non-ASCII payload's real wire size and point you past the transport's own cap. Binary members are the one exception: session.openFile's bytes (ArrayBuffer | Uint8Array | number[]) is charged its real payload bytes instead — the same byte sequence estimates identically in every member — so size those by byte count, never by JSON.stringify length (which over-counts a Uint8Array roughly eightfold and counts an ArrayBuffer as almost nothing).

Three things to know before you size a call:

  • This is a limit on every call, whatever the payload. It is not an image-specific footnote. layer.batch is where large args are normal — a dense page of shape and text ops reaches the limit at well under 90 ops — and image bytes reach it far sooner, which is the only case the image-staging remedy below is for.
  • Aim for roughly two-thirds of the limit, then split the op list across calls of that size and pass ids from earlier results literally ("$<index>" / { "opResult": n } resolve within a single call, so a chunk boundary needs real ids). Each chunk is its own undo step, so history.undo() steps back through them in order — splitting trades one atomic undo for a correctly sized call, and no amount of chunking makes a very large page one undo step.
  • Non-ASCII payloads reach the cap earlier. The limit counts characters; the transport caps bytes. A payload carrying emoji or other multi-unit characters can measure comfortably under argsChars while its wire size is several times that, so budget lower for those.

For image payloads specifically, stage the bytes on a local CORS origin (http://127.0.0.1:<port> with Access-Control-Allow-Origin: *) and pass that http://… URL instead of an inline data: URI — see layer.setImageFill. The editor's own refusal names that remedy only when your payload actually carried image bytes, so the message you get tells you which situation you are in.

// 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 SKILL.md bootstrap (see "Download the Skill" / "Connect via figpea-mcp" below) is transport-only and, since REQ-1534, a pointer rather than a copy: it names GET <editor-origin>/agent/skill.md and carries no body of its own, because a copy frozen here would be wrong between editor releases. The actual reference body lives in the runtime itself, and as a 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().

Damaged .fp archives get a specific code: fp_entry_damaged when an archive entry's bytes disagree with the archive's own directory -- a failed checksum, a declared size that does not match, or a bit-stream that does not decompress -- and fp_entry_missing when a required entry (main.fpe / repo.json) is absent, which is what a truncated download or a partial copy looks like. message names the entry, the size the archive declared for it, and the size that actually came out. Branch on these two when you want to know why a .fp archive failed; every other reason keeps returning open_failed with its existing message, byte for byte (REQ-1304).

layerTree()

Signature: layerTree(space?: "world" | "local", includeText?: boolean): Promise<{ ok: true, value: FigpeaLayerNode } | { ok: false, code: string, message: string }>

space (REQ-1479): the coordinate frame every node's bounds is reported in; the rule itself is stated above and in full on layerById(), and is not restated here. Omitted or "world" is the world rect, unchanged in output and cost. "local" resolves each node against that node's own parent, so one flag covers a whole tree and a folder nested inside a page reports a third basis that is neither page-local nor world. The project root has no parent, so its "local" rect equals its world rect by construction rather than by a special case. Any other value is refused with invalid_params naming the value it got, before anything is read. "local" is also the expensive form — one unmemoized bounds computation per node where the default uses a memoized one — so on a large document prefer layerById(id, "local") or find(sel, "local").

includeText (REQ-1469): opts each text layer's node into its textRuns payload — the string slices with their resolved fonts and sizes (see the node shape). Omitted or false is the unchanged default: no textRuns key anywhere in the tree. A tree read over hundreds of text layers is the expensive form, so prefer layerById for targeted copy verification. A non-boolean flag is refused with invalid_params naming the value it got, before anything is read.

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", "adjustment"
  shape?: string;                // present only for shape layers
  shapeParams?: {                // present only for shape layers carrying params — the stored namespace verbatim: rwidth/rheight/roundCornerRadius (rect), rx/ry (ellipse), x2/y2 (line), points (polygon), starPoints/innerRadius/outerRadius (star), arc (arc record); omit-when-unset
    rwidth?: number; rheight?: number; roundCornerRadius?: [number, number, number, number];
    rx?: number; ry?: number; x2?: number; y2?: number;
    points?: Array<{ x: number; y: number }>;
    starPoints?: number; innerRadius?: number; outerRadius?: number;
    arc?: { r: number; startAngle: number; endAngle: number; thickness: number; rx?: number; ry?: number; sweep?: number };
  };
  visible: boolean;
  transform: [a, b, c, d, e, f]; // 2D affine matrix
  bounds: {                      // `bounds` is a **world**-space axis-aligned rectangle — the same basis `canvas.getViewport()`, `canvas.layerRect()` and `session.layersAtPoint()` report — and `layer.setPosition(id, {x, y})` with `space` omitted is the one write call that takes those same world numbers. Every other placement input is **parent-local**: `layer.setLocalPosition(id, {x, y})`, `layer.setPosition(id, {x, y, space:"local"})`, `layer.create(kind, {x, y})` and a transform's `e,f` — so on a page auto-placed at world x 2580, a child created at page-local `x: 110` reads back `bounds.left` 2690, and handing that number straight to `setLocalPosition` throws the layer 2580px off the page while returning `ok: true`. Pass `space:"local"` to `layerTree()`/`layerById()`/`find()` and the returned rect is the same `calcBounds({relativeTo: parent})` local rect `setLocalPosition` consumes, so read-then-write needs no arithmetic, except inside a rotated or mirrored parent, where the parent-local placement calls are refused with `invalid_position` naming the parent — use the world-space round trip (`bounds` ↔ `setPosition`) there, which stays exact under any rotation. Inside a rotated or mirrored parent the world and parent-local rects are not translates of each other at all, so `space:"local"` — not subtraction — is the only exact conversion. 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
  textRuns?: {                   // REQ-1469 — present only for text layers, and only when opted in: always on layerById, on layerTree only with includeText set (omitted otherwise)
    from: number;                // slice start in rawText
    to: number;                  // slice end (exclusive) — text === rawText.slice(from, to)
    text: string;                // this run's slice of the string
    fontFamily?: string;         // resolved against the layer-level font default — present only when defined
    fontWeight?: string;         // resolved ditto
    fontItalic?: boolean;        // resolved ditto
    fontSize?: number;           // resolved ditto
  }[];
  arc?: { mode: "ring" | "donut"; r: number; startAngle: number; endAngle: number; thickness: number; rx: number; ry: number; sweep: number }; // present only on arc layers
  documentId?: string;           // the active design's id -- on EVERY node (see Durability)
  documentName?: string;         // the active design's project name
  children?: FigpeaLayerNode[];  // present only for container layers
}

mode is the discriminant. Ring layers report fraction angles as authored (startAngle normalized onto [0,1)), with rx/ry/sweep round-tripping; donut layers keep their degree angles, report rx = ry = r, and compute sweep as (endAngle − startAngle)/360 -- so coverage is verifiable from sweep without pixel-measuring. thickness reads back defaulting to 14 on ring layers (an explicit thickness/style.strokeWidth wins), 12 on donut layers.

const ring = await figpea.layer.create("arc", { rx: 90, startAngle: 0, sweep: 0.78 });
const node = await figpea.session.layerById(ring.value.id);
console.log(node.value.arc);
// { mode: "ring", rx: 90, ry: 90, startAngle: 0, sweep: 0.78, endAngle: 0.78, … }
// A ring covering 78% from 12 o'clock clockwise -- verified from `sweep`, no pixels measured.

Every node names its document. documentId (the project's own id) and documentName are stamped on every node, not only the root -- so a node returned by find() is self-describing about which design it came from, which a root-only stamp could not do. documentId is stable while one design stays loaded and differs between two open designs; read it with session.document() (an O(1) read) and pin it with session.expectDocument() to stop a mutation landing in the wrong one. The coordinate space of bounds (REQ-1479): bounds is a world-space axis-aligned rectangle — the same basis canvas.getViewport(), canvas.layerRect() and session.layersAtPoint() report — and layer.setPosition(id, {x, y}) with space omitted is the one write call that takes those same world numbers. Every other placement input is parent-local: layer.setLocalPosition(id, {x, y}), layer.setPosition(id, {x, y, space:"local"}), layer.create(kind, {x, y}) and a transform's e,f — so on a page auto-placed at world x 2580, a child created at page-local x: 110 reads back bounds.left 2690, and handing that number straight to setLocalPosition throws the layer 2580px off the page while returning ok: true. Pass space:"local" to layerTree()/layerById()/find() and the returned rect is the same calcBounds({relativeTo: parent}) local rect setLocalPosition consumes, so read-then-write needs no arithmetic, except inside a rotated or mirrored parent, where the parent-local placement calls are refused with invalid_position naming the parent — use the world-space round trip (bounds ↔ setPosition) there, which stays exact under any rotation. Inside a rotated or mirrored parent the world and parent-local rects are not translates of each other at all, so space:"local" — not subtraction — is the only exact conversion. Geometry is always matrix-first (transform + bounds), never rect-based, and style only ever carries the whitelisted keys documented under stylePatch below -- the full 48-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. Adjustment layers (.psd/.psb) -- what the node reports, and what it does not. A decoded adjustment layer arrives as layerType: "adjustment", and all 16 shipped types render natively: Brightness/Contrast (brit), Levels (levl), Curves (curv), Exposure (expA), Vibrance (vibA), Hue/Saturation (hue2 and hue ), Colour Balance (blnc), Black & White (blwh), Photo Filter (phfl), Channel Mixer (mixr), Colour Lookup (clrL), Invert (nvrt), Posterize (post), Threshold (thrs), Gradient Map (grdm) and Selective Colour (selc). The four-character keys are Photoshop's own identifiers -- what the decoder matches inside the file -- and an agent cannot read them or the names back off this node. Which of the sixteen a given layer is, is not readable back, because style is serialized through a fixed 48-key whitelist that contains no adjustment type; so an agent identifies an adjustment by its layerType, and reads the type name from the layer panel, which gives each one a type-specific icon with a tooltip naming it (see Managing PSD Adjustment Layers). Adjustment layers are decoded, not authored: no create() call accepts the kind adjustment, and no stylePatch parameter selects an adjustment type or changes which one a layer is. The layer is not sealed, though -- the ordinary whitelisted style keys still apply to it, so stylePatch(id, { opacity }) and setVisible both work on an adjustment layer exactly as they do on any other.

layerById(id)

Signature: layerById(id: string, space?: "world" | "local"): Promise<{ ok: true, value: FigpeaLayerNode } | { ok: false, code: string, message: string }>

space (REQ-1479) — the read a read-then-write performs, so the rule lives here in full. See The coordinate space of bounds above. Pass "local" for the parent-local rect setLocalPosition(id, { x, y }) consumes, and the round trip needs no arithmetic except inside a rotated or mirrored parent, where the parent-local placement calls are refused with invalid_position naming the parent — use the world-space round trip (bounds ↔ setPosition) there, which stays exact under any rotation:

const world = await figpea.session.layerById(id);            // WORLD bounds
const local = await figpea.session.layerById(id, "local");   // PARENT-LOCAL bounds
await figpea.layer.setLocalPosition(id, { x: local.value.bounds.left, y: local.value.bounds.top });

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 is unknown or deleted — a deleted id never reads back a stale node. (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.

Text content (REQ-1469). A text layer's node always carries its content — no opt-in needed on the single read: the rawText string plus the textRuns payload (the string slices with their resolved fonts and sizes; each run's text is rawText.slice(from, to) and a partially-styled run still reports a complete face resolved against the layer-level font default). String-level checks — which copy a layer carries, whether two layers agree — read these fields. transform is reported at full precision on every read.

Layout box vs ink box (REQ-1507). For text layers, bounds is the layout box — the fixWidth-wide box (or the natural measured box for point text) the glyphs are laid out in, not their ink extent. The same node carries inkBounds: the painted-ink rect (the wrapped lines' measured widths at their align offsets, first-line ascent to last-line descent), in the same space space selects, so the two boxes are directly comparable. Blank text and non-text layers carry no inkBounds key.

session.document()

Signature: document(): Promise<{ ok: true, value: { documentId: string, documentName: string } } | { ok: false, code: string, message: string }>

Returns the active document's identity in O(1) -- two reads off the live project, no tree walk and no serialization. This is what figpea_status reports as document, and it exists rather than reusing layerTree() because that call serializes every node: on a large PSD it takes seconds, and status is the call an agent makes when something is already wrong.

documentId is stable for as long as one design stays loaded and differs between two open designs, so reading it before a mutating batch and again afterwards tells you whether the tab switched underneath you. It is per-load: reopening the same file mints a new id. That is deliberate -- a reload discards every unsaved layer, so an id that survived it would report "same document" for a tab whose whole in-memory state was just thrown away, which is the incident this feature exists to prevent rather than a fix for it.

Returns { ok: false, code: "not_found" } when no design session is active.

session.expectDocument(documentId?)

Signature: expectDocument(documentId?: string | null): Promise<{ ok: true, value: { expectedDocumentId: string | null, activeDocumentId: string | null } } | { ok: false, code: string, message: string }>

Arms an expected-document identifier. Until it is cleared, a mutation is refused with document_mismatch when the active document is a different one -- and nothing is created, moved or deleted in either design. The check runs before the method is dispatched, so the method never runs at all: there is no partial application and nothing to roll back. The refusal names both identifiers, so the corrective action is in the message.

The Figpea editor canvas with design A open — an indigo panel on a page, framed with the surrounding workspace. A callout chip over the canvas shows what the call returned: session.document() reports documentId and documentName for the open design, and the same two fields are stamped on every node session.layerTree() returns.

The Figpea editor canvas now showing design B — an amber panel on a page where design A's panel was indigo, the visible mark of the document switch. A layer.create still aimed at design A was refused with document_mismatch, leaving design B with exactly the two layers it had before the call; the capture asserts the refusal and the unchanged layer count rather than photographing them.

An animated capture of the swap: design A's indigo panel is on the canvas with its identifier armed by session.expectDocument, the tab switches to design B, and the amber panel replaces it — a layer.create still aimed at A is then refused with document_mismatch and design B's layer count is unchanged. The paired end-state still shows design B after that refused call.

End-state still showing design B after that refused call, its amber panel on the canvas and its layer count unchanged.

This call is never itself refused and never raises the mismatch. It always answers { expectedDocumentId, activeDocumentId }, so a stale expectation is recoverable in one call: call it to see where you actually are, then re-aim or clear. That exemption is what keeps the guard from becoming a deadlock.

Omit documentId (or pass null) to clear it. With nothing armed, every method behaves exactly as it did before. Read methods are never guarded, so layerTree(), find() and document() stay usable while a mismatch stands.

const doc = await figpea.session.document();
await figpea.session.expectDocument(doc.value.documentId);
// ...mutations now refuse rather than landing in the wrong design...
await figpea.session.expectDocument();   // clear when done

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, space?: "world" | "local"): Promise<{ ok: true, value: FigpeaLayerNode[] } | { ok: false, code: string, message: string }>

space (REQ-1479): same rule as layerById(), with one flag applying to the whole call — so "local" is resolved against each node's own parent for each node separately, and a folder nested inside a page reports a third basis that is neither page-local nor world. The selector slot comes first, so find({ name: "x" }, "local") is the call and find("local") is a string in the SELECTOR slot, which the per-parameter type gate refuses rather than silently reading as a space; pass find(undefined, "local") to read every node in a chosen frame.

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, any in-flight image decoding finished, and the canvas has drawn a settled frame -- the render-settle signal for an agent's mutate -> settle -> observe loop. The pending work it covers is exactly four classes -- an in-flight openFile document swap, an in-flight corner-radius fillet, pending webfont fetches, and in-flight image (pattern-fill) decoding -- not everything that could be in flight. 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.

Animated WebP of the Figpea canvas: a 760 by 400 rectangle holding its flat navy fallback colour, figpea.layer.setImageFill attaching a large photograph to it, and the decoded image replacing that flat colour on the canvas once the settle completes. The paired end-state still is the same rectangle with the photograph in place.

End-state still showing the same 760 by 400 rectangle with the decoded photograph in place on the Figpea canvas, taken from the same run as the animation.

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; bare call captures at on-screen size -- pass { id } to judge or measure
}

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, warning?: string } } | { ok: false, code: string, message: string }>

The success value may carry an optional warning: a "text" layer created with a style.lineHeight below its text's measured single-line height still creates successfully and still stores the value you gave it, but every line after the first will be overprinted -- so the returned string names the measured minimum and the px conversion. It is a warning, never a refusal: the existing lineHeight < 1.5 rejection is unchanged, and the warning is silent when there is no leading, the text is blank, or the height could not be measured (see the version-history row that introduced it). A second warn-only source (REQ-1458): a "text" layer created with a style.fixWidth whose manual line breaks do not survive the box -- a manual line wider than fixWidth that the renderer re-wrapped -- carries the same warning naming the re-wrapped manual line(s) with measured px vs box px; both warnings present join with "; ", and the layer is still created with the text passed.

Creates a new layer of the given kind -- one of "rect", "ellipse", "line", "polygon", "path", "text", "folder", "page", "image", "icon", "arc", "flower" -- 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": x? (default 0), y? (default 0) -- parent-local position, same meaning as "image"'s; rwidth? (default 100), rheight? (default 50)
  • "ellipse": cx? (default 0), cy? (default 0) -- center in the same parent-local coordinates as "rect"'s x/y; rx? (default 50), ry? (default 50)
  • "line": x?/y? (no default) place the line's box; x2? (default 100), y2? (default 0) -- the second endpoint, running from the local origin to (x2, y2)
  • "polygon": points?: { x: number, y: number }[]
  • "path": path?: string (SVG path data, default "M0 0") — absolute and relative commands both render; d?: string is the documented alias (REQ-1484). A present value must parse to usable geometry or the call is rejected with invalid_geometry naming "path" (REQ-719); a present value that parses but yields no drawable geometry is rejected the same way, and only "", "M0 0" and drawable data keep working. A path's d is authored in the layer's own local space — it is stored as given, never rebased to its bounding-box origin. x/y (no default) place the path's box top-left at (x, y) in parent-local coordinates, and the reported bounds are the drawn shape in world coordinates. To put a mark at parent-local (500, 700): create("path", {path: "M 0 0 H 100 V 100 H 0 Z", x: 500, y: 700}) — not by baking 500/700 into the d. A path layer is re-pathed in place with layer.setPath(id, d) — delete-and-recreate is no longer needed; the same layer id, parent, transform and style survive, and its regionFills are invalidated. (see setPath(id, d)).the path's box.
  • "text": text?: string (default "") -- text content. Plus x?/y? (no default) place the text box
  • "folder": x?/y? (no default) place the folder. An empty folder reports a degenerate 0x0 box, so its x/y move the folder's own origin -- visible as soon as it has children.
  • "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. Unless you give it a background, a page created here arrives with a white background, matching a page you add by clicking — see setPageFill to choose a different one or to ask for a transparent page.
  • "image": url? or bytes? (exactly one of the two is required; there is no default for either, unlike every other kind), or filePath? (a local filesystem path, which via figpea-mcp is mapped to the loopback bridge URL), optional mimeType? (used when constructing from bytes), plus x?/y? like "rect"'s (default 0/0) and rwidth?/rheight? like "rect"'s (no static default -- when omitted, the layer takes the decoded image's own natural pixel dimensions), 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. deferred? (boolean, default false) omits the fetch entirely -- omit url/bytes, or pass deferred: true, to create a visible placeholder sized to rwidth/rheight (default 200x150) that setImageFill can target afterwards, which places structure in a batch without a fetch failure rolling back unrelated operations
  • "icon": glyph? (string, default "heart") selects a glyph from the curated set -- the machine-readable list of every valid glyph is describe("layer.create").params.props.byKind.icon.glyph.enum, and an unknown glyph is rejected with { ok: false, code: "invalid_params" } naming the offense and listing every valid glyph, and nothing is created. name? is the layer's own name for this kind as for every other -- it is what session.find({name}) matches, so two icons using the same glyph can be told apart (glyph:"search", name:"hero-search-icon" and glyph:"search", name:"rail-search-icon"). For backward compatibility name remains a glyph alias: create("icon", {name: "search"}) still selects the search glyph exactly as before and also names the layer search; a name that is not a glyph is simply the layer name and leaves the glyph at its default. Passing a glyph together with a glyph-valued name is rejected with { ok: false, code: "invalid_params" } naming both props. size? (number, default 24) is the rendered size in pixels and must be a positive finite number, else { ok: false, code: "invalid_geometry" }. Plus x?/y? (no default) place the glyph's box
  • "arc": r? (default 50), startAngle? (default 0), endAngle? (default 270), thickness? (default 12) for the legacy degree donut. For kind "arc", a present "sweep" selects ring mode — "sweep" is a number holding fractions of a full turn, with "rx" and "ry" (ellipse radii) and "startAngle" (0 = 12 o'clock, clockwise positive) as its siblings, rendered as a stroked centerline arc (fillType none, strokeWidth from style, else thickness, else 14; sweep 1 is a closed ring); without "sweep" the legacy degree donut applies unchanged — "r", "startAngle", "endAngle" and "thickness" keep their documented defaults, and "endAngle" together with "sweep" is rejected with invalid_params. Plus x?/y? (no default) place the arc's box -- the donut is centred on its local origin, so this moves the visible ring, not a path point. rx? falls back to r when given, else 90; ry? defaults to rx -- so a bare create("arc", { sweep }) is a circle of radius 90. startAngle? defaults to 0 (= 12 o'clock), shared with the legacy donut; its unit is fixed by mode, never sniffed from magnitude -- a fraction beside sweep, degrees without it -- and ring values normalize onto [0,1). sweep has no default: absent means the legacy degree donut, unchanged, while sweep: 1 is a closed ring. Ring geometry validates before anything is created -- a non-finite or out-of-range sweep (it must be in (0,1]), a non-positive or non-finite rx/ry, or a non-finite startAngle rejects the call with { ok: false, code: "invalid_geometry" }, and nothing is created
  • "flower": m? (lobe count, default 8), n? (exponent, default 4), rx? (default 50), ry? (default 50) -- the superellipse-family radial shape, r(θ) = (|cos(mθ/4)|^n + |sin(mθ/4)|^n)^(-1/n), the first shape authored end to end through the editor's shape registry. At n = 2 the shape is a circle of the expected radius -- |cos φ|² + |sin φ|² = 1 for all θ, so m has no effect. This is intended, not a bug. n is clamped to its declared range so the n → 0 discontinuity can never produce NaN geometry; out-of-range or non-finite values are rejected with { ok: false, code: "invalid_geometry" } naming the prop, and nothing is created. It declares no x/y: placement goes through setPosition/transform like "polygon". A flower is parametric -- its outline is derived from m and n, so it has no editable anchor points. Hand-editing a flower's outline is not supported; convert or trace it if you need freeform control. (v3 REQ-1407)

Four flower shapes on a Figpea canvas at the default exponent n = 4: five, eight and twelve lobes (m = 5, m = 8, m = 12, left to right), plus the intended-degenerate n = 2 circle on the right, each with selection bounds hidden so the silhouettes read cleanly.

Animated: the Lobes inspector input driven from 4 to 8 while the selected flower's petals multiply live on the canvas.

End-state still showing the 8-lobe flower selected with the inspector reading Lobes 8, taken from the same run as the animation.

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. Relative commands (h/v/etc.) render identically to their absolute equivalents. 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).

Kind-applicability (REQ-403): Props that don't apply to the requested kind are rejected with invalid_transform and nothing is created — the applicable set is this method's common shape props merged with the kind-specific props for the requested kind (byKind[kind]). This is method-level content validation inside the create() implementation, not entry-time argument validation.

Position props (REQ-1302, widened by REQ-1281, narrowed by REQ-1311): x and y are not common create props — they are declared in the per-kind schema of "rect", "line", "path", "text", "folder", "image", "icon" and "arc", and on every one of those kinds a single create(kind, {x, y, …}) places the new layer in the same call: its bounds top-left lands exactly on (x, y) in parent-local coordinates, the same numbers on the same basis as setLocalPosition(id, {x, y}), so no second call is needed. It is the visible box that moves, not the transform's e,f — the two differ whenever a kind's local geometry origin is not its bounds top-left (a path whose d starts at 10,10, a glyph scaled by size/24, an arc centred on its local origin), which is why placement is bounds-relative rather than a translate. A missing axis is 0, matching "rect"/"image", and an explicit transform wins over x/y outright. The six kinds REQ-1281 added deliberately carry no default: 0 would be a false claim about where a path/glyph/arc actually starts. "ellipse" takes cx/cy instead and "line" also takes x2/y2; the kinds declaring no top-level position prop ("ellipse", "polygon", "page" and "flower") still reject x/y — "polygon"'s points entries are {x, y} vertex data nested one level down, not a placement — so create("ellipse", {x: 120, y: 250, rx: 40, ry: 20}) comes back invalid_transform naming x, y and nothing is created, which is a bridge round trip better spent elsewhere. Two ways out: pass the position in the same call as transform: [1, 0, 0, 1, x, y] (its e,f are parent-local), or create first and then setPosition(id, {x, y}) (world) or setPosition(id, {x, y, space:"local"}) (the same parent-local numbers). In every parent, including a folder nested in a page: omitting x/y applies no placement (no default is assumed), and the reported bounds are the drawn world bounds either way — never raw geometry coordinates.

layer.create accepts an optional world-space position: {x, y} alongside the existing parent-local placement. When supplied, the new layer's bounds top-left lands exactly on (x, y) in world coordinates — the same numbers and the same promise as setPosition(id, {x, y}) — with the page-offset math done for you, so creating on a page at a non-zero offset needs no manual world = pageOffset + local conversion and no second call. When omitted, creation behaves exactly as before (parent-local placement). One create remains one undo step either way.

Prop placement (REQ-807): create() accepts geometry/identity props top-level (parentId/index/name/transform plus the kind's own byKind fields); every visual property — fontFamily, fontSize, fontWeight, cornerRadius, fixWidth, align, and the rest of the style whitelist — belongs inside style{}, and a top-level visual prop is rejected with invalid_transform naming style.<prop> and creates nothing. When the offender is a known style key the message appends a Did you mean style.<key>? hint.

await figpea.layer.create('text', { parentId, text: 'Hi', style: { fontFamily: 'Cinzel', fontSize: 44 } }); // note style.fontFamily, not a top-level fontFamily

Text style units (REQ-1436): three numeric style props take a unit the key name does not suggest, and reading any of them as unitless is a silent wrong render rather than an error — every one of those calls still returns ok: true. All three go inside style{} (see the prop-placement note above).

  • lineHeight is absolute px, never a multiplier. It is the advance applied to every line after the first, so a three-line block measures natural + 2 × lineHeight — 1.6 is 1.6 pixels of leading and the lines overprint each other. Convert a multiplier with lineHeight = fontSize × multiplier (16px at 1.6 → lineHeight: 26); below 1.5 is rejected with invalid_style_value. Below the text's measured single-line height the call still succeeds and carries a warning naming the minimum.

    Two three-line text blocks set in 34px Public Sans: the upper block's lines overprint each other from a lineHeight of 1.6, the lower block's lines are correctly spaced from a lineHeight of 56

  • letterSpacing is thousandths of an em. Each unit is 1/1000 of fontSize, so 6 at 26px is 0.156 px of tracking per gap — visually nothing — while 600 is 15.6 px and reads as visibly loose tracking. Writing 6 meaning "six pixels" is the trap; the unit is letterSpacing / 1000 × fontSize.

    The word TRACKED at 26px twice: letterSpacing 6 leaves the tracking imperceptible, while letterSpacing 600 sets visibly loose tracking

  • fixWidth is a wrap width in px, and leaving it off is a visible default. Set it and lines wrap to that width; omit it and the layer is single-line point text that never wraps, so a longer line overflows and is clipped (cut off) rather than folding onto a second line.

    The same sentence set at 26px: without fixWidth it runs past the artboard edge and is cut off mid-word, while with fixWidth 420 it wraps onto a second line

describe() now names the unit of every other numeric style prop — for layer.create and for layer.stylePatch alike: opacity / fillOpacity are fractions 0..1 and not percents, fontSize / strokeWidth / cornerRadius / fixHeight / tabWidth / tabs / dashArray / dashOffset are px, and miterLimit is a ratio of strokeWidth (2.61.0).

Result: the newly created layer's id.

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, space?: "local" | "world" }): Promise<{ ok: true, value: { bounds: { left: number, top: number, right: number, bottom: number }, transform: [a,b,c,d,e,f], warning?: string, outOfClip?: boolean } } | { ok: false, code: string, message: string }> and setTransform(id: string, matrix: [a, b, c, d, e, f]): Promise<{ ok: true, value: { bounds: { left: number, top: number, right: number, bottom: number }, transform: [a,b,c,d,e,f], warning?: string, outOfClip?: boolean } } | { ok: false, code: string, message: string }> — or, on a host that cannot nest arrays, setTransform(id: string, matrix: "[a, b, c, d, e, f]"): the same six numbers as a JSON string, declared by the contract on params.matrix.stringJson. The array form above is unchanged and remains the default. See Hosts that collapse nested arrays.

setPosition places a layer’s visible box: the layer’s bounds top-left — the same bounds that layerById/layerTree report — lands exactly on {x,y}, leaving rotation/scale untouched; repeating the same call is idempotent. Coordinate space is explicit via the space option: space: "local" positions relative to the parent (parent-local coordinates — identical semantics to setLocalPosition); omitted or space: "world" is world coordinates (not parent-local) — contrast create(kind, {transform:[1,0,0,1,x,y]}) which interprets e,f in parent-local coordinates, so passing the same local numbers with and without the option displaces by the parent’s world origin. Inside a rotated or mirrored parent the parent-local placement calls (setLocalPosition, setPosition with space: "local") are refused with invalid_position naming the parent — no parent-local write is exact there — so use the world-space round trip (bounds ↔ setPosition) on the same parent instead, which stays exact under any rotation. Either axis may be omitted and the omitted one is preserved in the returned world bounds; at least one axis is required. An unrecognised key in pos (yy is not y — and top is not y on the shared parent-local form) is refused with invalid_params naming it before anything moves, so ok:true means the position landed. setTransform sets the full affine matrix verbatim (no decompose/recompose), so a read immediately afterward deep-equals exactly what was set. Result: { bounds, transform, warning?, outOfClip? } on success — world bounds just placed plus the layer’s new local transform, with the optional out-of-clip warning beside present-and-true outOfClip when the layer lands wholly outside its parent’s clip rect (both absent otherwise); setPosition returns { ok: false, code: "invalid_position" } when neither axis is supplied, when a supplied axis is not a finite number, or when an omitted axis cannot be preserved — the message names the axis at fault; 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

setPath(id, d)

Signature: setPath(id: string, d: string): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Rewrites the target path layer's d/svg in place — the layer keeps its id, parent, transform and style, so prototype interactions, component bindings and selection wiring survive the edit. A single undo step (withLayerTransaction); batchable as { method: "setPath", args: [id, d] } through batch(ops). No auto-recenter: world-bounds semantics come only from the explicit d. Rewriting the outline invalidates regionFills, so getRegionFills reflects the new geometry (kept fills would address the old geometry's subpath indices onto the new one). Validation mirrors create("path"): an unknown id returns not_found, a non-path layer returns not_a_path_layer, and a malformed d returns invalid_geometry naming "path" with the received value and the parser error (the same validatePathData gate create uses — a d that parses but yields no drawable geometry is rejected the same way). The empty string stays valid. See batch(ops) for the atomic multi-op form and getRegionFills for the invalidation promise.

await figpea.layer.setPath(layerId, "M0 0 L100 0 L50 80 Z");
// same layer, new outline — prototypes, instances, selection wiring intact

Can I change a path's shape without recreating the layer? — Yes: setPath(id, d) rewrites the outline in place; the layer keeps its id, parent, transform, style, and undo history. What happens to region fills when I rewrite a path? — regionFills are invalidated, so getRegionFills reflects the new geometry.

roundCorners(id, radius[, points])

Signature: roundCorners(id: string, radius: number, points?: number[]): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Rounds the corners of a path layer in place with the Round Corners fillet — the scripted counterpart of the toolbar operation: each rounded corner is replaced by a clean tangent arc of the radius you pass, softening a logo, rounding a polygon, or taking the hard edges off an imported path without hand-computing geometry. The layer keeps its id, parent, transform and style, so prototype interactions, component bindings and selection wiring survive the edit. A single undo step; batchable as { method: "roundCorners", args: [id, radius] } (or [id, radius, points]) through batch(ops), with point identities preserved for later edits: untouched anchors keep their ids and new points get fresh ids, so follow-up anchor edits land on the right points.

points lists anchor/command indices to round; omitted or empty rounds every anchor. Only corners between straight segments are rounded: a corner with a curved segment on either side is skipped (and the rest of the selection is still rounded), as are collinear, degenerate and open-path endpoint corners. A radius larger than a corner allows is clamped to the largest fitting radius rather than silently ignored. Re-applying to an already-rounded corner finds nothing eligible — those points now sit on a curved segment — and is refused with invalid_params carrying the per-corner skip reasons, changing nothing.

Validation: an unknown id returns not_found, a non-path layer returns not_a_path_layer, and a non-positive radius, a malformed points list, an explicitly-listed out-of-range index (naming it), or zero eligible corners returns invalid_params — the zero-eligible message names each skipped corner and its reason (e.g. 1: curved), and nothing is mutated. See batch(ops) for the atomic multi-op form and Round Corners for the UI counterpart (including the live, re-editable Corner radius sidebar property, which this method does not write).

await figpea.layer.roundCorners(layerId, 12);
// every straight corner rounded with 12px tangent arcs — same layer, one undo step
await figpea.layer.roundCorners(layerId, 12, [1]);
// only anchor 1 rounded; the rest stay sharp

Does layer.roundCorners differ from the rectangle cornerRadius property? — Yes: roundCorners rewrites path geometry (a rounded path exports as path data), while cornerRadius stays a live re-editable rect property (a rectangle's corner radius exports as CSS border-radius). Can I round only some corners? — Yes, limit to chosen anchor indices with points; unlisted corners stay sharp, and unroundable listed corners are skipped or refused per the error contract, never silently wrong.

The same polygon path before and after figpea.layer.roundCorners: sharp corners on the left, clean tangent-arc fillets on the right.

offsetPath(id, distance)

Signature: offsetPath(id: string, distance: number): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Offsets the target path layer's outline by distance px with the editor's own offset geometry — the same result as the Inspector's Offset Path operation: a positive distance pushes the outline outward, a negative distance pulls it inward. A single undo step; batchable as { method: "offsetPath", args: [id, distance] } through batch(ops). Offsetting the outline invalidates regionFills, so getRegionFills reflects the new geometry (kept fills would address the old geometry's subpath indices onto the new one). An unknown id returns not_found; a non-path layer returns not_a_path_layer (only path layers offset — a rectangle, ellipse, or text layer is rejected, never auto-converted). A zero distance, a boolean-combined (compound) layer, or a path with more than 200 segments returns invalid_params naming zero-distance, compound (flatten it to a path first), or too-complex; an engine failure names its reason (degenerate, empty-input, engine-failed). See Offset Path for the Inspector operation this call runs. See batch(ops) for the atomic multi-op form and getRegionFills for the invalidation promise.

await figpea.layer.offsetPath(layerId, 12);
// the badge outline grows outward — the same geometry as the Inspector's Apply

Animated capture of the Figpea canvas: a badge outline expanding outward into a concentric ring after a layer.offsetPath call with a positive distance. The paired end-state still shows the expanded outline at rest.

End-state still showing the badge on the Figpea canvas with its outline expanded into a concentric ring after the offsetPath call, taken from the same run as the animation.

What happens if my path is too complex? — Paths with more than 200 segments are declined with a clear error instead of freezing the editor; simplify first. Can I offset a boolean-combined group? — No — flatten it to a path first, then offset. Is offsetting undoable from a script? — Yes: one call is one undo step, and inside layer.batch it participates in the batch's all-or-nothing semantics.

toPath(id)

Signature: toPath(id: string): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Converts a drawn shape (a rectangle, an ellipse, or similar) into an editable path on the same layer — the layer keeps its id, parent, transform and style, so prototype interactions, component bindings and selection wiring survive the edit. It wraps the editor's existing shape-to-path conversion — the same result as the Inspector's Convert to Path control. A single undo step (reversed by history.undo()); batchable as { method: "toPath", args: [id] } through batch(ops). Converting the outline invalidates regionFills, so getRegionFills reflects the new geometry (kept fills would address the old geometry's subpath indices onto the new one). A layer already stored as a path is left alone — a no-op success with no mutation and no history entry. An unknown id returns not_found; a layer that is not a convertible shape (text, a group, any other non-shape) returns not_a_path_layer; a shape carrying children returns invalid_params naming children — it is refused rather than child-deleted. See setPath(id, d) to rewrite a path's outline after converting, create(kind, props) for path authoring, and batch(ops) for the atomic multi-op form.

await figpea.layer.toPath(rectId);
// same layer, now an editable path — prototypes, instances, selection wiring intact

Can a script convert a shape into an editable path? — Yes: toPath(id) converts a drawn shape in place; a layer already stored as a path is left alone.

A rectangle shape layer selected on the Figpea canvas before conversion, with its shape properties visible

The same layer after layer.toPath, shown as an editable path with its anchor points visible

setVariableWidth(id, profile)

Signature: setVariableWidth(id: string, profile: Array<{ id?: string, anchorId: string, segmentT: number, width: number }>): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Applies a variable-width profile to a path layer in place — the Variable Width toolbar operation, callable from scripts. The written profile uses the editor's own variable-width geometry — the same taper the Variable Width tool produces — so a scripted taper matches a hand-built one. The profile is the layer's width-point model: each entry addresses a path point by anchorId (discover it with getVariableWidth in a read-modify-write loop, or reuse the healed pointIds), positions itself along the following segment with segmentT (0 at the anchor, 1 at the next point), and sets the stroke width there in px. setVariableWidth applies the profile as one undo step and renders the same tapered filled outline the tool produces; an empty profile clears the taper back to a uniform stroke. Entry ids are kept verbatim when you supply them and minted when you omit them, and a value-identical rewrite is a no-op that records no history step. Batchable as { method: "setVariableWidth", args: [id, profile] } through batch(ops). An unknown id returns not_found; a non-path layer returns not_a_path_layer (only path layers taper — a rectangle, ellipse, or text layer is rejected, never auto-converted). An invalid profile (a malformed entry, a segmentT outside [0, 1], a negative width, an anchorId that addresses no point of the layer, duplicate entry ids) fails with a clear invalid_params-style error and mutates nothing. The tool's own declines apply too: dashed strokes and multi-subpath paths are declined per the tool's gates (split a multi-subpath layer into single-subpath layers first), and so are layers with no stroke or a zero-width stroke. See Variable Width Tool for the Inspector operation this call runs. See batch(ops) for the atomic multi-op form and getVariableWidth for the read-back.

await figpea.layer.setVariableWidth(layerId, [
  { anchorId: "a1", segmentT: 0.5, width: 10 },
  { anchorId: "a2", segmentT: 0.25, width: 3 },
]);
// the uniform stroke re-tapers around the two width points —
// the same taper the Variable Width tool produces

Animated capture of the Figpea canvas: a uniform stroked path re-tapering into a swelling mid-stroke bulge after a layer.setVariableWidth call, with width-point handles visible along the taper. The paired end-state still shows the tapered stroke at rest.

End-state still showing the stroked path on the Figpea canvas tapered after the setVariableWidth call, with width-point handles along the stroke, taken from the same run as the animation.

Does a scripted profile look the same as a hand-built taper? — Yes — the setter runs the editor's own variable-width geometry, so getVariableWidth after setVariableWidth matches what the Variable Width tool produces. Can I set variable width on a dashed stroke or a multi-subpath path? — No — same declines as the tool (dashed unsupported, single-subpath only; split multi-subpath layers first). Is setting a profile undoable from a script? — Yes: one call is one undo step, and inside layer.batch it participates in the batch's all-or-nothing semantics.

getVariableWidth(id)

Signature: getVariableWidth(id: string): Promise<{ ok: true, value: { points: Array<{ id: string, anchorId: string, segmentT: number, width: number }> } } | { ok: false, code: string, message: string }>

Reads a path layer's stored variable-width profile back verbatim — round-trips what setVariableWidth wrote, so scripts can read, tweak, and re-apply. A layer with no taper reads back an empty points array; reads are never declined on gate grounds. An unknown id returns not_found; a non-path layer returns not_a_path_layer.

const tapered = await figpea.layer.getVariableWidth(layerId);
// { points: [{ id: "w1", anchorId: "a1", segmentT: 0.5, width: 10 }, ...] }

insertAnchor(id, point) / deleteAnchor(id, index) / setAnchor(id, index, point) / setHandle(id, index, side, point) / setMirrorMode(id, index, mode)

Signatures: insertAnchor(id: string, point: { x: number; y: number }): Promise<{ ok: true } | { ok: false, code: string, message: string }>, deleteAnchor(id: string, index: number): Promise<{ ok: true } | { ok: false, code: string, message: string }>, setAnchor(id: string, index: number, point: { x: number; y: number }): Promise<{ ok: true } | { ok: false, code: string, message: string }>, setHandle(id: string, index: number, side: "in" | "out", point: { x: number; y: number }): Promise<{ ok: true } | { ok: false, code: string, message: string }>, setMirrorMode(id: string, index: number, mode: "none" | "angle" | "angle-length"): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Adds, removes and moves individual anchors of a path layer — and adjusts their handles and smoothing — in place, the scripted counterpart of the editor's anchor tool: each op edits one anchor or handle and the layer keeps its id, parent, transform and style, so prototype interactions, component bindings and selection wiring survive the edit. A single undo step each; batchable as { method: "insertAnchor", args: [id, point] } (or the matching deleteAnchor / setAnchor / setHandle / setMirrorMode form) through batch(ops), with point identities stable so follow-up anchor edits land on the right points: untouched anchors keep their ids and new anchors get fresh ids — the promise roundCorners makes above is what this section consumes.

Anchors are addressed by command index everywhere except insertAnchor, which takes the point to insert: the call projects it onto the path and splits the segment there (a line or closing segment gains a straight anchor; a cubic is divided in two), so the reference is self-validating — indices shift after every insert or delete. deleteAnchor removes the anchor at index (deleting a subpath start promotes the next command so the subpath survives); setAnchor moves one anchor to point, carrying its attached controls along; setHandle puts the "in" (incoming) or "out" (outgoing) handle on point — a side with no handle is first given one by the same smoothing the Bend tool applies — and mirrors the opposite handle per the anchor's effective mirror mode; setMirrorMode stores "none", "angle" or "angle-length" for that anchor with no geometry change, steering the next setHandle.

Adding or removing anchors changes subpath membership, so an insert or delete invalidates regionFills (re-list regions afterwards); an in-place reshape keeps each regionKey stable for getRegionFills. Use setPath(id, d) only when you are replacing the entire outline; use these five calls when you are editing points.

Validation: an unknown id returns not_found, a non-path layer returns not_a_path_layer naming the id, and a malformed point, a malformed or out-of-range index (naming it), a side outside "in" / "out", a mode outside the enum (naming it), a CLOSE_PATH index (it carries no point), an insert with nothing projectable, a delete that would leave no anchors, or a handle edit the geometry cannot host returns invalid_params — and nothing is mutated. See batch(ops) for the atomic multi-op form.

await figpea.layer.setAnchor(layerId, 1, { x: 120, y: 80 });
// same layer, one anchor nudged — id, parent, transform, style intact
await figpea.layer.setHandle(layerId, 1, "out", { x: 150, y: 20 });
await figpea.layer.batch([
  { method: "insertAnchor", args: [layerId, { x: 60, y: 10 }] },
  { method: "setMirrorMode", args: [layerId, 1, "angle-length"] },
]);

Can I move one point without rewriting the whole outline? — Yes: setAnchor moves a single anchor in place; use setPath only when you are replacing the entire d. Do anchor edits break region fills or follow-up edits? — An in-place reshape keeps regionKeys stable for getRegionFills; adding or removing anchors may not — re-list regions after an insert/delete, the same way you re-read points after roundCorners.

setMask(id, type) / clearMask(id)

Signatures: setMask(id: string, type: "alpha" | "vector"): Promise<{ ok: true } | { ok: false, code: string, message: string }> and clearMask(id: string): Promise<{ ok: true } | { ok: false, code: string, message: string }>

setMask declares a layer to be a mask over its preceding sibling / container, specifying whether masking uses "alpha" or "vector" mode. clearMask un-declares mask behavior. Returns { ok: false, code: "invalid_mask_type" } if type is invalid.

getRegionFills(id) / setRegionFill(id, regionKey, fill) / clearRegionFill(id, regionKey)

Signatures: getRegionFills(id: string): Promise<{ ok: true, value: { regions: { regionKey: string, fill: { fillType: "solid", fill: string } | null }[] } } | { ok: false, code: string, message: string }>, setRegionFill(id: string, regionKey: string, fill: string): Promise<{ ok: true } | { ok: false, code: string, message: string }>, clearRegionFill(id: string, regionKey: string): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Registers the Paint tool's region-fill model — filling individual closed regions of a path layer, including the regions formed where two or more subpaths overlap — on the agent API.

getRegionFills(id) lists every closed region derivable from a path layer's own subpath geometry, each with its regionKey and current fill ({fillType:"solid", fill} or null if unfilled). regionKey is the sorted, comma-joined list of the region's contributing subpath indices (e.g. "0", "1", "0,1" for the lens where two overlapping subpaths meet) — the only way to discover a valid regionKey, since no other method exposes a path layer's svg/subpath structure. It is stable across an edit that reshapes a subpath in place (an anchor move), but not guaranteed stable across an edit that adds, removes, or reorders subpaths.

setRegionFill(id, regionKey, fill) fills or re-colors one region with a plain CSS color string (solid fill only). If the layer still carries a single layer-wide fill, that color is first decomposed into an equal per-region entry for every currently-derived region, then the requested region is overwritten — all in one undo step. clearRegionFill(id, regionKey) removes one region's fill, leaving every other region (including the non-overlapping parents of an overlap region) untouched; a value-identical call is a genuine no-op.

Both region-addressed methods return { ok: false, code: "not_a_path_layer" } if the target isn't a path-shaped layer; setRegionFill additionally returns { ok: false, code: "invalid_region_key" } if regionKey doesn't match a region derivable from the layer's current geometry.

const listing = await figpea.layer.getRegionFills(pathId);
// listing.value.regions -> [{regionKey:"0", fill:null}, {regionKey:"1", fill:null}, {regionKey:"0,1", fill:null}]
await figpea.layer.setRegionFill(pathId, listing.value.regions[0].regionKey, "#3d63dd");
await figpea.layer.clearRegionFill(pathId, listing.value.regions[2].regionKey);

setConstraints(id, patch) / setResizeMode(id, mode)

Signatures: setConstraints(id: string, patch: { horizontal?: "left" | "right" | "leftAndRight" | "center" | "scale", vertical?: "top" | "bottom" | "topAndBottom" | "center" | "scale" }): Promise<{ ok: true } | { ok: false, code: string, message: string }> and setResizeMode(id: string, mode: "content" | "viewport" | "constraint"): Promise<{ ok: true } | { ok: false, code: string, message: string }>

setConstraints sets a layer's per-child constraint axes (used when its parent is in constraint mode); the patch merges against the layer's current/default constraints, so a partial {horizontal?}- or {vertical?}-only call preserves the other axis. setResizeMode sets a container's (page or group/folder) resize mode: "content" hugs to its children, "viewport"/"constraint" give it a fixed frame that clips overflow; a page cannot use "content" mode. Returns { ok: false, code: "invalid_constraint" } if an axis/mode value is outside its enum, if "content" mode is applied to a page, or if the target is neither a page nor a folder.

setAutoLayout(id, patch)

Signature: setAutoLayout(id: string, patch: { mode?: "none" | "horizontal" | "vertical", itemSpacing?: number, padding?: { top?: number, right?: number, bottom?: number, left?: number }, primaryAxisAlign?: "min" | "center" | "max" | "space_between" | "space_evenly", counterAxisAlign?: "min" | "center" | "max", primarySizing?: "fixed" | "resize_to_fit", counterSizing?: "fixed" | "resize_to_fit" }): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Sets a group's stack layout by patching its autoLayout field — one call, one undo step (ILayerProxy mutation path, history boundary via commitHistory). The patch merges against the group's current autoLayout (so { itemSpacing: 16 } preserves an already-set padding and mode). Passing mode: "none" clears the stack and restores free layout; remaining keys in the same patch are ignored when mode is "none".

  • mode — none (clear) / horizontal / vertical. Required on first enable; omit to tune an already-active stack.
  • itemSpacing — gap between adjacent children on the stack axis. Must be a finite, non-negative number.
  • padding — insets the content box on four sides; partial { top?, right?, bottom?, left? } merges against the existing padding (omit to leave a side unchanged). Each side must be a finite, non-negative number.
  • primaryAxisAlign / counterAxisAlign — distribution along / across the stack axis (enums above).
  • primarySizing / counterSizing — fixed (container size authoritative) 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: gradient — type: "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. Stops interpolate in sRGB; midpoints darken perceptually on dark grounds and this is accepted. The linear geometry is authored as normalized fractions of the layer\u2019s own width (linearX1/linearX2) and height (linearY1/linearY2) \u2014 0 is the near edge, 1.0 the far edge \u2014 so values that read as pixels (e.g. linearY2: 50 on a 100 px-tall layer) paint flat stop[0], which is correct behaviour, not a renderer bug.
  • 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")), bevelEmboss ({ enabled, ... } — decoded PSD bevel/emboss: style ("inner" | "outer" | "emboss" | "pillow"), technique ("smooth" | "preciseMatte" | "slopeLimitMatte" | "chiselHard" | "chiselSoft"), direction ("in" | "out"), contour, depth, plus highlight/shadow color+opacity+blend, blur/soften and light angle. Render contract for technique: the value is decoded faithfully and, for inner bevels, it is honoured: smooth paints the narrow steep-edged smooth profile while preciseMatte and slopeLimitMatte paint a distinct matte profile — a soft, wide transition calibrated against a captured Photoshop-authored reference — so setting the technique changes the render. An enabled chiselHard/chiselSoft value, or an unrecognised technique, is still painted with the smooth profile and publishes a rasterized_fallback warning under report.diagnostics(), naming the file key, the value decoded to, and the profile actually painted — which is how you tell a honoured render from an approximated one. chiselHard/chiselSoft are retained but unconfirmed: they appear in no measured fixture and in neither Adobe's published descriptor terminology nor the reference renderer. style's non-inner values are likewise decoded faithfully and painted as inner, reported the same way), satin ({ enabled, ... } — decoded PSD satin: color, blend, opacity, angle/distance/blur, invert)
  • 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 (px), fontWeight, fontItalic, letterSpacing (thousandths of an em — each unit is 1/1000 of fontSize, so 6 at 26px is 0.16px of tracking, not 6px), lineHeight (absolute px, never a multiplier — the advance of every line after the first, so lineHeight = fontSize × multiplier; below 1.5 is rejected as invalid_style_value), align, case ("upper" | "lower" | "title" — layer-level default only, no ranged authoring; out-of-enum → invalid_style_value), opticalMargin (optical-margin trim in px, default 0 — shifts the layer's painted ink toward the box's start edge while the layout box never moves; ignored while inkAlign is on), inkAlign (ink-align switch, default off — when true, the effective trim is computed from the nearest vertical guide/column edge instead of the authored number; no guides nearby resolves to 0). Both default to zero/off so the layout box stays put while only the ink moves — see Optical margin trim & ink-align. The Layout box vs ink box entry is the observability side of the same concept (inkBounds reads the painted-ink rect); these two keys are the authoring side.

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 (Wrap width in px: when set, lines wrap to this width (word wrap) instead of extending past it; without fixWidth the layer is single-line point text that never wraps — a long line overflows (clips) instead of wrapping. A single token too wide for fixWidth is neither an overflow nor a clip: it is broken mid-token across lines, and no character is lost or duplicated. The one exception is a single character wider than fixWidth — it cannot be split further, so it takes a line of its own and that line overflows.) and fixHeight (text box height in px; omit for auto-height). A token too wide for fixWidth is broken mid-token across lines — never clipped, never overflowing — so an unbroken string folds onto further lines rather than running past the box. The one exception: a single character wider than fixWidth cannot be split further, so it takes a line of its own and that line overflows. fixHeight clips rather than wraps — it neither reflows nor shrinks the text — so the lines reported by figpea.layer.measureText is the unclipped count: the number of lines the text needs at that fixWidth, which can be larger than the box shows (REQ-1532). valign ("top" | "center" | "bottom") — only visible when fixHeight is set.
  • Font metrics (REQ-1507): every text node reports the resolved face's vertical metrics in px at the resolved size — ascent / descent on textMetrics (layerTree() / layerById) and on figpea.layer.measureText — plus capHeight (the actualBoundingBoxAscent of "H" in the resolved face; canvas exposes no cap-height API) and baselineOffset (px from the box top to the first baseline for the layer's fixHeight / lineHeight, valign shift included on the fixWidth + fixHeight clip branch). firstBaselineY = y + baselineOffset lands the first baseline with no render. Worked example: create a text layer at a known family / size / lineHeight, read it back with layerById, add textMetrics.baselineOffset to bounds.top.
  • Per-line widths (REQ-1458): figpea.layer.measureText reports one ink width (px) per wrapped line, in order, as lineWidths — length === lines — read off the same wrapped layout the renderer paints, so any entry wider than your fixWidth is a manual line the renderer re-wrapped. Worked example: measuring 'At the same cycle count LFP\nheld 7 % more capacity than' at fontSize: 15 in a fixWidth: 240 box reports the second manual line at 295px in that 240px box. Q: "How do I tell which wrapped line overflows its fixWidth?" A: "Read lineWidths — one ink width per wrapped line, in order; any entry wider than your fixWidth is a line the renderer will re-wrap." Q: "Does the re-wrap warning block creation?" A: "No. The layer is still created (or set) with the value you passed; the warning string only reports that the applied breaks differ from the manual ones." See create(kind, props) / setText below for the warn-only warning.
  • Baseline metrics (REQ-1507): figpea.layer.measureText returns the same four keys — ascent, descent, capHeight, baselineOffset — read off the same layout the renderer paints, so a layerById read-back agrees exactly. naturalWidth stays the advance width (not the ink extent — side bearings differ), and agrees with export-measured ink width within 2px on the calibrated setting. The metrics describe the measured face: read them alongside the measuredFamily / substituted / verified keys (REQ-1447 / REQ-1517), never as properties of the requested family.
  • Image fill mode (image/pattern layers only): patternScaleType ("fit" | "cover" | "fill" | "free"), patternRepeat ("no-repeat" | "repeat" | "repeat-x" | "repeat-y")

This is the full 48-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",
});

setPageFill(pageId, patch)

Signature: setPageFill(pageId: string, patch: { fill?: string, fillType?: 'solid' | 'none' }): Promise<{ ok: true, value: void } | { ok: false, code: string, message: string }>

Sets a page's background fill. An unparseable color is rejected with invalid_style_value, leaving the prior fill untouched. The page's background is composited into figpea.canvas.screenshot({ id }) and into figpea.export.artboard / figpea.export.layer, so an export of that page carries the color as an opaque ground; with fillType: 'none' the page paints nothing and exports transparent where no layer covers. A page created through this API arrives with a white background, the same as a page you add by clicking in the editor; pass a background with the create call, or use setPageFill, to choose a different one — and you do not need a full-page rectangle.

const page = await figpea.layer.create("page", { pageWidth: 1440, pageHeight: 900 });
if (page.ok) {
  await figpea.layer.setPageFill(page.value.id, { fill: "#FFFFFF", fillType: "solid" });
}

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). The call resolves only once those bytes have decoded into the renderer's pattern cache, which is a distinct step from the load above (which establishes the type and intrinsic size), so an attach followed immediately by a capture needs no wait between them.

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 -- and, as of the contract version in the row above, only once the image has decoded; { 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 }>. setText on a fixWidth layer carries an optional warn-only warning (REQ-1458): when the new text's manual line breaks do not survive the box -- a manual line wider than fixWidth that the renderer re-wrapped -- the warning names the re-wrapped manual line(s) with measured px vs box px; the text is set either way, and the warning is absent unless there is something to say.

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" }.

batch is the method where large args are normal — it is the only all-or-nothing, single-undo-step way to place a page — so its ops share one per-call argument budget: past describe().limits.argsChars the whole call is refused with arg_size_exceeded and nothing is applied, so there is no partial page to recover. Size against that value (aim for roughly two-thirds), split the op list across calls of that size, and pass ids from earlier results literally. Each chunk is its own undo step. See Per-call argument budget. On a host that cannot nest arrays, send ops as one JSON string instead — that is also how the batch stays a single atomic call there; see Hosts that collapse nested arrays.

// 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}`);
}

Within one layer.batch call, an op can reference an earlier op's created layer two ways: the string form "$<index>" (e.g. "parentId": "$0") or the object form { "opResult": n } (e.g. "parentId": { "opResult": 0 }). Both resolve within the single call only — across chunked calls, pass real ids from earlier results. A malformed placeholder is refused with invalid_params naming the offending op index, so the error points at the op to fix rather than at the shape of the payload.

// string form: create on a page, then position it in the same batch
figpea.layer.batch([
  { method: "create", args: ["rect", { parentId: pageId, rwidth: 200, rheight: 100 }] },
  { method: "setPosition", args: ["$0", { x: 2000, y: 200 }] },
]);
// object form for a dependent op in the same batch
figpea.layer.batch([
  { method: "create", args: ["rect", { parentId: pageId, rwidth: 200, rheight: 100, position: { x: 2000, y: 200 } }] },
  { method: "setName", args: [{ opResult: 0 }, "hero-rect"] },
]);

Hosts that collapse nested arrays (figpea-mcp only)

Some agent host harnesses serialise a nested array into a single-key {"item": …} envelope before the value reaches the server. Measured in a real design run on 2026-09-27, that made layer.batch — the contract's only atomic multi-op path — and layer.setTransform unreachable on that host, one call at a time.

The rule. Any parameter the manifest declares object, array or matrix may be sent as a JSON string instead, and figpea-mcp parses it before the round trip, with no flag. A string is a scalar, which is the one thing such a host does not collapse. The array form remains the default and fully supported — this is an alternative route, not a replacement, and it works on both lanes.

Where you meet one — three positions, in practice:

  • layer.batch's ops — the whole op list as one string, which is what keeps the batch atomic (one call, one undo step).
  • layer.setTransform's matrix, and layer.create's transform — the six numbers as one string.
  • The props / patch object position — create's props and stylePatch's patch are objects, and the arrays a reader most often needs inside them live one level down: a gradient's stops, multi-stroke arrays, and tabStops (text tab-stop sets). These are keys inside that object, not methods of their own — there is no style group on this contract, and gradient/strokes/tabStops are keys you write inside patch, reached through stylePatch. Stringify the object, not the key inside it.
// 1. The matrix, as a string.
await figpea.layer.setTransform("L_rect", "[1,0,0,1,48,110]");

// 2. A 2-op batch as ONE string — and note the inner matrix is a REAL ARRAY inside
//    the literal. The server parses the string once, at `ops`, and never descends
//    into an op's own `args`, so a matrix written as a nested string would reach
//    the editor as a string, be rejected, and roll the whole batch back.
await figpea_call({
  group: "layer",
  method: "batch",
  args: ['[{"method":"create","args":["rect",{"rwidth":100,"rheight":50}]}, {"method":"setTransform","args":["L_rect",[1,0,0,1,48,110]]}]'],
});

// 3. The object position — the object itself, as its own slot.
await figpea_call({ group: "layer", method: "create", args: ["line", '{"x":0,"y":0,"style":{"strokes":[[{"width":2}]]}}'] });

Read the form in advance. figpea_describe({ group: "layer", method: "setTransform" }) returns the contract's own declaration — stringJsonParams names the parameters that accept a string, and where the contract declares one, the parameter carries a typed stringJson with a copyable example:

"matrix": {
  "type": "matrix",
  "required": true,
  "stringJson": { "type": "string", "example": "[1,0,0,1,48,110]" }
}

type stays matrix — that is the runtime type the editor validates against, and it is not renamed. stringJson is the alternative published beside it. The rule above holds in both lanes, but figpea_describe is where the declaration is published: a full-mode tool description states the parameter's array shape and nothing more, so read describe() rather than the tool list.

What does not change. A {"item": …} envelope is still refused by name, never silently unwrapped — the collapse is lossy, and a legitimate single-key argument object that happens to be named item would be silently reinterpreted. The refusal costs zero round trips and now names this route as the way out:

A terminal showing figpea-mcp refusing a collapsed matrix with the argument path and expected shape, with the same call succeeding below it once the matrix is sent as a JSON string

So the whole round trip, from a host that cannot nest arrays:

$ figpea_call({group:"layer", method:"setTransform", args:["L_rect", {"item":1}]})
{ "ok": false, "code": "invalid_params",
  "message": "layer_setTransform: args[1] must be an array of numbers, but it arrived as an
              object with the single key \"item\". … Or send it as a JSON string — a string is
              a scalar, which no host collapses — and this server will parse it, e.g. …" }

$ figpea_call({group:"layer", method:"setTransform", args:["L_rect", "[1,0,0,1,48,110]"]})
{ "ok": true, "value": { "transform": [1, 0, 0, 1, 48, 110] } }

"My host says args[1] must be an array of numbers — what do I send?" The same six numbers in one string: "[1,0,0,1,48,110]". That is the entire fix, and the manifest above now tells you so before you hit the error rather than after.

A terminal showing the figpea_describe response for layer.setTransform, whose text names a string form for the matrix alongside the array type

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. An unrecognised key inside input (propertys is not properties) is refused with invalid_params naming it before anything is validated or created, so a stored interaction's properties are the ones supplied.

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 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)
  • component.removeState(componentId, input)
  • component.renameState(componentId, 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, and both record one undo step:

  • 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, captured via the 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 it is captured as one undo step as well, from the parent addChild's children mutation channel, which the app's history stack does record (measured — req-270-component-undo-doc-parity.test.ts).

Corrected (REQ-270) — see the figpea.version history entry above for the version that shipped it. This entry previously said the no-position path remains NOT undoable, and that figpea.history.undo() would not reverse it. That was measured false: a single undo fully reverses either path. The claim was introduced when a previously-wrong "one undo step" was overcorrected into a blanket "not undoable", and it was re-asserted in three earlier releases. If you have code that worked around the old wording, that workaround is no longer needed.

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. An unrecognised key in input (stateName is not name) is refused with invalid_params naming it and no state is created. Captured as exactly one undo step: a single figpea.history.undo() removes the state again -- the state leaves the list and the activation falls back to the state that was active before, so activeStateId is never left pointing at a state the list does not carry; figpea.history.redo() re-adds it (measured — req-1361-component-state-undo.test.ts).

Corrected (REQ-1361) — see the figpea.version history entry above for the version that shipped it. This entry previously said the call was NOT undoable, because the states array was spliced directly and the state-activation the call performs internally was marked non-recordable. That was accurate then and is now false: the deletion and the activation are both recorded, as one step, and one undo reverses both together.

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). An unrecognised key in input (stateIdd is not stateId) is refused with invalid_params naming it and the active state is left exactly where it was. 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. Extra keys in input beyond group are ignored — only group is read. 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.

removeState(componentId, input)

Signature: removeState(componentId: string, input: { stateId: string }): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Removes a state variant from a component by id. If removing the active state, active state falls back to Base (undefined). Unknown componentId or stateId returns { ok: false, code: "not_found" }; targeting an instance layer returns { ok: false, code: "unsupported_op" } (instances do not own states). Undo is captured as exactly one undo step on both paths, so the two are described separately only because the active path also moves the active pointer. Removing the active state records one undo step that fully reverses the gesture: the deleted state and everything authored inside it come back, and the fallback to Base is undone with them. Removing a non-active state records one undo step that restores that state and leaves the active pointer where it was. figpea.history.redo() re-applies either deletion (measured — req-1361-component-state-undo.test.ts).

Corrected (REQ-1361) — see the figpea.version history entry above for the version that shipped it. This entry previously claimed the two paths differed: the active path was only partially reversible (one step that reversed only the activeStateId fallback, leaving the state deleted) and the non-active path recorded no step at all. That was accurate then and is now false — both paths record one step and one undo fully reverses either.

renameState(componentId, input)

Signature: renameState(componentId: string, input: { stateId: string, name: string }): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Renames an existing state variant on a component. An unrecognised key in input (naem is not name) is refused with invalid_params naming it and the name is left unchanged. Unknown componentId or stateId returns { ok: false, code: "not_found" }; targeting an instance layer returns { ok: false, code: "unsupported_op" }. One undo step: a single figpea.history.undo() fully reverses the rename (measured — property mutation on the state layer via BaseMutator).

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.

Getting an export off the wire: every binary result above — the native .fp included — can be written to a file instead of returned as inline base64, with the reserved returnAs: "path" key on a call through figpea-mcp; see Off-band binary returns under figpea.canvas below.

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. When the target is a page carrying a background color (see setPageFill), the raster export carries that color as its ground; with the page's fill type none it is transparent instead.

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", verifyGeometry?: boolean }): Promise<{ ok: true, value: { bytes: string, mime: string, filename: string, geometryWarning?: { code: string, message: string, layerCount: number, layers: string[] } } } | { ok: true } | { ok: false, code: string, message: string }>

verifyGeometry (optional, default false, format: "figpea" only) checks the round trip for you. Absent — today's exact behaviour and today's exact cost: nothing is read back. Present and true, a canonicalised geometry fingerprint taken before encode is compared with the one taken after decode, and a mismatch returns geometryWarning on the success value naming the layers whose geometry moved — { code, message, layerCount, layers }. It compares the bounds, the transform's translation, the shape kind, the path's parsed absolute segment list at fixed precision, and the paint keys that change rendered geometry (fill, fillType, strokeEnabled, strokeWidth, strokeColor, roundCornerRadius) — deliberately not a hash of the serialized records, because load-time path normalization re-encodes every path and a raw hash would warn on every file. Text runs, images and preRender are out of its scope. It costs one extra O(file) read of the archive's repo.json, which is why it is opt-in. It is a warning field on a successful result, not an error code, so the error-code table is unchanged — a mismatch does not make the call ok:false. A good CI recipe is export.project({ format: "figpea", verifyGeometry: true }) and fail when value.geometryWarning is present.

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. The two lanes differ in what comes back, which is why the signature is a union of two success arms: for format: "figpea" the call returns {bytes, mime, filename} as base64 bytes directly and never triggers a download, so successive exports stay distinct without inspecting ~/Downloads, while pdf and zip do trigger a download and return {ok: true}. To keep a .fp out of your context, call it through figpea-mcp with returnAs: "path" — see Off-band binary returns under figpea.canvas below.

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 -- viewport-independent when id is passed, camera-scaled when it is not. One caveat that matters when you capture an image fill: the settle described under screenshot below lives on figpea.canvas.screenshot() only. If you capture through your own browser-automation MCP, the editor gives you no settle, so call await figpea.session.waitForIdle() first -- exactly as you already do for a webfont -- or you can still be handed a frame whose image fill has not decoded yet.

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, settled?: false, warning?: string } } | { ok: false, code: string, message: string }>

Captures the live canvas as a PNG -- in a project with exactly one page, that page by default (the whole visible stage only when there are zero or several pages), or (via options.id) a single layer/page/artboard's own on-canvas region; options.pixelRatio controls the capture's pixel density. options.id is now viewport-independent in density as well as region -- it returns the same pixels at any editor zoom, because omitting pixelRatio (or passing 1) draws at 1x and an explicit pixelRatio still wins. That was not true before 2.64.0 (AGENT_API_CONTRACT_VERSION), where a {id} capture silently picked up the live camera's zoom, so a 1px hairline came back thicker after a round trip that the layer model said was unchanged; the returned width/height/mime are unaffected either way. The no-id form still crops the live visible stage and is therefore camera-positioned by design -- it never moves the camera. Omitting options.id captures the region at its on-screen size — the layer's world size multiplied by the camera zoom — and only then by your pixelRatio, so the returned width/height depend on camera state and both forms return ok: true. Always pass options.id for a capture you intend to judge or measure. The no-id form is correct only when you want the region exactly as the user currently sees it. Measured on a 3543 × 5002 artboard at pixelRatio: 0.42 with the camera at zoom 0.1477: screenshot({pixelRatio: 0.42}) returned 219 × 310, and screenshot({id: pageId, pixelRatio: 0.42}) returned 1488 × 2100 — 219 = 3543 × 0.1477 × 0.42. (Our measurement of one scenario, not a product guarantee.) When options.id names a page, the returned PNG carries that page's background color as its ground; with the page's fill type none it is transparent instead. 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. It also waits for image decoding before it captures. Decoding a pattern fill is asynchronous, so a capture issued right after attaching a photograph could return a frame with that fill still painted as its flat fallback colour -- with no error and no other signal. screenshot now settles the decodes in flight for what it is about to capture, on every capture path (a {id} layer/artboard, the single-page crop, and the whole-stage fallback), so what you look at is what was painted; this changes when you may look, not how the image looks. The settle is budgeted (10 seconds), so a capture that genuinely cannot settle is still returned rather than withheld -- carrying settled: false and a warning naming what had not settled. Recovery is one line: await figpea.session.waitForIdle() and capture again. Both keys are absent when the capture settled, so code reading only {bytes, mime, width, height} is unaffected.

const shot = await figpea.canvas.screenshot({ id: pageId }); // id-bearing: viewport-independent, safe to judge or measure
if (shot.ok) {
  const img = new Image();
  img.src = `data:image/png;base64,${shot.value.bytes}`;
}

The same artboard captured twice: the left capture is a 219 by 310 pixel thumbnail, too small to read any type, while the right capture of the identical artboard is 1488 by 2100 pixels and its headings are legible

The Figpea canvas with the rectangle still painted its flat navy fallback colour, and a terminal panel over it printing what figpea.canvas.screenshot returned for that page: ok true with the capture's bytes, mime, width and height, plus settled false and a warning that the image pattern decodes did not settle within 10000ms, so the capture may still show a pattern fill's flat fallback colour and the recovery is to call session.waitForIdle and capture again.

Off-band binary 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. A project export is inlined just as base64, so a native .fp you meant to leave on disk comes back as base64 text instead.

Called through figpea-mcp (not through window.figpea directly), a tool accepts the reserved returnAs: "path" key. It qualifies on the shape of the result, not on the name of the tool: any payload carrying {bytes, mime, filename?} comes back off-band — an image (figpea.canvas.screenshot, figpea.export.layer, figpea.export.artboard) or a non-image such as the native .fp from figpea.export.project. 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, filename, url}. This is a figpea-mcp envelope feature: the window.figpea contract, its version, and its error codes are untouched.

The keys are path (the absolute session path), mime (the payload's own type — a .fp is a ZIP container, so it reads application/zip), and bytes (the raw decoded size, not the base64 length). filename is the payload's own name, and it appears only when the payload carried one: figpea.canvas.screenshot carries none, so its result — key set and file name — is byte-identical to what it was before. url is the bridge's token-gated alias for the same bytes, and width/height are always present, at 0 for a non-image payload. The written file keeps that name, so the export below lands as export_project-<stamp>-<uuid>-My_Design.fp — never .bin.

figpea.export.project({ format: "figpea" }) is the ungated native export, and it is the case most worth sending off-band. Both call forms write the file and return the same text block — the .fp lands on disk, and its bytes never cross the wire as base64:

// Compact mode (the figpea-mcp default) reaches every method through figpea_call,
// with args as the method's positional argument array:
const res = await mcp.callTool("figpea_call", {
  group: "export",
  method: "project",
  args: [{ format: "figpea" }],
  returnAs: "path",
});
// -> one text block; a .fp on disk, not base64 in the tool result

// Full mode exposes the same call as its own tool, where the parameter is `input`:
const res2 = await mcp.callTool("export_project", { input: { format: "figpea" }, returnAs: "path" });
// -> {"ok":true,"path":"/tmp/figpea-mcp/<session>/export_project-<ts>-<uuid>-My_Design.fp","mime":"application/zip","width":0,"height":0,"bytes":8800,"filename":"My Design.fp","url":"http://localhost:<port>/blob/<token>"}
const bytes = host.readFile(res2.path);

The same key on a screenshot, whose result is unchanged:

// 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://localhost:<port>/blob/<token>"}' }], isError: false }
const bytes = host.readFile(res.path); // bytes never cross the MCP wire as base64

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. A value the server does not accept, or a write that cannot land, comes back loud: invalid_params naming what the key takes, or return_path_write_failed with isError: true and no partial bytes. There is no other way to get a result onto disk: a parameter this server does not recognise is refused, not ignored. Rule of thumb for anything large — for >1 MB screenshots, and for large exports generally — pass returnAs: "path" and read the file with your host's file tool; saves ~1.3 tokens/raw byte.

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})`);
}

assertGeometry(input)

Signature: assertGeometry(input: { page: string, assertions: { layer: string, x: number, y: number, width: number, height: number, tolerance?: number }[] }): { ok: true, value: { layer: string, pass: boolean, expected: { x, y, width, height }, actual: { x, y, width, height } | null, failures: string[], note?: string }[] } | { ok: false, code: string, message: string } (synchronous -- no Promise)

Checks a list of geometry assertions against the live document in a single call -- the checkable counterpart to eyeballing coordinates. Each assertion names a layer by NAME within the given page's subtree (exact match, first match wins; names never fuzzy-match, so a typo fails loudly rather than matching approximately) plus its expected world-space position/size and an optional per-assertion tolerance in px (default 0.5). Returns one entry per assertion: pass, the asserted expected values echoed, the live actual read, and failures naming the disagreeing fields among x/y/width/height.

A pure model-level read on the same calcBounds() basis as layerRect above -- not a rendered measurement: it reports the numbers a capture is cropped from, not proof of visual correctness (a passing assertion means the layer sits where the spec says, not that the slide looks right). Read-only: no selection, camera, or model writes. An unknown page or layer yields pass: false with actual: null and a naming note, never a throw; duplicate names resolve to the first match with a note naming the ambiguity. Like every window.figpea call it returns the standard { ok, value } | { ok: false, code, message } envelope -- never throws -- and it is available only with ?agent=1, like the rest of this page.

How do I verify a layer's position programmatically? Call assertGeometry with the page and one assertion per layer you specified: a pass: true entry per layer means the slide matches its coordinate spec, and a pass: false entry names the layer with both expected and actual values so you can fix the layout rather than re-measure it. For the shipped file rather than the live document, the same coordinates are checkable with no editor at all -- see Exporting for the native .fp format the check reads.

// Check a slide against its coordinate spec in one call.
const checked = await figpea.canvas.assertGeometry({
  page: "Slide A",
  assertions: [
    { layer: "hero", x: 30, y: 25, width: 120, height: 70 },
    { layer: "caption", x: 30, y: 110, width: 400, height: 24, tolerance: 1 },
  ],
});
if (checked.ok) {
  for (const entry of checked.value) {
    console.log(`${entry.pass ? "PASS" : "FAIL"} ${entry.layer}`
      + (entry.pass ? "" : ` -- expected ${JSON.stringify(entry.expected)}, actual ${JSON.stringify(entry.actual)} (off: ${entry.failures.join(",")})`));
  }
}

Terminal showing a geometry assertion result with one passing and one failing layer, the failure listing expected and actual position and size.

Version history: added in 3.7.0 (AGENT_API_CONTRACT_VERSION) alongside capturePages below.

capturePages(input?)

Signature: capturePages(input?: { pages?: string[], scale?: number }): Promise<{ ok: true, value: { bytes: string, mime: "image/png", width: number, height: number, pages: { name: string, x: number, y: number, width: number, height: number }[] } } | { ok: false, code: string, message: string }>

Renders the named pages -- omitted means all pages in document order -- at a caller-chosen scale (default 1, pass ~0.15-0.25 for thumbnails) and composites them into one labeled PNG contact sheet in a single call: a single row wrapping to a grid past a max row width, each page's name drawn on the sheet itself above its tile at a fixed device-pixel size so labels stay legible at thumbnail scale, plus a per-tile {name, x, y, width, height} manifest for programmatic checks. Viewport-independent (the camera never moves) with the same budgeted image-pattern settle as screenshot. Ungated, like screenshot -- a capture, not an export artifact -- and distinct from the gated export.contactSheet PDF. Returns the standard { ok, value } | { ok: false, code, message } envelope -- never throws -- and like the rest of this page it is available only with ?agent=1. Pair it with an MCP setup that can display the one returned image.

How do I capture all pages in one image? Call capturePages once with a thumbnail scale: the single returned PNG holds every page side by side with its name visible, so a cross-slide question -- is this slide distinguishable from that one at thumbnail size? -- is answered by looking at one image, with no image processing outside the editor. Read the pages manifest when you need tile coordinates rather than pixels.

// One call: the whole deck as a single labeled comparison image.
const sheet = await figpea.canvas.capturePages({ scale: 0.2 });
if (sheet.ok) {
  console.log(`${sheet.value.pages.length} tiles in a ${sheet.value.width}x${sheet.value.height} image`);
  const img = new Image();
  img.src = `data:image/png;base64,${sheet.value.bytes}`;
}

Sample contact-sheet capture showing three labeled pages side by side in a single image.

Version history: added in 3.7.0 (AGENT_API_CONTRACT_VERSION) alongside assertGeometry above.

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, .fig, and PSD. A 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, a .fig STRETCH image fill missing its original dimensions, or a PSD bevel/emboss effect whose style or technique is painted with the profile the renderer covers. (The bevel cases are a reported approximation, not content loss: the layer still renders and keeps its bounds — severity warning.)
  • '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}`);
  }
}

figpea.font -- Font-Alias Read/Write

Read and write the stored missing→replacement font-alias map -- the same map the editor's missing-font popup and Font Manager read and write. All methods are FREE -- ungated. Neither method throws: every failure is a structured { ok: false, code, message } result.

listAliases()

Signature: listAliases(): Promise<{ ok: true, value: Record<string, string> } | { ok: false, code: string, message: string }>

Returns the stored alias map, verbatim: keys are the original-cased missing family names, values their replacements. Empty object when nothing is stored.

const result = await figpea.font.listAliases();
if (result.ok) {
  console.log(result.value); // e.g. { "Avenir": "Arial" }
}

getAlias(missing)

Signature: getAlias(missing: string): Promise<{ ok: true, value: string | null } | { ok: false, code: string, message: string }>

Reads the stored replacement for one missing family (case-insensitive lookup). Returns the replacement string, or null when no alias is stored -- absence is normal state, not an error.

Errors: { ok: false, code: "not_found" } when no design session is active; { ok: false, code: "invalid_params" } when missing is missing, not a string, or blank.

setAlias(missing, replacement)

Signature: setAlias(missing: string, replacement: string): Promise<{ ok: true, value: { missing: string, replacement: string } } | { ok: false, code: string, message: string }>

Stores missing → replacement (overwrites on re-set -- the missing name is the key, matched case-insensitively) and re-resolves via useFont(missing), so canvas text repaints in the replacement in-session while layers keep the original family name (non-destructive, reversible -- the same set+re-resolve order the popup picker uses, so UI and API agree by construction).

The replacement must resolve through the existing library chain (local + built-in + Google manifest -- the same availability signal the picker's aggregated list reads). An unresolvable family rejects with { ok: false, code: "unknown_font" } naming the family, so the corrective action is "pick an available family".

const result = await figpea.font.setAlias("Avenir", "Arial");
if (!result.ok && result.code === "unknown_font") {
  console.warn(`No such family: ${result.message}`);
}

Errors: { ok: false, code: "not_found" } when no design session is active; { ok: false, code: "invalid_params" } when either name is missing, not a string, blank, or a self-alias (replacement equal to missing); { ok: false, code: "unknown_font" } when the replacement is not an available family.

clearAlias(missing)

Signature: clearAlias(missing: string): Promise<{ ok: true, value: void } | { ok: false, code: string, message: string }>

Removes the stored alias for one missing family (case-insensitive) and drops the in-session loaded mark, so the next open falls back to default substitution (the popup row returns). Idempotent -- clearing an absent alias returns ok, nothing to clear.

Errors: { ok: false, code: "not_found" } when no design session is active; { ok: false, code: "invalid_params" } when missing is missing, not a string, or blank.

Durability

A project that exists only in the live editor tab is not durable. Everything you author lives in that one browser tab's memory: close the tab, refresh it, or let the editor reload a file and the project is gone, with nothing written to disk.

An exported .fp checkpoint is the only durable form. export.project({ format: "figpea" }) writes a self-contained archive holding the project's layers, styles and structure, and that file is what survives. Every other step -- a screenshot, a report, a long successful authoring session -- is in-flight work in the tab.

Figpea does not write back to the file you opened. session.openFile on a .psd, .xd, .fig, .svg or .pdf gives you an editable layer model, but your edits stay in the tab: there is no save-back to the original format and no mechanism that merges changes into it. That is a deliberate boundary, not a missing feature -- those formats are read-only sources, and a lossy round-trip through a design tool is how original files get damaged. Export a .fp and reopen that to keep your work.

Which design you are pointed at, and how to keep a mutation out of the wrong one:

const doc = await figpea.session.document();          // { documentId, documentName }
await figpea.session.expectDocument(doc.value.documentId);
// ...mutations refuse with document_mismatch if the tab switches designs...
await figpea.session.expectDocument();                  // clear when done

The guard covers a mutation given an expected identifier. With none armed, every method behaves exactly as it did before -- reads included.

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)
fp_entry_damagedA .fp archive's own bytes disagree with its own directory -- an entry failed its checksum, its declared size, or did not decompress, so the file is corrupt (session.openFile)
fp_entry_missingA required .fp archive entry (main.fpe / repo.json) is absent -- a truncated download or a partial copy, so re-fetch or re-copy the whole file (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; layer.create with a prop that doesn't apply to the requested kind (nothing created)
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, gradient-shaped fill without fillType — supply gradient data as style.gradient with fillType: "gradient")
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: an unrecognised key inside a nested object param is refused naming it (<method>() received unknown parameter "<key>") on session.openFile, layer.resize, canvas.setViewport, export.flowPoster, layer.setPosition/setLocalPosition, component.addState/renameState/setActiveState/removeState/restoreChild/placeInstance, interaction.create, canvas.addDimension/setRulerVisible, layer.cut/setConstraints/setImageFill/smoothPath and session.waitForIdle (component.resetOverride and layer.repeat document that unknown keys are ignored instead); 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, plus arc ring sweep/rx/ry/startAngle values) is present but its value isn't finite, doesn't parse (path), is non-positive (ring rx/ry), 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)
unknown_fontThe named font family is not an available family (local, built-in, or Google) -- pick an available one (font.setAlias)
arg_size_exceededTool-call args exceeded the 22,000-unit per-call budget -- figpea.describe().limits.argsChars (see Per-call argument budget). Binary members (e.g. session.openFile bytes) are charged real payload bytes, everything else JSON-serialized chars; the refusal names the firing method. Nothing was applied, so there is no partial result to recover from. Image payloads keep the stage-bytes-on-a-local-CORS-origin (http://127.0.0.1:… with ACAO:*) or reuse-projectImages + placeProjectImage guidance instead of a data: URI
document_mismatchA mutation was given an expected document identifier (via session.expectDocument()) and the active document does not match. Nothing was created, moved or deleted in either design -- the method was not run, so there is nothing to roll back. The message names the expected and the active identifier; re-aim or clear with session.expectDocument. Applies to the mutating methods (the layer.* verbs including batch, session.newProject/openFile/renameProject/setSelection, and the component/interaction/canvas authoring methods) while an expectation is armed. Reads are never guarded, and a mutation with no expectation armed behaves exactly as before

figpea-mcp & Local Network Access (LNA) Guided Pairing

When using figpea-mcp to drive Figpea via AI agents, pairing is guided and transparent. The editor connects on its own. The browser's own local-network permission is the only gate — there is no consent step and no Connect button to press.

Animated capture of the pairing sequence in Figpea's editor: the "Connect to figpea-mcp?" notice showing the bridge address ws://localhost:32161 and reporting progress, then the notice gone and an emerald "Figpea agent connected · localhost:32161" pill in the lower-right corner

End state of the pairing sequence: Figpea's editor paired with the local figpea-mcp bridge, the template grid scrolled, the notice gone and an emerald "Figpea agent connected · localhost:32157" 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 a connection block naming what the bridge last saw:

    {
      "ok": false,
      "code": "no_tab",
      "connection": {
        "lastEvent": "no_attempt",
        "nextStep": "nothing has reached this bridge yet — open the pairing URL from this status call in a browser to start an editor tab",
        "tcpConnections": 0,
        "upgrades": 0,
        "helloAccepted": 0,
        "helloRejected": 0,
        "supersededCount": 0,
        "lastCloseCode": null,
        "lastCloseReason": null,
        "startedAt": "2026-10-06T12:00:00.000Z"
      },
      "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 five top-level keys — and the ten fields inside connection — are what the server returns; only the values change run to run (a fresh token per run, per-process counters, startedAt dating this run's numbers).

    Which situation you are in is the connection.lastEvent value — and only some of them are fixed by opening the URL:

    • no_attempt — nothing has reached the bridge yet. Opening the pairing URL fixes this.
    • transport_only — a socket reached the port but the handshake never completed (often the wrong port). Opening the URL does not fix this — check the port, then reload the tab.
    • hello_timeout — the handshake completed but the tab never said hello. Opening the URL does not fix this — reload the tab.
    • hello_rejected — the pairing token was stale (from an earlier server run). Only a freshly minted URL fixes this — an old URL never matches.
    • hello_accepted — a tab is paired; healthy, proceed. Nothing to fix.
    • slot_refused — another tab already holds the bridge's single slot and is still serving. Opening the URL does not fix this — close that tab or restart with multi-slot mode.
    • tab_superseded — a newer tab took over; still paired, proceed. Opening the URL does not fix this.
    • disconnected — the paired tab went away. Opening a fresh pairing URL fixes this.

    Each failure also carries its own next step in connection.nextStep — follow that text for the details; the rows above only tell you which fix family you are in.

    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 localhost: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. The bridge prints localhost in URLs but still binds 127.0.0.1 (loopback only) by design — only the emitted host changed.

  2. Automatic pairing: Opening the pairing URL dials the bridge straight away — the socket opens on its own, and the in-app notice (Connect to figpea-mcp?, naming the bridge as ws://localhost:<port>) reports what is happening rather than asking for approval. While it works, the notice shows its progress (Connecting…, then Retrying on its own — no reload needed with an attempt count). Once connected, the notice unmounts and the editor shows a bottom-right pill (Figpea agent connected · localhost:<port>). A Connect now button stays available for a manual re-dial at a moment of your choosing; the first connection never waits for it.

    Figpea's editor with the "Connect to figpea-mcp?" notice in the lower-right corner, showing the bridge address ws://localhost:32159, the editor-connects-on-its-own lead sentence, an automated-tab diagnosis, and a disabled button reading "Connecting…"

    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 · localhost:32157" 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 click is needed (the permission explainer is likewise omitted from any notice).
    • denied: Blocked by browser settings. Shows recovery instructions: reset Local network access to Allow in Site settings, then reload the page — retrying alone will not reconnect.

    The same Figpea connect notice in its denied state: the bridge address ws://localhost:32160, the editor-connects-on-its-own lead sentence, a warning that local network access is blocked for this site with recovery steps pointing at the browser's Site settings, and a pairing-URL paste box whose reconnect action stays disabled

  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 notice's action button (Connect now, or Try again once an attempt has failed).
    • Localhost exemption: Editors served from http://localhost are same-address-space and exempt from LNA entirely.
  5. How many tabs can pair: By default the bridge serves one tab, and the default flow is unchanged — but it is safe now. A second connection is refused by name rather than silently taking the tab already paired: the close reason says which tab holds the bridge, the tab that was already paired keeps serving, and status reports it as connection.lastEvent: "slot_refused" with a nextStep in the same payload as every other event. Nothing is displaced and nothing is lost.

    To run two agents (or an agent and a human tab) against Figpea at the same time, opt in:

    npx -y figpea-mcp --bridge-slots=multi    # or FIGPEA_BRIDGE_SLOTS=multi
    

    Each tab then holds its own slot, with its own document and its own connectionId, and you address a specific one. The knob's default is explicitly single, so nothing changes unless you ask it to.

    Two Figpea editor tabs open at once, each showing its own connected pill naming the same local bridge address, and each holding a differently named document

    figpea_status names the tab it is attached to — its origin (scheme://host:port) and contract version — and lists every paired connection under connections, so assert it before a destructive write: that is how you know you are editing the document you think you are. status.bridgeSlots is how you tell which kind of bridge you are looking at.

    A figpea_status response listing two paired editor tabs, each with its own connection id, origin and contract version

    In multi-slot mode only, the tool select_tab selects which paired tab subsequent calls address. It is registered only when the bridge runs with --bridge-slots=multi (or FIGPEA_BRIDGE_SLOTS=multi), and is absent from tools/list on the default single-slot bridge, where there is nothing to select — a second tab is refused by name instead. That is what status.bridgeSlots tells you.

    The refused state: the first tab still connected, the bridge reporting slot_refused with the action to take

    Can I run two agents against Figpea at the same time? Yes — one editor tab each, on the same machine, with the bridge started as npx -y figpea-mcp --bridge-slots=multi. Each tab keeps its own document, and a call addressed to one never reaches another. Without the flag the second tab is refused by name instead of taking the first one's place. This is several editor tabs each with its own document, not collaboration: there is no shared document and no real-time co-editing, and your design files still never leave your machine — the bridge binds loopback only, holds no credentials, and ships no telemetry.

    How do I know my agent is editing the document I think it is? Read figpea_status before the write, not after. It names the attached tab's origin and contractVersion, and lists every paired connection with its own id, origin and contract version — so you can check that the tab your agent reached is the one you meant instead of assuming it. If origin comes back null, that is the browser declining to send the header rather than a guess: the bridge never reconstructs an origin. With two tabs paired, select_tab (multi-slot mode) both moves the pointer and re-points the tool descriptions at the tab you picked.

    See /ai-agents for client setup — adding npx -y figpea-mcp to Claude Code, Cursor or any other MCP client, and where the flag goes.

If pairing fails

  1. Open the pairing link in the tab you want to pair — if lastEvent is no_attempt or disconnected (for hello_rejected, mint a fresh one first: re-read the URL from a new status call, since a token from an earlier run never matches). For any other row above, do what that row says instead — opening the link again will fail the same way. The no_tab result and the terminal printout both carry the full URL, and both values in it are per-run.
  2. Wait for the connection. The notice reports its progress (Connecting…, then Retrying on its own — no reload needed with an attempt count) — give it that time instead of reloading mid-attempt.
  3. Read the on-screen diagnosis rather than guessing: the notice names what it tried (Couldn't reach ws://localhost:<port> yet, or the local-network block with its Site-settings recovery), so follow that text instead of assuming the bridge is down.
  4. Let it retry on its own: the editor works through a retry budget (the notice counts its attempts) without a reload, and the button stays disabled while the budget runs.
  5. Reload only if the tab is genuinely stuck: the notice has stopped retrying and says so (then stopped), or the permission is denied and the notice says retrying alone will not reconnect.

Download the Skill

This download is a pointer you can drop straight into an agent's skills directory, and it is deliberately not a copy. It names GET https://editor.figpea.com/agent/skill.md as the authoritative, always-current body — so there is nothing here that can drift out of date between editor releases, and no version to re-pin when the API changes. Concretely: fetch that URL directly, or read it from the runtime with await figpea.SKILL(), or call the figpea_skill MCP tool. 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 on that endpoint and in the runtime itself — see figpea.SKILL(selector?) above.

Download the Skill (SKILL.md)

Connect via figpea-mcp

The recommended way to connect an agent to Figpea is the published figpea-mcp package — it fetches the live contract from the editor origin, so it can never go stale. Add it to any MCP client (Claude Code, Cursor, or any other) with npx -y figpea-mcp, or see /ai-agents for the full client-setup guide and the GitHub repository for source:

npx -y figpea-mcp

More in Export & Handoff

See all in Export & Handoff