MCP/API Issue list

Hiya…

…I know, I know, I know it’s Claude bug reporting but as it’s real-world experience with Penpot / MCP it seems it should be of value. If it’s not, then LMK.

# Penpot Plugin API — Discovered Issues

Compiled from production use building a multi-file design system with a real-world component library (VL Design System → VL module files). All issues were encountered via the MCP execute_code interface, which runs code in the plugin executor context (penpotUtils / penpot globals).

Issues are grouped by severity. Each entry includes reproduction steps, observed behaviour, expected behaviour, and the workaround used.

## Critical — Data Loss / Corruption

### 1. Large execute_code calls silently roll back with no error

Severity: Critical

Area: Plugin executor / persistence

Description:

When a single plugin call creates many shapes (≥ ~25), the call returns successfully, shapes are findable within the same call, but nothing persists to the next call. No error is thrown; the promise resolves normally. This is indistinguishable from success at the call boundary.

Reproduction:

1. Open a Penpot file that has had a freeze/disconnect event in its history (or has been exported and re-imported).

2. Write a single execute_code call that creates ~25+ boards and text shapes, appends them hierarchically, and logs their IDs.

3. In a subsequent call, read penpot.currentPage.root.children — the shapes are absent.

Expected: Either the call commits atomically, or it throws an error.

Observed: Silent rollback. Logs show IDs. Next call shows zero new children.

Workaround:

- Keep each call to ≤ ~8 new shapes.

- Create the parent board in its own tiny call, capture its .id.

- Add children in small batches, re-finding the parent by .id each call via root.children.

- Always verify persistence by reading parent.children.length in the next call before continuing.

### 2. layoutCell.columnSpan corrupts grid layout — auto-expands column count and scrambles cell positions

Severity: Critical

Area: Grid layout API

Description:

After building a grid with addGridLayout(), addColumn(), addRow(), and appendChild(child, row, col), setting layoutCell.columnSpan on any cell causes Penpot to silently expand the column count (e.g. 7 → 9) and scramble all cell column assignments. Attempting cleanup via removeColumn() or re-assigning layoutCell properties triggers a freeze/crash on the page.

Reproduction:

1. Create a board, call board.addGridLayout().

2. Add N columns, add rows, place children at (row, col) positions.

3. Set someChild.layoutChild.columnSpan = 2 (or any span > 1).

4. Observe column count in Penpot UI — it changes from N to N+2 or similar.

5. All other cells shift to wrong columns.

Expected: columnSpan spans the cell across multiple columns without affecting other cells or column count.

Observed: Column corruption + freeze on attempted recovery.

Workaround:

Do not use columnSpan. For full-width rows (e.g. row highlight backgrounds), use a separate background layer positioned behind the grid board (absolute coordinates), rather than a spanning grid cell. Use grid.setParentIndex(n) (not bringToFront()) to ensure the grid renders in front of background layers.

### 3. switchVariant() on instances inside cloned trees freezes the plugin bridge

Severity: Critical

Area: Component instance API

Description:

Calling instance.switchVariant(propertyName, value) on component instances that exist inside a cloned board (i.e. a board that was itself produced by cloning another board containing instances) hangs the plugin bridge indefinitely. The execute_code call does not resolve or reject — it times out after ~30s, then the entire bridge freezes requiring a manual browser tab reload. Plugin storage is wiped on disconnect.

Reproduction:

1. Have a component with variants (e.g. nav-panel-item with active/inactive state).

2. Build an app shell board and clone it (.clone()).

3. Walk the clone’s children, find an instance, call instance.switchVariant('state', 'active').

4. The call hangs; browser tab becomes unresponsive.

Expected: switchVariant updates the variant on the instance.

Observed: Bridge freeze, tab reload required, storage wiped.

Workaround:

Avoid switchVariant on instances in bulk or cloned trees. Instead: set the shape’s fills directly to the selected-state colour, or delete the instance and re-create it using the desired variant’s component.

## High — Incorrect Behaviour

### 4. penpot.currentPage.findShapes(predicate) always returns an empty array

Severity: High

Area: Shape querying API

Description:

penpot.currentPage.findShapes(pred) returns [] regardless of predicate, even when many shapes exist on the page and are visible in the Penpot UI. The shapes have not been removed — they are present and accessible via root.children.

Reproduction:

1. Create several boards on the current page.

2. Call penpot.currentPage.findShapes(s => true).

3. Observe: returns [].

4. Call penpot.currentPage.root.children — shapes are present.

Expected: Returns all shapes matching the predicate.

Observed: Always [].

Workaround:

Use penpot.currentPage.root.children for top-level shapes. For nested lookups, walk .children recursively. Store shape .id values in penpot.storage to re-find them across calls.

Note: penpotUtils.findShapes(pred, root) (the utility variant) does work, but includes the root shape itself if it matches the predicate — scope predicates with s !== root to avoid accidentally mutating the container.

### 5. board.width and board.height are getter-only — assignment silently fails or throws

