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

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, transform: [1, 0, 0, 1, 40, 160], text: "Made by an agent",
  }), "create(text)");

  // 3. Refine.
  must(await figpea.layer.move(rect.id, 20, 0), "move(rect)");
  must(await figpea.layer.stylePatch(label.id, { fontSize: 24, fill: "#111827" }), "stylePatch(label)");

  // 4. Read the tree back — this is what you'd screenshot with your MCP, then iterate on.
  const tree = must(await figpea.session.layerTree(), "layerTree()");
  return { pageId: page.id, rectId: rect.id, labelId: label.id, tree };
}

The recipe returns { pageId, rectId, labelId, tree }. tree is the same shape as figpea.session.layerTree()'s result -- walk it to confirm the three created layers are present, then continue the screenshot-and-mutate loop from there. For an isolated visual check without leaving your browser-automation MCP's own screenshot step, figpea.canvas.screenshot() is the in-contract feedback-loop alternative documented under figpea.canvas below.

API reference

The full surface: figpea.version and figpea.describe(), then the eight groups -- figpea.session, figpea.layer, figpea.history, figpea.export, figpea.canvas, figpea.report, figpea.interaction, and figpea.component.

figpea.version

Type: string

Current API contract version (semver), independent of the product version. Value: 0.21.0.

figpea.describe()

Signature: describe(): DescribeIndex and describe(selector: string): GroupDescriptor | MethodDescriptor | Record<string, string> | undefined

Works like a CLI's --help — call it bare to see what exists (a compact index of every group, each method's name and one-line doc), then drill with a selector to see how one thing works (its full parameter schema).

Bare describe(): returns a compact index — every group name, each method's name and one-line doc, plus version and the enumerable errorCodes keys; it deliberately omits params/byKind/result schema trees so the mandated first call stays small as the surface grows.

Drill shapes: describe("layer.create") returns one method's full { doc, params, result }; describe("layer") returns every method in a group; describe("errorCodes") returns the complete catalog. Unknown/malformed/non-string selector returns undefined — never throws.

The honesty beat: each drilled schema is byte-for-byte identical to the machine-readable schema it always was — a valid call is still constructible from describe() alone, no external docs; you just fetch the detail when you need it. A consumer that wants the whole surface (e.g. an MCP bridge building tool definitions) simply drills every group at startup, paying the full cost once, intentionally.

Prefer reading describe() over this page when you need the authoritative, up-to-the-minute shape.

// Discover what methods exist
const index = figpea.describe();
console.log(index.layer.create); // "Creates a new layer"

// Drill the full schema for one method
const method = figpea.describe("layer.create");
console.log(method.params, method.result);

figpea.session -- Project and Layer-Tree Operations

newProject()

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

Creates a new, empty design project and makes it the active session. Result: the new project's id.

openFile(options)

Signature: openFile(options: { url: string, type: string, fileName?: string } | { bytes: ArrayBuffer | number[], fileName: string, type?: string }): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Opens a design file (PSD, XD, Figma, SVG, or PDF) into the active session, either by URL ({ url, type, fileName? }) or by raw bytes ({ bytes, fileName, type? }) -- provide one variant, not a mix of both. After opening, the resulting layer tree is available via layerTree() or getSelection().

layerTree()

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

Returns the curated, JSON-serializable layer tree from the active project's root, including hidden layers. Every node has this shape:

{
  id: string;
  name?: string;
  layerType: string;            // e.g. "page", "folder", "rect", "text", "image"
  shape?: string;                // present only for shape layers
  visible: boolean;
  transform: [a, b, c, d, e, f]; // 2D affine matrix
  bounds: {                      // calculated bounding box
    left: number;
    top: number;
    right: number;
    bottom: number;
  };
  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
  };
  rawText?: string;              // present only for text layers
  children?: FigpeaLayerNode[];  // present only for container layers
}

