---
name: figpea-agent
description: Drives the Figpea design editor programmatically through its window.figpea scriptable API over a browser-automation MCP (Playwright MCP, Chrome DevTools MCP, or similar) — opening the editor with ?agent=1, discovering the surface with describe(), authoring and mutating layers, reading back the layer tree, and iterating with screenshots. Use this skill whenever an agent needs to create, inspect, or edit a Figpea design end-to-end without manual UI clicks (e.g. build a page layout, adjust styles or text, verify a change, recreate a reference faithfully), and needs to know the authoring loop, undo etiquette, and export entitlement boundaries.
---

# Figpea Agent

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

## 1. Open the editor with the agent flag

`window.figpea` is only attached when the editor URL includes `?agent=1`. Point
your browser-automation MCP at that URL, e.g. `https://<editor-host>/?agent=1`
(or `http://localhost:8080/?agent=1` in a local dev build). The attach happens
after the app's initial render, so wait for `window.figpea` to exist before
calling anything — poll for it (e.g. `!!window.figpea`) rather than assuming it
is present immediately on navigation.

## 2. Discover the surface first: `figpea.describe()`

Before doing anything else, call `figpea.describe()`. This is an agent's
**first call** on every session — it returns the full runtime manifest (the
`session`, `layer`, `history`, `export`, `canvas`, and `report` groups, each
method's parameters and a short doc string) so you can confirm what is
available before relying on it, instead of guessing method names from memory.
Each method's `params` is a typed schema (not a free-text hint), and the
manifest also carries an enumerable `errorCodes` catalog — the contract is
machine-authoritative, so an agent can introspect it straight off
`describe()` instead of relying on this doc alone. `figpea.version` holds the
API's own semver string, independent of the product version, in case your
tooling needs to gate on contract version.

## 3. The authoring loop

Start a project, then create and refine layers, checking `ok` after every call:

- **Start**: `figpea.session.newProject()` for a blank project, or
  `figpea.session.openFile(options)` to load an existing design (by URL or raw
  bytes).
- **Create**: `figpea.layer.create(kind, props)` — `kind` is one of `"page"`,
  `"rect"`, `"ellipse"`, `"line"`, `"polygon"`, `"path"`, `"text"`, `"folder"`,
  `"image"`. Pass `parentId` in `props` to attach under a specific page/folder
  (defaults to the project root), plus that kind's own geometry fields (e.g.
  `x`/`y`/`rwidth`/`rheight` for `"rect"`, `text` for `"text"`,
  `pageWidth`/`pageHeight` for `"page"`). `"path"` takes an SVG `d` string and
  `"polygon"` takes a `points` vertex list — these, not a stroked rect, are
  how real iconography gets authored (see "Recreating a reference faithfully"
  below).
- **Transform**: `figpea.layer.move(id, dx, dy)`, `figpea.layer.resize(id, { width?, height? })`,
  `figpea.layer.rotate(id, deg)`, `figpea.layer.flipX(id)` / `figpea.layer.flipY(id)`.
  `figpea.layer.setPosition(id, {x, y})` sets a layer's absolute local
  translate — idempotent, unlike `move`'s relative delta, so repeating the
  same call always lands on the same point. `figpea.layer.setTransform(id,
  matrix)` sets a layer's full `[a, b, c, d, e, f]` affine transform
  verbatim (no decompose/recompose), so a read-back immediately deep-equals
  what you set.
- **Restructure**: `figpea.layer.reorder(id, targetId, pos)`,
  `figpea.layer.reparent(id, parentId, index?)`, `figpea.layer.group(ids)` /
  `figpea.layer.ungroup(id)`, `figpea.layer.clip(id)` / `figpea.layer.unclip(id)`,
  `figpea.layer.setVisible(id, visible)`, `figpea.layer.delete(id)`.
- **Style & text**: `figpea.layer.stylePatch(id, patch)` for whitelisted style
  keys (`fill`, `fillType`, `strokeEnabled`, `strokeColor`, `strokeWidth`,
  `opacity`, `cornerRadius`, and the text-only `fontFamily`/`fontSize`/`fontWeight`/
  `fontItalic`/`align`); `figpea.layer.setText(id, text)` to replace a text
  layer's content.