Severity: High

Area: Shape geometry API

Description:

Assigning board.width = 300 throws "setting getter-only property 'width'" (or silently fails depending on strict mode). This applies to all board-like shapes (frames, components). The property appears writable in documentation/autocomplete but is not.

Reproduction:

1. const b = penpot.createBoard(); b.width = 300;

2. Observe: TypeError or silent no-op.

Expected: board.width = n sets the width.

Observed: Throws or no-ops.

Workaround:

Use board.resize(width, height) for all dimension changes. Critical ordering constraint: call resize() BEFORE addFlexLayout(). Once flex layout is added, calling resize() on the board again makes width/height getter-only even via resize() (the flex container takes over sizing). Set dimensions first, then add layout.

### 6. text.fontColor assignment throws “Object is not extensible”

Severity: High

Area: Text styling API

Description:

Attempting to set text.fontColor = '#rrggbb' throws "Object is not extensible". The property does not exist on the text shape; the object is sealed.

Reproduction:

1. const t = penpot.createText('hello'); t.fontColor = '#677081';

2. Observe: TypeError.

Expected: Sets the text colour.

Observed: Throws.

Workaround:

Use the fills array: t.fills = [{ fillType: 'solid', fillColor: '#677081', fillOpacity: 1 }].

### 7. text.characters = "" throws a validation error

Severity: High

Area: Text content API

Description:

Setting text.characters = "" (empty string) throws "Value not valid: . Code: :characters". Empty string is not accepted as a valid characters value.

Reproduction:

1. const t = penpot.createText('hello'); t.characters = '';

2. Observe: throws validation error.

Expected: Clears the text content.

Observed: Throws.

Workaround:

Use text.hidden = true to hide the text instead of clearing it. If the text must be empty, delete the shape entirely.

### 8. Library component instances cannot be created programmatically

Severity: High

Area: Component / Library API

Description:

There is no plugin API method to create a linked component instance from a library. penpot.library.connected[i].components[j].mainInstance() returns the main component shape, and calling .clone() on it returns a shape with type: null, name: null. Appending this null-typed shape via parentBoard.appendChild(nullShape) silently drops it — nothing is added, no error thrown.

No penpot.createInstance(), component.instantiate(), or equivalent exists.

Reproduction:

1. Have a connected library with components.

2. const comp = penpot.library.connected[0].components[0]; const inst = comp.mainInstance().clone();

3. someBoard.appendChild(inst);

4. Observe: someBoard.children.length is unchanged.

Expected: A linked instance of the component is added to the board.

Observed: clone() returns a null shape; appendChild silently ignores it.

Impact:

Patterns built via the plugin cannot use library component instances. They must be built from raw shapes, losing the component-linking benefit. The only way to place a real linked instance is to drag it from the Assets panel in the Penpot UI.

Suggested API addition: penpot.createInstance(component) → returns a live, linked instance shape.

### 9. Spacing tokens cannot be applied to flex padding via applyToken

Severity: High

Area: Token binding API

Description:

shape.applyToken(spacingToken, ['paddingLeft']) (and all other padding properties) throws "Value not valid: Field message is invalid". Including any padding property in a multi-property array fails the entire call.

Gap properties (columnGap, rowGap) work correctly.

Reproduction:

1. Have a spacing token in the local library.

2. board.applyToken(tok, ['paddingLeft']) → throws.

3. board.applyToken(tok, ['columnGap']) → works.

Expected: Token is bound to the padding property, analogous to gap binding.

Observed: Throws for all padding properties.

Workaround:

Set padding as raw numeric values: board.paddingLeft = 8. Can be manually token-bound in the Penpot UI via right-click → Assign Token.

## Medium — Unexpected Behaviour

### 10. penpot.openPage() requires its own call — page switch is not synchronous

Severity: Medium

Area: Page navigation API

Description:

After calling penpot.openPage(page), reading penpot.currentPage.name in the same call still returns the old page name. The switch takes a tick to propagate. Any shape edits in the same call as openPage target the old page.

Reproduction:

1. penpot.openPage(otherPage); console.log(penpot.currentPage.name); — logs old page name.

2. Run openPage in call 1, read currentPage.name in call 2 — logs new page name.

Expected: openPage is synchronous, or returns a Promise that resolves after the switch.

Observed: Switch is deferred; same-call reads still see old page.

Workaround:

Always call openPage alone in its own execute_code call, then edit in the next call.

Additional note: Edits to shapes on a non-active page silently revert. Always verify currentPage before editing.

### 11. page.name not set by penpot.createPage(name) argument

Severity: Medium

Area: Page management API

Description:

penpot.createPage('v2 - Project Data') creates a page but the name comes out as "Page 2", "Page 3" etc. — the argument is ignored.

Reproduction:
[HeyTC: Have the Tokens panel open when new page is being called.]
1. const p = penpot.createPage('My Page Name'); console.log(p.name); → logs "Page 2".

Expected: Page is created with the given name.