Geometry is always matrix-first (transform + bounds), never rect-based, and style only ever carries the whitelisted keys documented under stylePatch below -- the full 31-key round-trip, read from the same internal fields the write side writes to (a key can't be settable without also being readable). Only keys actually set on the layer are present in a given node. All nodes are plain JSON -- no live proxy references. Honesty boundaries: for gradient-filled layers, style contains the entire gradient object (type, stops, geometry, transform) because gradients are small pure JSON with nothing held back — the deliberate opposite of the image-fill raster boundary stated below. For image/pattern-filled layers, style reports only the current fit/tile mode (patternScaleType/patternRepeat) -- the underlying image bytes (and the "free"-mode matrix) are deliberately never serialized back.

layerById(id)

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

Same node shape as layerTree(), but for a single layer id (with its subtree if it's a container). Returns { ok: false, code: "not_found" } if the id doesn't exist.

getSelection() / setSelection(ids)

Signatures: getSelection(): Promise<{ ok: true, value: string[] } | { ok: false, code: string, message: string }> and setSelection(ids: string[]): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Read or set the ids of the layer(s) currently selected in the active session. Pass [] to setSelection to clear the selection; unknown ids are silently ignored.

find(selector?)

Signature: find(selector?: object): Promise<{ ok: true, value: FigpeaLayerNode[] } | { ok: false, code: string, message: string }>

Queries the layer tree by attribute, returning all matching nodes in document order (depth-first pre-order). A pure read that does not mutate the current selection. All selector fields are optional and AND-combined; empty/omitted selector matches every node (returns the entire tree as a flat array). Selector fields: type?, name?, nameRegex? + nameRegexFlags?, text? (text layers only), textRegex? + textRegexFlags?, visible?, hasChildren?. Returns { ok: false, code: "invalid_selector" } if the selector contains an unknown key or an uncompilable regex pattern/flags.

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

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 and the canvas has drawn a settled frame -- the render-settle signal for an agent's mutate -> settle -> observe loop. Bounded by a timeout (default 10 seconds); resolves promptly if already idle, is safe to call repeatedly, and never rejects or hangs. Result: void on success; { ok: false, code: "timeout" } if the deadline elapses before a settled frame is drawn.

await figpea.layer.create("rect", { rwidth: 100, rheight: 50 });
const settled = await figpea.session.waitForIdle();
if (settled.ok) {
  const shot = await figpea.canvas.screenshot(); // canvas has finished drawing
}

figpea.layer -- Layer Authoring Operations

All layer operations apply to the active session and share the same undo/redo history as the UI -- each call is recorded as exactly one undo step, regardless of wall-clock timing.

create(kind, props)

Signature: create(kind: string, props: object): Promise<{ ok: true, value: { id: string } } | { ok: false, code: string, message: string }>

Creates a new layer of the given kind -- one of "rect", "ellipse", "line", "polygon", "path", "text", "folder", "page", "image" -- and attaches it under props.parentId (or the project root if omitted). Common props fields: parentId?, index?, name?, transform?: [a,b,c,d,e,f], style? (whitelisted keys only, see stylePatch). Every kind has its own real geometry fields -- there is no generic width/height:

  • "rect": x?, y?, rwidth? (default 100), rheight? (default 50)
  • "ellipse": cx?, cy?, rx? (default 50), ry? (default 50)
  • "line": x2? (default 100), y2? (default 0) -- runs from the local origin to (x2, y2)
  • "polygon": points?: { x: number, y: number }[]
  • "path": path?: string (SVG path data, default "M0 0")
  • "text": text?: string (default "") -- note there is deliberately no x/y field on text; position it via transform or a parent
  • "folder": no kind-specific fields
  • "page": pageWidth? (default 300), pageHeight? (default 150); name sets the page title
  • "image": url? or bytes? (exactly one), optional mimeType?, plus x?/y?/rwidth?/rheight? like "rect", plus optional patternScaleType?: "fit" | "cover" | "fill" | "free" (default "cover") and patternRepeat?: "no-repeat" | "repeat" | "repeat-x" | "repeat-y" (default "no-repeat") to control image fit/tile mode

Result: the newly created 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 a relative delta in degrees around its own center (rotate), or flip it in place (flipX/flipY). All return Promise<{ ok: true } | { ok: false, code: string, message: string }>.

setPosition(id, pos) / setTransform(id, matrix)

Signatures: setPosition(id: string, pos: { x: number, y: number }): Promise<{ ok: true } | { ok: false, code: string, message: string }> and setTransform(id: string, matrix: [a, b, c, d, e, f]): Promise<{ ok: true } | { ok: false, code: string, message: string }>

setPosition sets a layer's absolute local position -- the translate components ([4]/[5]) of its transform -- leaving the rotation/scale components untouched; unlike move's relative delta, repeating the same setPosition(id, { x, y }) call is idempotent (always lands exactly on { x, y }). setTransform sets the full affine matrix verbatim (no decompose/recompose), so a read immediately afterward deep-equals exactly what was set. Result: void on success; setPosition returns { ok: false, code: "invalid_position" } if x/y aren't both finite numbers, setTransform returns { ok: false, code: "invalid_transform" } if matrix isn't an array of exactly 6 finite numbers.

await figpea.layer.setPosition(layerId, { x: 100, y: 100 });
await figpea.layer.setTransform(layerId, [5, 0, 1.5, 1, 40, 30]); // scale x5, shear, translate

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
  • Gradient fill: gradienttype: "linear" | "radial" | "conic", required non-empty stops: [{ offset, color }], optional per-type geometry (linearX1/Y1/X2/Y2 for linear; innerCircleX/Y/R + outterCircleX/Y/R for radial; startAngle/centerX/centerY for conic) and optional transform matrix; both type and stops must be supplied in the same patch as fillType: "gradient" (setting fillType alone is not valid). Malformed gradient → invalid_style_value, with the layer's prior fill left completely unchanged.
  • Stroke controls: strokePosition ("inside" | "center" | "outside"), lineCap ("butt" | "round" | "square"), lineJoin ("bevel" | "miter" | "round"), dashArray (number array, e.g. [4, 2]), dashOffset (number), miterLimit (number)
  • Effects: dropShadow / innerShadow ({ enabled, dx, dy, r, color }), objectBlur ({ enabled, r }), backgroundBlur ({ enabled, r, brightness, opacity }), overlay ({ enabled, color })
  • Blend mode: blendMode (accepted: "normal" | "multiply" | "darken" | "color-burn" | "lighten" | "screen" | "overlay" | "soft-light" | "hard-light" | "difference" | "exclusion" | "color-dodge")
  • Transparency: opacity
  • Geometry: cornerRadius (number, or a [tl, tr, br, bl] tuple)
  • Text style (text layers only): fontFamily, fontSize, fontWeight, fontItalic, align, case ("upper" | "lower" | "title" — layer-level default only, no ranged authoring; out-of-enum → invalid_style_value)
  • Text-frame sizing: fixWidth / fixHeight (numbers; omit for auto-width / auto-height). valign ("top" | "center" | "bottom") — only visible when fixHeight is set.
  • Image fill mode (image/pattern layers only): patternScaleType ("fit" | "cover" | "fill" | "free"), patternRepeat ("no-repeat" | "repeat" | "repeat-x" | "repeat-y")

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

setImageFill(id, source)

Signature: setImageFill(id: string, source: { url: string, mimeType?: string, patternScaleType?: "fit" | "cover" | "fill" | "free", patternRepeat?: "no-repeat" | "repeat" | "repeat-x" | "repeat-y" } | { bytes: ArrayBuffer | Uint8Array | number[], mimeType?: string, patternScaleType?: "fit" | "cover" | "fill" | "free", patternRepeat?: "no-repeat" | "repeat" | "repeat-x" | "repeat-y" }): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Attaches an image fill to an existing layer (any kind), choosing the fit/tile mode in the same call. Validate-before-mutate: any supplied patternScaleType/patternRepeat is checked first (out-of-enum -- invalid_style_value, nothing touched), then the source is resolved to a loaded image (fetch/decode failure -- invalid_image_source, nothing touched).

Parameters:

  • id (string) — Layer id.
  • source (object) — Image source (exactly one of url/bytes is required):
    • url?: string — HTTP(S) URL to an image.
    • bytes?: ArrayBuffer | Uint8Array | number[] — Raw image bytes.
    • mimeType?: string — used when constructing from bytes.
    • patternScaleType?: "fit" | "cover" | "fill" | "free" (default "cover") and patternRepeat?: "no-repeat" | "repeat" | "repeat-x" | "repeat-y" (default "no-repeat") — same enums as stylePatch/create("image"), to control image fit/tile mode.

Result: Void on success; { ok: false, code: "not_found" } for an unknown layer id; { ok: false, code: "invalid_style_value" } if patternScaleType/patternRepeat is outside its enum; { ok: false, code: "invalid_image_source" } if the source (url fetch or bytes decode) failed to resolve to a usable image.

await figpea.layer.setImageFill(layerId, { url: "https://example.com/image.png" });
// Read it back:
const node = await figpea.session.layerById(layerId);
if (node.ok && node.value.style?.fillType === "pattern") {
  console.log(`Fit mode: ${node.value.style.patternScaleType}`);
}

setText(id, text) / setVisible(id, visible) / delete(id)

Replace a text layer's content (setText), toggle visibility (setVisible), or remove a layer from its parent (delete). All return Promise<{ ok: true } | { ok: false, code: string, message: string }>.

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 directly above it (clip), or restore a clip mask as a visible sibling again (unclip).

batch(ops)

Signature: batch(ops: { method: string, args: unknown[] }[]): Promise<{ ok: true, value: { results: OpResult[] } } | { ok: false, code: string, message: string, failedIndex: number, results: OpResult[] }>

Applies a sequence of other figpea.layer.* ops as one undo step -- a single figpea.history.undo() reverses the whole batch. All-or-nothing: if any op fails, the whole batch rolls back (no partial application, no undo step created) and the result reports the failing op's own code/message plus its failedIndex. method: "batch" (recursion) or any unrecognized method name is rejected per-op with { ok: false, code: "unsupported_op" }.

// Build a 2-rect layout as ONE undo step.
const result = await figpea.layer.batch([
  { method: "create", args: ["rect", { parentId: pageId, x: 0, y: 0, rwidth: 50, rheight: 50 }] },
  { method: "create", args: ["rect", { parentId: pageId, x: 60, y: 0, rwidth: 50, rheight: 50 }] },
]);
if (result.ok) {
  const ids = result.value.results.map((r) => r.value.id);
  await figpea.history.undo(); // removes both rects
} else {
  console.log(`batch failed at op ${result.failedIndex}: ${result.code}`);
}

select(target)

Signature: select(target: string | string[]): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Sets the active session's current selection to the given layer id(s), rendering the transform widget for them -- the ergonomic authoring-surface wrapper over the same selection path figpea.session.setSelection uses, accepting either a single id or an array for multi-select. Result: void on success; { ok: false, code: "not_found" } if any given id doesn't resolve to a layer.

// Select one layer, then read where its transform widget rendered:
await figpea.layer.select(rectId);
const geometry = await figpea.canvas.widgetGeometry();

figpea.history -- Undo/Redo

All figpea.layer calls are recorded in a shared history with the UI's undo/redo -- one API call, one undo step.

undo() / redo()

Signatures: undo(): Promise<{ ok: true } | { ok: false, code: string, message: string }> and redo(): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Revert or re-apply the most recent step on the active session's history. Prefer undo() over hand-computing an inverse mutation while iterating -- it's cheaper and safer, and it shares the same stack as the UI.

figpea.interaction -- Interaction / Prototype Authoring

Author and read back prototype interactions (tap/time → navigate triggers). 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", action: "navigate", 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 to a target page. Validation runs before any mutation: unsupported trigger/action, missing/invalid targetId, or non-page target all reject with invalid_interaction.

Result: the newly created interaction's id on success, not_found if the layer or target page don't exist, or invalid_interaction on validation failure.

const result = await figpea.interaction.create(rectId, {
  trigger: "tap",
  action: "navigate",
  targetId: pageId,
  properties: { duration: 0.3 }
});
if (result.ok) {
  console.log("Interaction created:", result.value.id);
}

list(layerId)

Signature: list(layerId: string): { ok: true, value: { id: string, trigger: string, action: string, targetId?: string, properties?: object }[] } | { ok: false, code: string, message: string } (synchronous -- no Promise)

Lists all interactions on a layer. Returns an empty array for layers with no interactions.

const interactions = figpea.interaction.list(rectId);
if (interactions.ok) {
  for (const interaction of interactions.value) {
    console.log(`${interaction.trigger} → ${interaction.action} to ${interaction.targetId}`);
  }
}

remove(interactionId)

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

Removes an interaction by id from the active project.

figpea.component -- Component State Authoring and Instance Creation

Author component instances, manage component states, and convert layers to reusable components. All five methods are free -- no entitlement gating.

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 (no canvas change); selects the new component.

Result: the component's id on success; not_found if the layer id doesn't exist.

placeInstance(componentId)

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

Creates an instance of a component on the active page at the viewport center. The instance is tracked as a bridge layer and inherits changes from the master component; selects the new instance.

Result: the instance's id on success; not_found if the component id doesn't exist.

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 state to a component and makes it the active state. States let a component represent multiple visual variations. If name is omitted, defaults to "State {n}".

Result: { id, name } on success; not_found if the component id doesn't exist.

setActiveState(componentId, input?)

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

Switches the active state by { stateId }; omit stateId to switch back to Base (the default state, no state overlay).

Result: void on success; not_found if the component id doesn't exist.

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 states created on a component. Base is always implicitly available (never listed in states, active when activeStateId is undefined). Honesty note: only states created via addState are surfaced — states imported from a design file are not surfaced in v1 (a limitation of the current authoring surface, not a fidelity issue).

const states = figpea.component.getStates(componentId);
if (states.ok) {
  console.log(`Active: ${states.value.activeStateId || "Base"}`);
}

figpea.export -- Layer and Project Export

All export operations honor the same entitlement gating as the UI (via hasClaim(...)), returning { ok: false, code: "entitlement_required" } instead of popping an upgrade modal when access is denied. Authoring under figpea.session, figpea.layer, and figpea.history is always free; export is the one boundary that can require a plan.

layer(id, input) / artboard(id, input)

Signature: layer(id: string, input: { format: "png" | "jpeg" | "webp" | "svg", scale?: number, suffix?: string }): Promise<{ ok: true, value: { bytes: string, mime: string, filename: string } } | { ok: false, code: string, message: string }>

Exports a single layer as a raster image or SVG. scale ≠ 1 requires hasClaim("extract.scale"); format === "svg" requires hasClaim("extract.svg"). artboard(id, input) has the identical signature and gating -- an "artboard" is just a page/folder layer addressed like any other layer id.

Result: the exported bytes as a base64 string, a MIME type, and a suggested filename. Decode the bytes with Uint8Array.from(atob(bytes), c => c.charCodeAt(0)) -- not new Blob([atob(bytes)], ...), which corrupts anything outside the Latin-1 range because atob produces a binary string, not raw bytes:

const result = await figpea.export.layer(layerId, { format: "png", scale: 2 });
if (result.ok) {
  const { bytes, mime, filename } = result.value;
  const byteArray = Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0));
  const blob = new Blob([byteArray], { type: mime });
  // download or process blob...
} else if (result.code === "entitlement_required") {
  console.log("User must upgrade to export at this scale");
}

