---
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, opening existing PSD, Adobe XD, Figma (.fig), SVG, and PDF files, discovering the surface with describe(), authoring and mutating layers, wiring prototype interactions, authoring components and instances, 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, wire a clickable prototype, recreate a reference faithfully, pull a project report), 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 a **compact index** (each group →
every method's name and one-line `doc`, plus `version` and enumerable `errorCodes`
keys) so you can confirm what is available before relying on it, instead of
guessing method names from memory. It deliberately omits `params`/`byKind`/`result`
schemas so the mandated first call stays small as the surface grows. The eight
live groups are: `session`, `layer`, `history`, `export`, `canvas`, `report`,
`interaction`, and `component`. For full parameter schemas and result shapes,
use a **dotted-string selector** — `describe("layer.create")` (one method),
`describe("layer")` (a group), `describe("errorCodes")` (the error catalog) —
to drill in on-demand. Unknown/malformed selector → `undefined` (never throws).
`figpea.version` holds the API's own semver string, independent of the product
version, in case your tooling needs to gate on contract version.

Changed in contract `0.31.0`: `setPosition` and `rotate` operate on a layer's visible box (its `bounds`) rather than its raw transform translate. If you cached an earlier copy of this skill, re-read `figpea.describe()`.

## 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).   `openFile` validates parameters upfront and fails fast within 1s returning
  `{ ok: false, code: "invalid_params" | "open_failed", message }` without hanging on invalid inputs, malformed parameters, or decode failures.
  It supports `name` as an alias for `fileName`, and accepts `Uint8Array`, `ArrayBuffer`, or `number[]` for `bytes`.
  **Layer ID persistence rules**: PSD files regenerate synthetic layer IDs on every open; Adobe XD preserves 100% of native layer IDs across file opens; Figma (`.fig`) preserves native layer IDs (e.g. `0:1`) while regenerating synthetic wrapper nodes created during import (`... Mask Group`).