Observed: Name argument is ignored; page gets a generic sequential name.

Workaround:

Set the name separately after creation: p.name = 'My Page Name'; — this does work.

### 12. layoutChild properties are null when accessing from a subsequent call

Severity: Medium

Area: Flex layout / child sizing API

Description:

child.layoutChild.horizontalSizing = 'fill' only works when the parent board was created in the same execute_code call. If the parent was created in a previous call and is re-found via root.children, then appending a new child and accessing child.layoutChild returns null.

Reproduction:

1. Call 1: create parent board with flex layout, capture its .id.

2. Call 2: find parent via root.children.find(c => c.id === id), create a new child board, parentBoard.appendChild(child).

3. child.layoutChild.horizontalSizing = 'fill' → TypeError (null access).

Expected: layoutChild is accessible on any child of a flex container, regardless of when the parent was created.

Observed: layoutChild is null for children added to parents created in previous calls.

Workaround:

Build entire parent + children trees within a single call. For shapes that must be added to existing flex containers across calls, use hardcoded resize(w, h) values instead of fill sizing.

### 13. Inter font only supports weights 200, 300, 400, 700, 900 in Penpot

Severity: Medium

Area: Text styling API

Description:

Setting text.fontWeight = 500 or 600 throws "Value not valid: Font weight '500' not supported for the current font.". This is not documented; discovery is only by trial and error.

Reproduction:

1. const t = penpot.createText('test'); t.fontWeight = 500; → throws.

2. t.fontWeight = 400; → works.

Expected: All standard CSS font weights accepted (or a clear error/enum at the API level).

Observed: Runtime throw for unsupported weights; only 200/300/400/700/900 are valid for Inter.

Workaround:

Use 400 (regular) and 700 (bold) as the safe pair. Expose the valid weight set in API docs or as a typed enum.

### 14. Theme switching via plugin freezes and rolls back

Severity: Medium

Area: Token theme API

Description:

Toggling token sets (TokenSet.toggleActive()) to change the active module/mode triggers heavy propagation that freezes the plugin bridge and then rolls back — the active set reverts to its previous state. Even mid-toggle, already-bound shapes do not recolour (fills[0].fillColor stays the old resolved value).

Reproduction:

1. Have multiple token sets active (e.g. v2/Theme/Light + v2/Module/DataQuality).

2. Call penpot.library.local.tokens.sets.find(s => s.name === 'v2/Module/EnvFootprint').toggleActive().

3. Bridge freezes for ~20–30s, then reverts.

Expected: Active set changes; bound shapes recolour to the new resolved values.

Observed: Freeze + rollback. No recolouring occurs.

Workaround:

Theme switching must be done in the Penpot UI (Tokens → Themes panel). Components are correctly token-bound; only the render won’t follow a plugin toggle.

### 15. penpotUtils.findShapes(pred, root) includes the root shape if it matches

Severity: Low / Footgun

Area: Shape querying utility

Description:

penpotUtils.findShapes(pred, root) walks the entire subtree rooted at root, and includes root itself in the results if pred(root) is true. When used to find all filled descendants (e.g. to apply a fill token to all children), this inadvertently mutates the container board itself.

Example: Applying color.accent.contrast to all filled paths inside a tile board accidentally overwrites the tile board’s own accent fill with white.

Workaround:

Always exclude the root: penpotUtils.findShapes(s => s !== root && yourPredicate(s), root).

### 16. bringToFront() does not reliably reorder shapes in grid/flex containers

Severity: Low

Area: Z-order API

Description:

In some layouts (observed with grid layout boards with background stripe layers), shape.bringToFront() does not change the render order. The shape remains visually behind others.

Workaround:

Use shape.setParentIndex(n) with a high index value (higher = rendered in front). This reliably controls z-order.

## API Gaps (Feature Requests)

### A. No createInstance(component) API

As noted in issue #8, there is no way to place a linked component instance programmatically. This is the single most impactful gap for plugin-based pattern building. A penpot.createInstance(component) method that returns a live, linked instance shape would unlock the full FCP (Frame-Component-Pattern) workflow via plugins.

### B. applyToken does not support padding properties

As noted in issue #9. Padding token binding requires manual UI interaction. Parity with gap properties (columnGap, rowGap) would complete the spacing token binding surface.

### C. addFlexLayout() / addGridLayout() should accept an initial size

Because resize() cannot be called after addFlexLayout(), callers must remember to size first. An optional parameter board.addFlexLayout({ width: 300, height: 200 }) would prevent the common ordering mistake.

### D. fontWeight should accept a typed enum or expose valid values

Inter’s valid weights in Penpot (200/300/400/700/900) differ from CSS standard (100–900). Exposing this as an enum (e.g. penpot.fontWeights.inter) or as a validation error at the property setter level (before the async throw) would reduce friction.

Discovered during VL Design System build — June 2026. All issues reproduced in the Penpot plugin executor (MCP execute_code interface) against a Penpot cloud instance.