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.

Hi @HeyTC,

First, thank you for the detailed report. This is exactly the kind of real-world MCP/API feedback that helps us improve Penpot.

Sorry for the late reply. We reviewed the full list against the next release branch. Most of the issues are already fixed there, and your report helped us confirm a few bugs that we have now fixed.

Confirmed bugs

#9: Spacing tokens cannot be applied to padding via applyToken

Confirmed.

In the current build, this no longer throws, but the token binding is stored under the wrong key, so the token is not actually applied to paddingLeft.

Gap properties like columnGap and rowGap work correctly.

This will be fixed in the next release. Padding is already intended to be supported, so this was a bug rather than a missing feature.

#16: bringToFront() is unreliable in layout containers

Partially confirmed.

We reproduced the issue inside flex containers. bringToFront() was not reordering the child correctly there.

It worked on plain boards and, in our tests, also inside grid containers. The flex case is now fixed.

Your setParentIndex(n) workaround is the right approach until the fix is released.

resize() after adding a layout did not behave like the UI

We found this while investigating #5 and feature request C.

resize() can be called after addFlexLayout() or addGridLayout(), but it did not correctly switch auto-sized layouts to fixed sizing.

That meant:

  • Flex boards could snap back to hugged content.

  • Grid boards could apply the size once, but return to auto sizing on the next relayout.

This is now fixed. resize() now switches the affected axes to fixed sizing first, so the requested size sticks on both flex and grid boards.

While fixing this, we also found that GridLayout was missing its horizontalSizing and verticalSizing properties. That is fixed too.

Could not reproduce on the current development branch

We added tests for the cases below using the reproductions you described. They all pass on the current development branch.

A large batch of Plugin API fixes is queued for the next release, so some of these may already be resolved. If you can still reproduce any of them after the release, a small snippet would help us investigate.

#2: layoutCell.columnSpan corrupts the grid

We tested a 3×2 grid with children and set columnSpan = 2.

The column count stayed the same, and the other cells kept their positions.

#3: switchVariant() on instances inside cloned boards freezes

We cloned a board containing a variant instance and called switchVariant(0, value) on the cloned instance. It worked without hanging.

One detail to note: the signature is:

switchVariant(pos: number, value: string)

The first argument is the property position, not the property name. So switchVariant('state', 'active') passes an invalid first argument.

#8: Library component instances cannot be created programmatically

The API for this already exists:

component.instance()

For example:

penpot.library.connected[i].components[j].instance()

That returns a linked instance of the component and is the supported way to place component instances from a plugin.

We also could not reproduce the broken clone behavior. mainInstance().clone() returns a valid shape in our tests.

One thing that may explain what you saw: clone() already attaches the copy to the same parent as the original. If you then call appendChild on that same parent, children.length will not increase, so it can look like the shape was dropped even though it was already there.

#12: layoutChild is null for children added to parents from earlier calls

We tested this by re-finding a flex board with currentPage.getShapeById(id), appending a new child, and setting:

child.layoutChild.horizontalSizing = 'fill'

That worked correctly.

If this still happens after the next release, please share the exact sequence that triggers it.

#14: Theme switching via plugin freezes and rolls back

TokenSet.toggleActive() works and persists correctly in our tests when tokens from the set are bound to shapes.

That said, large themed files may behave differently. If you still see the freeze after the next release, the file would help us reproduce it. If sharing the file is not possible, an approximate size would also help: number of token sets, tokens, and bound shapes.

Working as designed

These match the current typed API in @penpot/plugin-types, but some of them clearly need better docs or clearer errors.

#4: currentPage.findShapes() always returns []

findShapes takes a criteria object, not a predicate function.

Use:

findShapes({ name: 'exampleName' })

Calling it with no arguments returns all shapes.

A predicate like s => true belongs to the MCP utility penpotUtils.findShapes(pred, root), which is a different function.

#5: board.width and board.height are getter-only

This is intentional. width and height are readonly.

Use resize(w, h) to change dimensions.

However, your note about resize() not working after adding a layout helped us find a real bug. See the confirmed resize() fix above.

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

This is intentional. A text shape must always have content, so an empty string is rejected.

The supported options are:

  • Hide the shape with hidden = true

  • Remove the shape

We agree the error message should be clearer.

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

There is no fontColor property in the Plugin API.

The plugin sandbox seals API objects, so writing to an unknown property throws. Using fills, as you did, is the supported way to set text color.

#10: openPage() is not synchronous

You are right. In the version you tested, there was no way to wait for the page switch.

This is fixed in the next release. openPage(page) now returns a Promise<void>, so you can do:

await penpot.openPage(page)

After that, you can safely mutate shapes on the new page in the same execute_code call.

Until then, your one-call-per-openPage workaround is the right approach.

Also related: in the next release, edits to shapes on a non-active page will raise a validation error instead of silently reverting.

#11: createPage(name) ignores the name

createPage() currently takes no arguments, so the name argument is ignored.

The intended flow is:

const page = penpot.createPage();
page.name = 'My Page Name';

Accepting an optional name is a reasonable improvement, and we have noted it.

#13: Inter only supports weights 200/300/400/700/900

Valid weights depend on the font. They come from the variants the font provides, not from the full CSS weight range.

You can inspect the available variants through penpot.fonts.

We also improved the error message for this. See feature request D below.

MCP tooling

#1: Large execute_code calls silently roll back