- **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.
  `rwidth`/`rheight` for `"rect"`, `rx`/`ry` for `"ellipse"`, `text` for `"text"`,
  `pageWidth`/`pageHeight` for `"page"`. **Placement**: create() specifies extent only;
  to position a layer, use `setPosition(id, {x, y})` after creation. E.g.
  `const rect = create("rect", {parentId, rwidth: 100, rheight: 50}); setPosition(rect.id, {x: 10, y: 20})`.
  Pages are auto-placed clear of existing pages in the document when no explicit
  `transform` is given (the editor's own add-page tool is a manual drag action with
  no fixed placement rule of its own to match — this auto-placement rule exists only
  for the agent-API path today). To position a page explicitly, pass a `transform`
  matrix — an explicit transform wins over auto-placement. `"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). Props that belong to a different kind are rejected with `{ok:false, code:"invalid_transform"}`; if you hit that, run `describe("layer.create")` to see the full applicable set for your kind.
- **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})` puts the layer's **bounds top-left**
  exactly at `(x, y)` — the same `bounds` you read back from `layerById`/`layerTree` —
  for every layer kind, and repeating the call is idempotent (unlike `move`'s relative delta).
  `figpea.layer.rotate(id, deg)` pivots about the visible box's centre, so a layer spins in place.
  `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 & stroke: `fill`, `fillType` (`"none"` | `"solid"` | `"gradient"` | `"pattern"`; the gradient *type* names `linear`/`radial`/`conic` belong in `gradient.type`, never in `fillType`), `strokeEnabled`, `strokeColor`,
  `strokeWidth` · stroke controls: `strokePosition`, `lineCap`, `lineJoin`,
   `dashArray`, `dashOffset`, `miterLimit` · effects: `dropShadow`, `innerShadow`,
   `objectBlur`, `backgroundBlur`, `overlay`, `gradientOverlay`, `patternOverlay`, `outerGlow`, `innerGlow` · text & frame: `fontFamily`,
  `fontSize`, `fontWeight`, `fontItalic`, `align`, `case`, `valign`, `fixWidth`,
  `fixHeight` · pattern & gradient: `patternScaleType`, `patternRepeat`,
  `gradient` · shared: `opacity`, `cornerRadius`, `blendMode`. See
  `window-figpea-api.md`'s `### stylePatch(id, patch)` section for value shapes
  and enums. `figpea.layer.setText(id, text)` replaces a text layer's content.
- **Image fills**: `figpea.layer.setImageFill(id, source)` attaches an image to a shape. A `data:` URI is accepted through the `{ url }` channel, which keeps `figpea.canvas.screenshot()` working; converting an asset to a `data:` URI happens **in the page** (nothing about the document leaves the browser), which avoids cross-origin tainting entirely. Read-back reveals `fillType: "pattern"` plus fit/tile mode only, never the image content or original source.
- **Constraints & responsive layout**: `figpea.layer.setResizeMode(id, mode)` sets how a container's children reflow when resized (`"content"` = hug, `"viewport"` = fixed frame, `"constraint"` = per-child axes — note the singular/plural trap: the enum value is `constraint`, singular, but every inspector label for it reads **Constraints**, plural). Then `figpea.layer.setConstraints(id, { horizontal?, vertical? })` pins each child's axes to its container's edges — valid values are `"left"`, `"right"`, `"leftAndRight"`, `"center"`, `"scale"` (horizontal) and `"top"`, `"bottom"`, `"topAndBottom"`, `"center"`, `"scale"` (vertical). The pattern is: set the container mode first (materializes a frame if needed), then set each child's axes, then verify by calling `figpea.layer.resize(id, { width, height })` — if the child tracks an edge/corner correctly, the layout is responsive. These two methods are the agent's only path to authored constraint-based reflow; constraints are inert without `"constraint"` mode on the parent. **Read-back**: constraints are authored in Figpea; no source format carries them — a layer you haven't set reports no `constraints` field at all, and the model default (`left`/`top`) is Figpea's own default, not a value read from your file; `resizeMode` is likewise absent until you call `setResizeMode()`.
- **Reuse the project's own images**: `figpea.report.projectImages()` lists every DISTINCT image the project already uses as a layer fill (content-hash deduped), each with `hash`/`name`/`mime`/`size`/intrinsic `width`×`height` and the `usedBy` ids of the layers using it — the same derived library the editor's Assets panel shows. Pass a `hash` to `figpea.layer.placeProjectImage(hash)` to drop that image onto your **selected page or layer's page** (a selected layer resolves to its own page; selection wins even over whatever page is under the viewport centre), or onto the **project root** at the viewport centre when nothing is selected — as a cover-filled rect at its intrinsic size, positioned at the viewport centre when that point lies on the target page (or otherwise the centre of the page's on-screen region, or the page's own centre if off-screen), the camera never moves, selected, as **one** undo step — and reusing the SAME underlying image rather than decoding a second copy, so the distinct-image count is unchanged and only that image's `usedBy` grows. Prefer this over re-fetching bytes you already have in the file. Scope to know: the library only ever lists images that are **in use** (there is no unplaced-asset state, so there is nothing to "delete" either), it walks component masters as well as pages, and it does **not** list PSD raster layers/masks (those decode to `ImageData`, not an image blob) — an empty result means "no blob-backed image fills found", never "this file has no images". To bring in bytes that aren't in the project yet, use `figpea.layer.setImageFill(id, {bytes|url})` or `figpea.layer.create("image", …)`; the panel's local `Add image…` disk picker has no agent surface, because a native file chooser can't be driven from in-page script.
- **Masks**: `figpea.layer.setMask(id, "alpha" | "vector")` declares a layer to be a mask and says how it masks — `alpha` carves the covered content with the mask's own alpha (so a semi-transparent, gradient or image-filled mask gives a soft result), `vector` clips it to the mask's geometry (hard-edged; the fill is irrelevant). The type is **declared, never guessed from the shape or fill**, so the same rectangle behaves differently only because you said so. `figpea.layer.clearMask(id)` un-declares it — cleared means unset, not `"none"`. A mask covers the siblings in front of it up to the next mask or its parent's boundary, and never masks itself. **Read-back** on `layerById`/`layerTree`: the mask layer reports `mask: { type }`, and each layer it covers reports `maskedBy` with the mask's id — both omit-when-unset. Keep these distinct from `figpea.layer.clip(id)` / `figpea.layer.unclip(id)`, which move a layer into or out of another layer's clip slot: those are about *where* a mask lives, `setMask`/`clearMask` are about *what kind* it is. Note the id trap on the older pair — `clip(id)` takes the layer becoming the mask, but `unclip(id)` takes the **host** that owns the slot; `setMask`/`clearMask` always take the mask layer's own id.
- **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.
- **Components & instances**: `figpea.component.create(layerId)` converts a
  plain layer to a component and registers it — one undo step (a single
  `figpea.history.undo()` fully reverses the conversion; measured — REQ-322).
  `figpea.component.placeInstance(componentId)`
  adds an instance to the component's own page, offset so they don't overlap —
  also not undoable. `figpea.component.addState(componentId, input)` creates a
  new state and makes it active — repo-level, not undoable. For state switching,
  use `figpea.component.setActiveState(componentId, input)` (one undo step only
  when switching between two named states, not to/from Base; see §7's undo
  etiquette for details). `figpea.component.getStates(componentId)` reads back
  all states on a component. Six more methods for per-instance overrides:
  `getChildOverrides`/`getOverrides` (read divergence), `resetOverride`/
  `resetAllOverrides` (clear overrides), `removeChild`/`restoreChild` (detach
  or re-instance a child on one instance, leaving every sibling instance and
  the master untouched). All route through the shared facade. No entitlement
  gating — all are free model mutations.
- **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` / `rasterized_fallback` / `interaction_lost` (XD), `layer_decode_failed` (PSD), `unsupported_node` / `layer_decode_failed` (.fig). The code set
  grows per format over time as more decoders' signals get wired; **its breadth
  reflects wiring order, not parser fidelity.** **More diagnostics on a format means more of its degradation paths are wired to report, not that it renders worse.** PDF stays unwired for a channel reason, not a fidelity one: the PDF decoder emits no decode warnings at all. .fig coverage is partial — unsupported node types and per-node style/convert failures report, other figma degradation paths are still silent, so an empty result is less informative on .fig than on SVG, XD or PSD. 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).

**Stroke-only shapes** are authored with `fillType: "none"` + `strokeEnabled` — this is the style combination that renders an outline without a fill, useful for sketch-style icons and wireframe marks.

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.
- A text layer's `transform` `e`/`f` is the **top-left of the text box, not the baseline** — a critical distinction that affects layout calculations. Measure, don't compute: set the text, read `bounds` from `figpea.session.layerById(id)`, and centre from that; never derive a text layer's extent from `fontSize`. (Observed once, for calibration only, not contract: a 4-glyph CJK run measures ~56 px at 14 px font-size; line height ≈ 1.14 × `fontSize`, font-metric-dependent.)

- **Fonts & substitution**: `fontFamily` accepts any family string and the layer renders — **but** if the editor has not registered that family, the glyphs are a **browser fallback face, not the requested family**, and its shapes and metrics won't match intent. `figpea.report.fonts()` is the signal: `missingLocally: true` means substituted. Treat it as a fidelity warning — re-measure `bounds` and **tell the user the font was substituted** rather than assuming it applied.

## 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, properties })`
authors a single interaction. `trigger` is one of `tap` (user click), `time`
(timer-triggered), `keys-gamepad` (keyboard/gamepad key), or `hover` (pointer
enter/leave, one-shot per enter in Present mode). `action` is one of
`navigate` (an alias for `artboard-transition`), `artboard-transition`,
`auto-animate`, `overlay-transition`, `previous-artboard`, `scroll-transition`,
or `hyperlink` — each with its own `targetId` rule: `navigate`/
`artboard-transition`/`auto-animate`/`overlay-transition` require a page
`targetId`; `previous-artboard`/`hyperlink` forbid `targetId`;
`scroll-transition`'s `targetId` is optional and, when given, must be any
layer in the owner's own page. `hyperlink` also requires
`properties.hyperlinkUrl`. `state-transition` (transitioning a component
between its own states) is **not** authored here — use
`figpea.component.setActiveState`/`addState` instead. 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.
For `transition: "auto-animate"`: matched layers (paired by id, then by exact
name) tween between source and destination; unmatched layers cross-fade.

**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 }
});
// A hover-triggered auto-animate to the same page, tweening matched layers:
const hoverAnimate = await figpea.interaction.create(rect, {
  trigger: "hover",
  action: "auto-animate",
  targetId: pageB,
  properties: { transition: "auto-animate", easing: "wind-up", duration: 0.6 }
});
// 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
```

**Multi-screen flows via duplication:** To author repeating flows (e.g. a multi-screen prototype with one template page duplicated N times, each copy customized), use `figpea.layer.duplicate(pageId)` to clone a page with its entire subtree (fresh ids, structure preserved), inserted as an adjacent sibling. Iterate over the duplicates with `figpea.layer.setText()` and other mutations to customize each copy. When wiring interactions, note that cloned interactions carry fresh ids (distinct per copy) but still point at the **original targets** — re-point them to the successor pages using `figpea.interaction.remove(interactionId)` followed by `figpea.interaction.create(layerId, { ... targetId: newPageId ... })` if each copy should navigate to its own successor.

## 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 (this includes `flipX`/`flipY`, fixed by
REQ-323 — previously the whole flip batch was silently dropped from history).
For `figpea.component.*`: `create` is also one
undo step (a single `figpea.history.undo()` fully reverses the conversion;
measured — REQ-322), but `placeInstance` and `addState` are repo-level layer
mutations that the app's history stack deliberately never captures — they are
NOT undoable via
`figpea.history.undo()`. `setActiveState` is one undo step for any state
transition — switching between two NAMED states, or to/from Base (REQ-323
fix — a switch to/from Base was previously NOT captured, a pre-existing
history-guard limitation; that guard is now change-based rather than
truthy-based, so the limitation no longer applies). `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.component.*`, `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.

**Export shadow & transparency behavior**: `export.layer` crops tightly to layer bounds and omits outer drop shadows and background transparency padding outside the layer bounds. When you need full-canvas RGBA transparency and soft drop shadows intact, use `export.artboard` on an unpainted artboard (`fillType: "none"`).

## 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, rwidth: 160, rheight: 90,
    style: { fill: "#4f46e5", cornerRadius: 8 },
  }), "create(rect)");
  must(await figpea.layer.setPosition(rect.id, { x: 40, y: 40 }), "setPosition(rect)");
  const label = must(await figpea.layer.create("text", {
    parentId: page.id, transform: [1, 0, 0, 1, 40, 160], text: "Made by an agent",
  }), "create(text)");

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

  // 4. 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=1.6.0 sha256=13ec7d211f8abb1625015b02889e3dd37cc42d0f16231b7a7bb7c86eaa904f2b -->