- **Inspect**: `figpea.session.layerTree()` for the full curated tree,
  `figpea.session.layerById(id)` for a single node by known id, and
  `figpea.session.find(selector)` to locate layers by attribute (name, type,
  text, visibility, structure) — returns matching nodes in document order,
  ideal for the "find by property then select/mutate" pattern. Also
  `figpea.session.getSelection()` / `figpea.session.setSelection(ids)` to read
  or set the current selection. `figpea.layer.select(target)` (a single id or
  an array) is the ergonomic authoring-surface wrapper over the same
  selection path — it also renders the transform widget for the selected
  layer(s). `figpea.canvas.layerRect(id)` reads an arbitrary layer's
  world-space bounding box without selecting it (contrast with
  `figpea.canvas.widgetGeometry()`, which gives the selected layer's
  client/viewport pixel transform-widget handles).
- **Batch**: `figpea.layer.batch(ops)` applies a sequence of `figpea.layer.*`
  ops as **one** undo step — `figpea.history.undo()` reverses the whole
  batch in a single call. All-or-nothing: if any op fails, the whole batch is
  rolled back (no partial application, no undo step created) and the result
  reports the failing op's own `code`/`message`. Prefer it over many
  individual calls when several layers/mutations should undo together.
- **Camera & screenshot**: `figpea.canvas.fit(id?)` pans/zooms the editor
  camera so a layer/page (or the whole design) is framed in the viewport —
  mirrors the UI's own zoom-to-fit. `figpea.canvas.getViewport()` /
  `figpea.canvas.setViewport(viewport)` read/set the camera as a world-space
  `{x, y, zoom}` rect (the same world basis `layerTree()`'s `bounds` use).
  `figpea.canvas.screenshot(options?)` captures the live canvas and returns
  `{ bytes, mime, width, height }`, where `bytes` is a base64-encoded PNG
  string (decode with `Uint8Array.from(atob(bytes), c => c.charCodeAt(0))`,
  or use it directly as a `data:image/png;base64,...` URL) — free and
  ungated, since it is a visual-feedback aid, not an export artifact.
  `figpea.canvas.widgetGeometry()` reads the selected layer's
  on-screen transform-widget handle/corner coordinates, useful for driving a
  real pointer-drag gesture.
- **Report**: `figpea.report.summary()` returns the project health report
  (artboard names, component/interaction counts, font manifest,
  distinct-by-content-hash image count/total bytes, and text stats) —
  mirrors the UI's Project Report modal. `figpea.report.copyDeck()` returns
  `{ markdown, csv }` compiled from every non-empty text layer's content,
  grouped by top-level artboard. `figpea.report.fonts()` is a convenience
  projection of `summary().fonts`. `figpea.report.diagnostics()` returns soft
  decode/render diagnostics on a successfully-opened file — wired sources:
  `font_missing_local` (every format), `unsupported_node` and `rasterized_fallback`
  (SVG), `unsupported_node` (XD), `layer_decode_failed` (PSD). The code set
  grows per format over time as more decoders' signals get wired; **its breadth
  reflects wiring order, not parser fidelity** — 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 ctx.warn, and the PDF decoder emits no decode warnings at all. An empty result means no *detected* soft issues — a clean open *by the
  checks this version runs* — never a guarantee of pixel-perfect fidelity; a
  warning/error entry is a soft problem in a file that did open, not a failed
  open; resets on each `openFile`/`newProject`; free/ungated. All four are
  free and ungated.

## 4. Recreating a reference faithfully

When the task is to recreate a reference design — a screenshot, a mockup, an
existing page — treat fidelity to that reference as the job itself. Five
principles, in this order:

1. **Read the target first.** Enumerate what's actually on the reference —
   sections, elements, layout — and sample its real colors and real
   dimensions directly from it, before creating anything. Don't guess a
   color or a size from memory or convention.
2. **Reproduce what is there, and only that**: no invented decoration, no skipped elements.
   Every element the reference has gets a corresponding layer, and nothing
   appears that the reference doesn't have.
3. **Author the real shape.** Icons, logos, glyphs, and other
   non-rectangular marks get built with `path`, `polygon`, `line`, or
   `ellipse` — never a placeholder rectangle standing in for the actual
   mark.
4. **Iterate section by section.** After each pass, compare the render
   against the reference and close the largest gap — the single most
   noticeable discrepancy — before starting the next pass.
5. **State substitutions explicitly.** When a construct can't be authored
   from these primitives — raster art, photography — say so plainly rather
   than silently approximating it with something else.

**Two observed failure modes**, named so they're recognizable next time: a
stroked `rect` standing in for a real icon (it reads as a placeholder box,
not the mark it's meant to represent), and a semi-transparent "shade" band
added "for legibility" when the reference never had one (an invented
element, not a reproduction of what's there).

A real icon is a `path`, not a box — a small plus mark:

```javascript
const icon = await figpea.layer.create("path", {
  parentId: page.id,
  path: "M10 0H14V10H24V14H14V24H10V14H0V10H10V0Z",
  style: { fill: "#111827" },
});
```

**Text-fidelity gotchas** (the highest-frequency misses when recreating a
reference):
- Text is positioned by `transform`, not by `x`/`y` props.
- Color comes from `style.fill` — the dark default is invisible on a dark
  background, so set it explicitly when the reference has one.
- Use `\n` inside the `text` string for multi-line text.

## 5. Wiring interactions (prototypes)

After building a layout with pages and layers, add prototype interactions to
make it navigable. `figpea.interaction.create(layerId, { trigger, action, targetId })`
authors a single interaction — a trigger event (user `tap` or timer `time`) that
navigates to a target page. Read back via `figpea.interaction.list(layerId)` or
the compact `interactions` field on `figpea.session.layerById(layerId)`, verify
with `figpea.report.summary().interactionCount`, and undo with
`figpea.history.undo()` (one step reverses one authored interaction). Author
interactions freely and ungated — they are model mutations, not a playback
entitlement; the Pro-gated Present-mode playback itself remains separate.

**Example:**
```javascript
// Pages already exist: pageA, pageB
const rect = (await figpea.layer.create("rect", { parentId: pageA })).value.id;
// Author: tap on the rect navigates to pageB
const interact = await figpea.interaction.create(rect, {
  trigger: "tap",
  action: "navigate",
  targetId: pageB,
  properties: { duration: 0.3 }
});
// Read back:
const interactions = figpea.interaction.list(rect);
console.log(interactions.value); // [{ id: "...", trigger: "tap", action: "navigate", targetId: pageB, ... }]
// Or:
const node = (await figpea.session.layerById(rect)).value;
console.log(node.interactions); // same data, read-back convenience
// Verify count:
const summary = (await figpea.report.summary()).value;
console.log(summary.interactionCount); // incremented
// Undo:
await figpea.history.undo(); // removes the one authored interaction
```

## 6. Screenshot feedback loop

After a meaningful mutation (or a small batch of them), **settle the canvas with
`figpea.session.waitForIdle()`** (awaits the render to complete — no guessing
with arbitrary timeouts) and call `figpea.canvas.fit(id?)` so the content you
just changed is actually framed in view, then take a screenshot with your
MCP's screenshot tool — or with `figpea.canvas.screenshot()`, the in-contract
alternative with no separate browser-automation screenshot call needed, which
returns `{ bytes, mime, width, height }` (`bytes` is a base64-encoded PNG
string — decode with `Uint8Array.from(atob(bytes), c => c.charCodeAt(0))`, or
use it directly as a `data:image/png;base64,...` URL) — and look at it: the
rendered canvas is the ground truth for what a human will see, and when
you're recreating a reference, the reference is the target. Put the render
next to the reference and name the largest discrepancy — wrong color,
missing element, invented element, off proportions — fix that one, and
repeat until a pass turns up nothing. Cross-check what you observe against
`figpea.session.layerTree()`'s `transform`/`bounds`/`style` fields for the
layers you just touched, then decide the next mutation. Treat the mutate →
settle → fit/screenshot → observe → iterate cycle as the default loop for
any non-trivial authoring task, rather than issuing a long chain of blind
calls or guessing render timing.

**Opening a file and verifying it opened cleanly**: After `figpea.session.openFile(...)` 
and `figpea.session.waitForIdle()`, call `figpea.report.diagnostics()` to assert 
the file opened cleanly — an empty result (or only `info`-level entries) means no 
detected soft issues, so you can proceed with confidence; non-empty warnings or errors 
indicate partial-fidelity outcomes (e.g. missing fonts) that may affect subsequent 
work. This gives you a first-class branch point: open → verify → proceed.

## 7. Undo etiquette

If a mutation didn't produce the intended result, prefer `figpea.history.undo()`
over manually computing an inverse mutation — it shares the same history stack
as the UI, and each `figpea.layer.*` call is recorded as exactly one undo step
regardless of how long it took. `figpea.history.redo()` re-applies the most
recently undone step. Undo liberally while iterating; it is cheaper and safer
than hand-reconstructing prior state.

## 8. Entitlement boundaries

Authoring — everything under `figpea.session.*`, `figpea.layer.*`,
`figpea.interaction.*`, `figpea.history.*`, and `figpea.canvas.*` — is free
and ungated. The canvas group's screenshot/camera/widget-geometry/layer-rect
methods are visual-feedback and geometry-inspection helpers, not export
artifacts, so none of them route through the entitled encoder path.
`figpea.interaction.*` (`create`/`list`/`remove`) authoring is likewise free
model mutation and does **not** enable the Pro-gated Present-mode playback
entitlement — authoring and playback are separate concerns. `figpea.report.*`
(`summary`/`copyDeck`/`fonts`/`diagnostics`) is likewise free and ungated — it mirrors the
UI's Project Report modal; only the *recovery* of original image bytes below
is paid.

Export is different: `figpea.export.layer(id, input)`,
`figpea.export.artboard(id, input)`, and `figpea.export.project(input)` honor
the same entitlement gating as the UI and can return
`{ ok: false, code: "entitlement_required" }` instead of the exported bytes
when the active user lacks the needed claim (e.g. a non-default export scale,
SVG export, or PDF/ZIP project export). The developer-handoff exports —
`figpea.export.specBundle()`, `figpea.export.tokens()`,
`figpea.export.originals()`, `figpea.export.assetHarvest(options?)`, and
`figpea.export.figmaKit()` — are **not** free either: they require the same
paid entitlement claim that gates the UI's ZIP-based project export, and
return the same `{ ok: false, code: "entitlement_required" }` shape (with
text/bytes in place of a download on success) rather than downloading a
file. Always branch on that code rather than assuming an export call
succeeds — surface it to the human (or fall back to a supported export)
instead of retrying the same call.

`figpea.export.contactSheet()` and `figpea.export.flowPoster(input?)` are paid
exports gated on the PDF-export claim `hasClaim("export.pdf")` — distinct from
the ZIP-based claim that gates the developer-handoff set above. `contactSheet()`
takes no arguments and returns the project's contact-sheet PDF (cover page with
project name/stats/font manifest, then one titled page per artboard in document
order) as base64 bytes + `mime: "application/pdf"` + suggested filename.
`flowPoster(input?)` accepts optional `{ renderFormat?: "svg" | "png" | "pdf" }`
(defaults `"svg"`) and returns the flow poster (one node per artboard, one edge
per parsed transition) as base64 bytes + matching MIME + suggested filename. Both
can return `entitlement_required` and never trigger a browser download. The same
`hasClaim("export.pdf")` applies regardless of `flowPoster`'s `renderFormat` —
note this explicitly, since "SVG must be cheaper" is an obvious wrong inference.

## Canonical Tier-1 recipe

The block below is the canonical, executable Tier-1 recipe: open the editor
with `?agent=1`, wait for `window.figpea`, then run
`await figpeaTier1Recipe(window.figpea)`. It exercises the loop above end to
end — discover, author a page with a rect and a caption, refine, and read the
tree back — and every call is `must()`-checked so a failure throws loudly
instead of failing silently.

<!-- figpea-recipe:start -->
```js
// 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. Settle the canvas — await the render to complete before observing.
  must(await figpea.session.waitForIdle(), "waitForIdle");

  // 5. 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 };
}
```
<!-- figpea-recipe:end -->

The recipe returns `{ pageId, rectId, labelId, tree }`. `tree` is the same
shape as `figpea.session.layerTree()`'s result — walk it (e.g. depth-first by
`id`) to confirm the three created layers are present, or use
`figpea.session.find(selector)` to locate a specific layer by name/type/text
attribute instead of walking manually, then continue the screenshot-and-mutate
loop from there.
<!-- figpea-skill-identity contract=0.21.0 sha256=87b57dd9b77e8534fc29b2b036e60a537119258a9663727b11904d1064a975d8 -->