project(input)

Signature: project(input: { format: "pdf" | "zip" | "figpea", size?: number, imageFormat?: "png" | "jpeg" | "webp" }): Promise<{ ok: true } | { ok: false, code: string, message: string }>

Exports the whole active project as PDF (requires hasClaim("export.pdf")), ZIP of per-layer rasters (requires hasClaim("export.zip")), or the native Figpea format (ungated). size/imageFormat apply to the ZIP format's raster output.

const result = await figpea.export.project({ format: "pdf" });
if (!result.ok && result.code === "entitlement_required") {
  console.log("User must upgrade to export PDF");
}

specBundle() / tokens()

Signatures: specBundle(): Promise<{ ok: true, value: { html: string } } | { ok: false, code: string, message: string }> and tokens(): Promise<{ ok: true, value: { css: string, json: string } } | { ok: false, code: string, message: string }>

Developer-handoff exports. specBundle() returns a self-contained, offline HTML string (zero external references) covering every artboard's inspectable elements -- position, size, fills, typography, copyable CSS. tokens() returns the active project's distinct fill colors and text styles as CSS custom properties (css) plus a documented JSON subset (json), with deterministic naming across consecutive exports. Both take no arguments and return their text directly rather than triggering a download. Both require hasClaim("export.zip") -- the same claim project({ format: "zip" }) gates on. They are not free, the same "authoring free, export gated" boundary above applies to them too.

