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.