We found issues in the MCP connection when the browser froze the tab. It could fail silently or leave the agent in an infinite loop.

Both cases are fixed for the next release.

#15: penpotUtils.findShapes(pred, root) includes the root

We changed this behavior.

The root shape is no longer included in the returned list.

Feature requests

A. createInstance(component)

This already exists as component.instance(). See #8 above.

B. applyToken padding support

Padding is already part of the intended API surface. The current issue is a binding bug, and it will be fixed. See #9.

C. Initial size for addFlexLayout() / addGridLayout()

We found that the real issue was resize() not switching auto-sized layouts to fixed sizing.

That is now fixed, along with the missing grid sizing properties.

With those fixes, this flow works as expected:

addFlexLayout() / addGridLayout()
resize(width, height)

So we fixed the underlying bugs instead of adding a size parameter.

D. Expose valid font weights

Done.

The validation error now lists the supported weights for the current font.

Example:

Font weight '500' not supported for the current font. Supported weights: 200, 300, 400, 700, 900

This will ship with the next release.

The full variant data is also available through penpot.fonts.

Thanks again for taking the time to write this up. Your report helped us confirm Plugin API bugs, fix MCP reliability issues, and identify a few places where the docs and error messages need to be clearer.

1 Like

Hi @alotor!

I’ve been using the MCP through both Claude Code and Codex and I’ve reported few of the bugs listed here myself, too.

You wrote that few of those have been fixed already when compared with the next release branch. Two questions from me:

  1. Is this a release branch for self-hosted or for cloud? I don’t know if both share the same codebase.

  2. When is the next cloud update due?

Awesome & thank you. I’ll wait until the next release to get Claude to re-run the battery of tests.

I have realised that Claude can analyse error reports post-crash to pinpoint the steps to reproduce crash errors and come up with work-arounds (it does my head in how quickly it does this).

Here’s a crash-causing issue discovered using this technique:
Cloning a component’s main instance leaves it internally tracked as a “copy,” which crashes the server on the next unrelated persist.

.clone() on a mainInstance() looks like a clean independent duplicate — commits fine, renders fine, survives several unrelated edits — but internally keeps a :shape-ref back to the original component. That makes it an orphaned “copy” shape, which Penpot’s backend only permits nested inside another component’s structure. The plugin API never rejects this at clone or append time; the crash only fires on the next persist that happens to touch the shape, which is why it took 3 reproductions to trace back to the real cause instead of whatever edit triggered it.

report.txt is the actual server crash trace confirming this (:code :nested-copy-not-allowed, shape named "input-error", Penpot 2.16.2-RC1) — it matches exactly the crash I hit and fixed on 2026-07-03 while building controls / input’s error/focus/disabled variants.

Fix: always .detach() immediately after cloning a main instance, before any other edit.

@radziszcze We’re releasing this week, unless there is a last minute blocker. Usually we release cloud first and when everything is working fine (there is always some surprises when deploying) we generate the images for self-hosted.

@HeyTC Thanks for the details, I’ll look into it

1 Like

@alotor will the release be announced? I’m asking, because I’ve reported a few bugs and I’m interested in testing them right after the release gets shipped to production.

@HeyTC I’ve investigated the report and we could find the issue. The plugins API could change the structure of component instances and this is something that the model forbids. We’ll add the change in the release.

@radziszcze yes, of course

2 Likes

Wow - Thank you!

# Penpot 2.17 Retest Report - Tracked Plugin-API Issues
Retested 2026-07-10 against a fresh test project/library (Test Design File / Test Design System), 15 isolated repro calls, each verified on a fresh execute_code call (not just same-call output, per this project’s established discipline).

## Fixed (9)

Title Description Status
Fills/strokes array elements read-only shape.fills\[0\].fillColor = ... direct mutation now persists FIXED - matches CHANGES.md #8357
openPage() not synchronous await penpot.openPage(page) now switches currentPage within the same call FIXED - #10195
applyToken fails on padding applyToShapes(shapes, \['paddingLeft'\]) no longer throws, binds and resolves correctly FIXED - #10077
Token application when target set is inactive No longer throws; binds correctly, resolves once the set is activated FIXED - #10070/#10071
TokenSet.active toggle doesn’t refresh bound shapes Activating a set now retroactively updates already-bound shapes’ resolved values with no re-apply needed FIXED - closes a long-standing gotcha
Numeric token values rejected addToken({value: 24}) (raw number) no longer throws FIXED - #10073
Board.combineAsVariants(ids) fails 4/4 Retested the exact failing pattern - now builds a real VariantContainer correctly FIXED
Appending a child to a still-linked instance silently orphans it Now throws immediately ("Cannot change the structure of a component copy") instead of silently corrupting state and crashing later FIXED (fails loud, not silent)
Editing a descendant’s property of a still-linked instance no-ops .characters writes on a nested text now persist without detaching first FIXED - closes the most-frequently-hit gotcha in the project
Large-batch applyToShapes (30+ shapes) silently no-ops 40/40 shapes bound correctly in one call FIXED
Cross-page/non-active-page mutation fails silently Now throws a clear "Cannot modify a page that is not currently active" error instead of silent no-op IMPROVED - active-page rule still applies, but now fails loud
Clone of a component main instance crashes later, unless detached Simple edits now persist safely undetached; structural edits now fail immediately instead of corrupting the file on a later unrelated persist IMPROVED