const bundle = await figpea.export.specBundle();
const tokens = await figpea.export.tokens();
if (bundle.ok && tokens.ok) {
  console.log(bundle.value.html.length, tokens.value.css);
} else if (!bundle.ok && bundle.code === "entitlement_required") {
  console.log("User must upgrade to export the spec bundle / tokens");
}

originals()

Signature: originals(): Promise<{ ok: true, value: { bytes: string, mime: "application/zip", filename: string } } | { ok: false, code: string, message: string }>

Recovers every distinct (by content hash) original embedded image in the active project as a ZIP of the verbatim original bytes -- no re-encode, byte-identical to the source -- the agent-API mirror of "Recover Original Images." Takes no arguments and returns the ZIP as base64 bytes rather than triggering a download.

Result: ZIP bytes as a base64 string, mime: "application/zip", and a suggested filename.

const result = await figpea.export.originals();
if (result.ok) {
  const { bytes, filename } = result.value;
  const blob = new Blob([Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0))], { type: "application/zip" });
  // save or process blob...
} else if (result.code === "entitlement_required") {
  console.log("User must upgrade to recover original images");
}

assetHarvest(options?)

Signature: assetHarvest(options?: { preset?: "web" | "ios" | "android" }): Promise<{ ok: true, value: { bytes: string, mime: "application/zip", filename: string } } | { ok: false, code: string, message: string }>

