# Deskhand — Operation Skill

How to work through Deskhand effectively and honestly. Read this
before any write-heavy design session. Applies to bridge **v0.4.0+**.

## Verification discipline — the core of this skill

**1. Verify bindings by readback, not by assumption.**
`figma_get_nodes` (full depth) returns `boundVariables` (node-level variable
bindings), `explicitVariableModes` (pinned modes), `layoutWrap`,
`counterAxisSpacing`, and `clipsContent`. After binding, pinning, or layout
work, read the node back and confirm the field says what you intended. Mutating
tools echo readbacks in their responses (`verified: true`, resulting
`explicitVariableModes`, per-side stroke weights, etc.) — check them.
Default-valued props are omitted from node reads; use
`figma_get_nodes({ nodeIds, verbose: true })` as the verification read when a
property might legitimately be at its default.

**2. Export the render and look at it before reporting done.**
`figma_export_node` writes the image to disk and returns the path — Read the
file and actually look at it. Property readback cannot see composition: real
sessions passed every numeric check while renders showed a portrait video well
where a 16:9 one belonged, a heading breaking mid-word, and a button hugging at
140px instead of spanning its form. Looking catches what measuring cannot.

**3. Report honestly.**
- If a tool errors, report the failure — never record the work as done.
- If a readback disagrees with a success response, report that too.
- Never paper over a missing capability by hardcoding a value.
- The bridge errors instead of silently no-oping (see error codes below);
  treat those errors as information about Figma's real constraints, not as
  obstacles to retry around.

## Errors that mean "Figma forbids this" (don't retry — change approach)

- `INSTANCE_SUBLAYER_RESTRICTED` — size binds, resizes, and reorders inside an
  instance are not allowed. Make the change on the component master; it flows
  to every instance.
- `MODE_NOT_FOUND` / `COLLECTION_NOT_FOUND` — the modeId/collectionId is wrong;
  the error lists valid modes.
- `WRONG_EDITOR` / `FIGMA_DESIGN_ONLY` — the tool is gated to FigJam or Figma
  Design; the error names the current editor type (there are five: figma,
  figjam, dev, slides, buzz).
- `BIND_NOT_APPLIED` / `STYLE_NOT_APPLIED` / `RESIZE_NO_OP` /
  `REORDER_FAILED` / `LIMIT_NOT_APPLIED` — the write did not land and the
  bridge is telling you instead of pretending. Report it.

## Tool guidance

- **Sizing a child to its parent:** use `figma_set_layout_align: STRETCH`, not
  `figma_resize_nodes`. STRETCH preserves width/height variable binds; resize
  may destroy them (the bridge re-applies and warns, but STRETCH avoids the
  problem entirely).
- **Pinning variable modes:** `figma_set_variable_mode` sets or clears
  (`clear: true`) an explicit mode per collection on nodes *and pages*. Pins
  belong on preview/page frames. Never pin a component master — every instance
  inherits it, per-collection, and the partial correctness hides the fault.
- **Text styles:** create with `figma_create_text_style`, bind `fontSize` (and
  other text fields) to variables via `figma_set_variable` with `styleId`,
  apply with `figma_apply_style`, delete with `figma_delete_style`.
- **Borders on one side:** `figma_set_strokes` per-side weights
  (`strokeTopWeight` etc.); `strokes` may be omitted to change weights only.
  A mixed weight reads back as the string `'MIXED'` plus the four per-side
  values.
- **Min/max sizes:** `figma_set_size_limits`; pass explicit `null` to clear.
  `figma_unbind_variable` on a min/max field also clears the residual literal.
- **Reordering:** `figma_reorder_node` `position` is the final index among
  siblings (0 = back, `childCount-1` = front), verified by readback.
- **Hiding:** `figma_set_visible` — don't fake it with opacity 0.
- **Exports:** the returned `path` is the deliverable; Read it. No need to
  inflate `scale` to force anything.