Harvests every detected asset (embedded images and icon-sized vector groups) from the active project as a preset-formatted ZIP with platform-appropriate scales, formats, and folder layout -- the agent-API mirror of the "Harvest Assets" gallery modal. preset defaults to "web"; the agent-API call exports all detected assets (selection is a UI-only affordance). Browsing the gallery is free -- only the export call itself is gated.

Result: ZIP bytes as a base64 string, mime: "application/zip", and a suggested filename.

const result = await figpea.export.assetHarvest({ preset: "ios" });
if (result.ok) {
  const { bytes, filename } = result.value;
  // save or process the ZIP bytes...
} else if (result.code === "entitlement_required") {
  console.log("User must upgrade to export assets");
}

// Harvest with the default (web) preset:
const webAssets = await figpea.export.assetHarvest();

figmaKit()

Signature: figmaKit(): Promise<{ ok: true, value: { bytes: string, mime: "application/zip", filename: string } } | { ok: false, code: string, message: string }>

Assembles the Figma Migration Kit: a Figma-tuned SVG for every top-level artboard (text stays editable, named layers/groups keep their names) plus an assets/ folder, fonts.md, copy-deck.md, and README.md, all in one ZIP -- the agent-API mirror of the "Figma Migration Kit" export option. This emits new Figma-tuned output; it does not save back into an existing Figma file. Takes no arguments.