- **Constraints:** set `figma_set_constraints` BEFORE converting the parent to
  auto-layout; inside auto-layout parents it's rejected, and it can't be
  overridden on instance sublayers at all.
- **Resizing:** `figma_resize_nodes` captures width/height/min/max variable
  binds and re-applies them after the resize; recovered binds are listed in
  `rebound`, lost ones in `warnings`. Instance sublayers are refused up front.
  Each node echoes `requested` and `actual`.
- **Size limits:** `null` is the documented way to clear a min/max, so
  max-width is not a one-way door. A variable-bound field keeps the bound
  value over the literal until you `figma_unbind_variable` it.
- **Variable binds on styles:** pass `styleId` (or a style id as `nodeId`)
  with `field`; the response echoes the style's `boundVariables`.
- **Reordering:** `position` is the final index among siblings; Figma sorts
  back-to-front, so 0 is the bottom. Out-of-range values clamp and say so.
- **Rotation:** the centre pivot writes `relativeTransform`; auto-layout
  parents ignore its translation, so non-absolute children get a top-left
  pivot plus a warning. Wrap the node in a plain frame or set
  `layoutPositioning: ABSOLUTE` for a true centre pivot.
- **Exports:** `figma_export_node` is file-first because inline base64 cannot
  be viewed; `figma_screenshot` is the tool that puts pixels in context, and
  it halves the scale once to stay under 200 KB inline.
- **Prototype reactions:** `figma_add_reaction` keeps existing reactions;
  NODE actions need a `navigation` value (NAVIGATE, SWAP, OVERLAY,
  SCROLL_TO, CHANGE_TO).

## Auto-layout facts that bite

- `SPACE_BETWEEN` is inert when any child has `layoutGrow: 1` (no free space
  to distribute) — `itemSpacing` is ignored then too.
- `figma_set_layout_align: CENTER` is a no-op on a stretched child — use
  `INHERIT` plus the parent's `counterAxisAlignItems: CENTER`.
- Reading wrap: `layoutWrap` and `counterAxisSpacing` come back in full node
  reads; compact children include x/y for geometry checks.
- `figma_set_text` does not decode HTML entities — send literal characters
  (`&`, not `&amp;`).

## Concurrency (multi-agent sessions)

- One WebSocket to one open document. More than ~2 write-heavy agents produces
  transient `Unable to establish connection` errors — retry them; if an agent
  stalls completely, kill and restart it.
- **Never call `figma_set_current_page` or `figma_set_selection` from a
  sub-agent** — it yanks the shared view out from under every other client.
- The plugin must be connected to THIS server's port: check
  `figma_get_context` once at the start and surface the port to the user if
  disconnected. Do not re-check before each step: every tool returns
  `NOT_CONNECTED` when the plugin is down, and script results carry the
  current `page`.

## Scripts: `figma_execute`

- Start from the cookbook: `figma_get_design_rules({ section })` for one
  topic (helpers, auto-layout, components, variants, text, lint, jobs,
  reference-match) or the whole `skill://deskhand/COOKBOOK.md`. Every
  recipe there runs in the integration suite, so copy them rather than
  improvising.
- The code is the body of an async function: `figma` and `bridge` are in
  scope, `await` works, `return` plain data. Never return a node — return
  `{ id: node.id }`; the tool refuses nodes with `EXECUTE_BAD_RETURN`.
- Use `bridge.log(...)` instead of `console.log`; logs come back in the
  response as `logs`.
- Load fonts before touching text: `await figma.loadFontAsync(node.fontName)`.
- Anything over ~20 s: pass `job: true`, then call `figma_job_status`. It
  waits up to 20 s for the job to end (`waitMs`, 0 to return at once), so call
  it again only while `state` is `running`. Report progress with `bridge.progress('message', 0.5)`
  inside the script. Check `bridge.cancelled()` inside long loops so
  `figma_job_cancel` can stop you; a script that never checks runs to the
  end. One job at a time.