Result: ZIP bytes as a base64 string, mime: "application/zip", and a suggested filename.

const result = await figpea.export.figmaKit();
if (result.ok) {
  const { bytes, filename } = result.value;
  // save or process the ZIP bytes...
} else if (result.code === "entitlement_required") {
  console.log("User must upgrade to export the Figma Migration Kit");
}

Entitlement note: like specBundle()/tokens() above, originals(), assetHarvest(), and figmaKit() all require hasClaim("export.zip") -- they are not free -- the same claim project({ format: "zip" }) gates on. The "authoring free, export gated" boundary applies to every developer-handoff export in this section, with no exceptions.

contactSheet()

Signature: contactSheet(): Promise<{ ok: true, value: { bytes: string, mime: "application/pdf", filename: string } } | { ok: false, code: string, message: string }>

Exports the whole active project's contact-sheet PDF — a cover page (project name, stats, font manifest) plus one titled page per artboard in document order. Takes no arguments.

Gating: Requires hasClaim("export.pdf") -- the same claim project({ format: "pdf" }) above gates on (the contact sheet is a PDF; no new claim key).

Result: PDF bytes as a base64 string, mime: "application/pdf", and a suggested filename.

const result = await figpea.export.contactSheet();
if (result.ok) {
  const { bytes, filename } = result.value;
  const blob = new Blob([Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0))], { type: "application/pdf" });
  // save or process blob...
} else if (result.code === "entitlement_required") {
  console.log("User must upgrade to export the contact sheet");
}

flowPoster(input?)

Signature: flowPoster(input?: { renderFormat?: "svg" | "png" | "pdf" }): Promise<{ ok: true, value: { bytes: string, mime: string, filename: string } } | { ok: false, code: string, message: string }>

Exports the project's prototype flow poster — one node per artboard (thumbnail + name), one edge per parsed prototype transition — as SVG (default) or a PNG/PDF render of that same SVG. GATED on hasClaim("export.pdf").

Result: Bytes as a base64 string, MIME type (matching renderFormat), and a suggested filename.

const result = await figpea.export.flowPoster({ renderFormat: "png" });
if (result.ok) {
  const { bytes, mime, filename } = result.value;
  const blob = new Blob([Uint8Array.from(atob(bytes), (c) => c.charCodeAt(0))], { type: mime });
  // save or process blob...
} else if (result.code === "entitlement_required") {
  console.log("User must upgrade to export the flow poster");
}

figpea.canvas -- Camera Control, Live Screenshot, Layer Geometry & Widget Geometry

A visual-feedback aid for driving the editor programmatically: camera (viewport) control, a live capture of what's actually on the canvas right now, and a geometry read-out of the currently-rendered transform widget. All of figpea.canvas is ungated -- none of it routes through the entitled export encoder path, because none of it is an export artifact; it's the agent's own equivalent of looking at the screen (and moving its own eyes to the right place first).

Recommended feedback loop: mutate (figpea.layer.*) -> figpea.canvas.fit(pageId) (bring the new/changed content into frame, mirroring the UI's zoom-to-fit) -> your browser-automation MCP's own native screenshot -> iterate. figpea.canvas.screenshot() below is the explicit no-camera-move fallback, for an isolated single-layer or off-screen check that doesn't disturb whatever the camera is currently framing.

fit(id?)

Signature: fit(id?: string): Promise<{ ok: true, value: FigpeaViewport } | { ok: false, code: string, message: string }>

Pans/zooms the editor camera so the given layer/page/artboard (or the whole design when id is omitted) is framed within the visible viewport, centered with ~10% padding. A degenerate/zero-area target (e.g. an empty new project) is a no-op that returns the current viewport unchanged (ok, not an error).

const rect = await figpea.layer.create("rect", { rwidth: 100, rheight: 50, style: { fill: "#ff0000" } });
await figpea.canvas.fit(rect.value.id); // frame the new shape

getViewport() / setViewport(viewport)

Signatures: getViewport(): Promise<{ ok: true, value: FigpeaViewport } | { ok: false, code: string, message: string }> and setViewport(viewport: { x: number, y: number, zoom: number }): Promise<{ ok: true, value: FigpeaViewport } | { ok: false, code: string, message: string }>

getViewport reads the current editor camera as a world-space visible rectangle -- FigpeaViewport is { x, y, width, height, zoom }, where { x, y } is the world coordinate at the DOM viewport's top-left corner, zoom is the stage scale factor, and width/height are derived, read-only world extent. This is the same world basis figpea.session.layerTree()/layerById()'s bounds use, so a layer's bounds can be compared directly against the viewport. setViewport is the exact inverse: it accepts only { x, y, zoom } (width/height are outputs of getViewport/fit, never accepted here), and setViewport(v) followed by getViewport() round-trips v (within floating-point tolerance).

await figpea.canvas.setViewport({ x: 0, y: 0, zoom: 1 });
const viewport = await figpea.canvas.getViewport();

screenshot(options?)

Signature: screenshot(options?: { id?: string, pixelRatio?: number }): Promise<{ ok: true, value: { bytes: string, mime: "image/png", width: number, height: number } } | { ok: false, code: string, message: string }>

Captures the live canvas as a PNG -- the whole visible stage by default, or (via options.id) a single layer/page/artboard's own on-canvas region; options.pixelRatio controls the capture's pixel density. bytes is base64-encoded PNG data with the identical decode gotcha as figpea.export.layer's bytes above -- see that section if you need raw bytes rather than a data:image/png;base64,... URL.

const shot = await figpea.canvas.screenshot();
if (shot.ok) {
  const img = new Image();
  img.src = `data:image/png;base64,${shot.value.bytes}`;
}

widgetGeometry()

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. Reads the widget's own rendered DOM, so every point matches exactly where that handle renders -- no transform/camera math is performed. Pair it with a real pointer drag (e.g. Playwright's page.mouse.move/down/move/up) to exercise the literal handle-drag gesture end-to-end. Returns { ok: false, code: "not_found" } when nothing is selected, or the rendered widget doesn't support these handles (e.g. a line-shape layer).

await figpea.layer.select(rectId);
const geometry = await figpea.canvas.widgetGeometry();
if (geometry.ok) {
  const { x, y } = geometry.value.handles.rightBottom;
  // drive a real pointer drag on the bottom-right resize handle...
}

layerRect(id)

Signature: layerRect(id: string): { ok: true, value: { x: number, y: number, width: number, height: number } } | { ok: false, code: string, message: string } (synchronous -- no Promise)

Returns the world-space bounding box (axis-aligned) of any layer, selected or not -- a pure model-level geometry read, the same bounds/calcBounds() basis layerTree()/layerById()/fit() use. Not a rendered measurement -- it excludes rendered stroke/shadow overflow; use figpea.canvas.screenshot() if you need actual pixel extent. Selection-preserving: a read-only call that never touches the current selection.