- A sync script that outlives `timeoutMs` keeps running and the reply is
  `{ jobId, promoted: true }`: wait on it with `figma_job_status`, never run
  the script again.
- After a script mutates the document, look at it in the same call: pass
  `screenshot: true` (renders the `id` the script returns) or
  `screenshot: '<nodeId>'`. The image comes back with the result. Property
  readback cannot see composition; look at the picture.
- `EXECUTE_SYNTAX` means the code did not compile; `EXECUTE_FAILED` carries
  the thrown message plus approximate line/column and the first stack lines.
- `bridge.image(pathOrUrl, { node })` and `bridge.snapshot(nodeId, label)` are available inside scripts; see the Images and Snapshots sections.

## Looking at renders

- `figma_screenshot` puts the render in your context as an image. After a
  script, prefer `screenshot` on that `figma_execute`; use `figma_screenshot`
  after any other layout change; it is the only way to see overlap, clipping and
  text wrapping. Keep the default `maxPixels`; raise it only to inspect
  detail, and lower it for big boards.
- Reproducing a reference? Place the reference image on the canvas, build
  next to it, then `figma_compare_to_reference`. Read the quadrant that
  carries the mismatch and fix that region; do not rebuild the whole
  screen. Under about 8% with imagery excluded is usually a match; the tool
  never decides for you.
- `figma_export_batch` with `targetWidth` is the way to produce App Store
  or marketing sets; scale is clamped to Figma's 0.01–4 range, so a 393 pt
  frame tops out at 1572 px wide.

## Icons and chrome

- Never hand-author an SVG icon or draw a status bar, home indicator or
  keyboard from rectangles. Resolve the concept first (`figma_resolve_icon`),
  then insert with `figma_place_icon` or `await bridge.icon(requested, ...)`.
  Key lookups by the name you requested; aliases resolve behind the scenes.
- An unknown concept returns `suggestions` (real Lucide names). Pick one; do
  not invent a name or a glyph.
- On a dark screen pass `theme: 'dark'` to the chrome helpers; the kit
  repaints its ink. Chrome scales to the parent frame's width, so build the
  screen frame first and pass it as `parent`.

## Quality gate and brand

- Run `figma_review_screen` on every screen you build. `pass: false` means
  not done: each finding names the node and the fix. Fix, review again.
  `figma_execute` with `lint: true` does the same for frames the script
  created.
- Before designing anything new, call `figma_brand_styles` once. The
  colours, type styles and components it returns are the brand; use them.
  Never ask the user to describe a brand the file already defines.
- Build from the file's components: `bridge.instance('Button', { Size: 'L' })`
  instead of redrawing a button. Unknown names return candidates.
- Create text with `bridge.text(...)`; it loads the font and substitutes the
  fallback when a family is missing, and tells you it did.

## Images

- Real imagery beats grey placeholders: `figma_set_image_fill` or
  `await bridge.image(path, { node })` puts a PNG/JPEG/GIF/WebP on any node
  with fills. Paths must be absolute; URLs must be http(s). Figma refuses
  images over 4096 px on a side — resize first, the tool will not.
- `scaleMode: 'FILL'` covers the node (default); use `'FIT'` for logos that
  must not crop. The lint's `PLACEHOLDER_AVATAR` finding points here.
- Inside a script, `bridge.image` gives a remote image 25 s to load
  (`timeoutMs` has no effect on this); use `job: true` for slow URLs —
  job-mode scripts get 2 minutes there instead.

## Snapshots

- Before a destructive script (restyle, re-layout, mass rename) take
  `figma_snapshot({ nodeId })` or `await bridge.snapshot(id, 'label')`.
  If the result is wrong, `figma_restore_snapshot({ snapshotId })` puts the
  copy back where the original was and returns a NEW id — update the ids you
  hold. A restore consumes the snapshot.
- Ten per file; the oldest is discarded. The clones live on the
  `🗄 Bridge Snapshots` page — never build there, and do not delete it by
  hand while a task is running.