Result: { x, y, width, height } in world px (x/y are the left/top edge). Hidden or zero-area layers resolve ok with their (possibly zero-area) rect -- visibility is a separate axis, never a throw; only an unknown layer id returns not_found.

// Get the bounding box of a layer without selecting it.
const rect = await figpea.canvas.layerRect(layerId);
if (rect.ok) {
  console.log(`${rect.value.width}x${rect.value.height} at (${rect.value.x}, ${rect.value.y})`);
}

figpea.report -- Project Health Report, Copy Deck, Font Manifest & Soft Diagnostics

All four methods are FREE -- ungated -- inspection, like figpea.canvas above (only original-image recovery itself, figpea.export.originals above, requires a claim). 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 }[];
  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 }[] } | { 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.

const result = await figpea.report.fonts();
if (result.ok) {
  const missing = result.value.filter((f) => f.missingLocally);
  console.log(`${missing.length} fonts missing locally`);
}

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 and XD. 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. A vector construct Figpea could not reproduce faithfully, replaced with a simpler approximation — currently an unresolvable <pattern> falling back to a solid fill.
  • 'layer_decode_failed'PSD, severity error. A layer whose decode threw during import; the layer is not shown in the project — the document still opened, but that layer's content is missing.

Honesty note: Diagnostic coverage breadth reflects wiring order, not parser fidelity — it grows per format over time, and a format surfacing more codes today is not inspected more faithfully than one surfacing fewer. .fig/PDF stay unwired for a channel reason, not a fidelity one: Figma's degradation sites log instead of calling a warning callback, and the PDF decoder emits no decode warnings at all.

const result = await figpea.report.diagnostics();
if (result.ok && result.value.length === 0) {
  console.log("File opened clean — no detected soft issues");
} else if (result.ok) {
  for (const diag of result.value) {
    console.warn(`[${diag.code}] ${diag.message}`);
  }
}

Error codes

The complete set of codes returned across the surface:

CodeMeaning
not_foundResource (layer, project, active session, component) does not exist -- including an unselectable id (layer.select), no rendered transform widget (canvas.widgetGeometry), an unknown layer id (canvas.layerRect), no active session (report.summary / copyDeck / fonts / diagnostics), or unknown component/layer id in figpea.component.* lookups
unsupported_style_keyStyle patch includes a key not in the whitelist
entitlement_requiredUser does not have the required claim for an export (including export.specBundle / export.tokens / export.originals / export.assetHarvest / export.figmaKit / export.contactSheet / export.flowPoster)
unsupported_kindlayer.create's kind argument isn't a recognized layer kind
not_flippableThe target layer's mutator doesn't implement flipping
not_a_text_layerThe target layer isn't a text layer (setText)
not_a_groupThe target layer isn't a folder/group layer (ungroup)
group_failedGrouping the given selection produced no group
open_failedThe file failed to decode/open (session.openFile)
export_failedAn unexpected error was thrown during export encoding
invalid_formatexport.project's format isn't "pdf" / "zip" / "figpea"
invalid_positionsetPosition's { x, y } isn't both finite numbers
invalid_transformsetTransform's matrix isn't an array of exactly 6 finite numbers
unsupported_opbatch's op method is "batch" itself (recursion) or not a recognized layer.* method name
timeoutThe operation did not complete before the given deadline (session.waitForIdle)
invalid_style_valueA whitelisted style key is present but its value is outside the enum it allows (patternScaleType/patternRepeat, malformed gradient with bad type/empty/out-of-range stops/non-string/empty color, out-of-enum case)
invalid_image_sourceThe image source (url fetch or bytes decode) failed to resolve to a usable image (layer.setImageFill, layer.create)
invalid_selectorThe selector object includes an unknown key or an uncompilable regex pattern/flags (session.find)
invalid_interactioninteraction.create validation failure: unsupported trigger/action, missing/invalid targetId, or non-page target

Download the Skill

The Tier-1 recipe above, plus the full authoring loop, a reference-recreation method (real-shape authoring and a target-comparison loop), undo etiquette, and entitlement boundaries, are packaged as a Claude Agent Skill you can drop straight into an agent's skills directory:

Download the Skill (SKILL.md)