Penpot — Setup & Verification Guide

SkillMedia

How to set up and manipulate Penpot design tool state via HTTP APIs for CUA-Gym UI design tasks. For setup-gen and reward-gen agents.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Penpot — Setup & Verification Guide skill

What this skill tells your AI

The instructions your AI receives, as published by xlang-ai/cua-gym in .claude/skills/penpot/SKILL.md and read by ahel’s review.

This skill teaches setup-gen and reward-gen how to create, manipulate, and verify Penpot project state for UI design tasks. Like Overleaf, Penpot tasks involve HTTP state management through a shared Penpot instance — there are no local design files on the VM.

  • Libraries: requests, json, uuid, re, zipfile, io
  • Penpot instance: https://cua-gym-figma.xlang.ai

0. Architecture Overview

Register temp user  →  Login (session cookie)  →  Create project
       ↓                       ↓                       ↓
  No email verif       API returns JSON         Import template (.penpot ZIP)
                               ↓                       ↓
                        setup-gen configures     CUA agent operates in browser
                               ↓                       ↓
                        reward-gen reads          get-file / get-page
                        shape tree                     ↓
                               ↓               Score 0.0–1.0
                        delete-profile (cleanup)

Key difference from mock_websites: Penpot uses per-session user accounts (not session IDs). Each training episode gets its own user for isolation. The user is created at setup and deleted after reward verification.

Key difference from Overleaf: No CSRF tokens needed. No admin account required — users self-register. API uses JSON RPC, not REST. Must set Accept: application/json header or responses return Transit+JSON (Clojure serialization).


1. Authentication API

All Penpot API calls use session-cookie authentication. The base endpoint is /api/rpc/command/<method>.

1.1 API Call Pattern

import requests

PENPOT_URL = 'https://cua-gym-figma.xlang.ai'

session = requests.Session()
session.headers.update({
    'Content-Type': 'application/json',
    'Accept': 'application/json',  # CRITICAL: without this, responses are Transit+JSON
})

def rpc(session, method, payload=None):
    """Call Penpot RPC API."""
    resp = session.post(
        f'{PENPOT_URL}/api/rpc/command/{method}',
        json=payload or {},
    )
    return resp

1.2 Register User

Registration is a two-step process: prepare → confirm.

def register_user(session, email, password, fullname):
    """Register a new user. Returns profile dict."""
    # Step 1: Prepare
    resp = rpc(session, 'prepare-register-profile', {
        'email': email,
        'password': password,
        'fullname': fullname,
    })
    resp.raise_for_status()
    token = resp.json()['token']

    # Step 2: Complete
    resp = rpc(session, 'register-profile', {'token': token})
    resp.raise_for_status()
    return resp.json()  # {'id': '...', 'email': '...', 'fullname': '...'}

1.3 Login

def login(session, email, password):
    """Login and establish session cookie. Returns profile with team/project IDs."""
    resp = rpc(session, 'login-with-password', {
        'email': email,
        'password': password,
    })
    resp.raise_for_status()
    return resp.json()
    # Returns: {
    #   'id': '...',
    #   'defaultTeamId': '...',
    #   'defaultProjectId': '...',
    #   'email': '...',
    #   ...
    # }

1.4 Delete User (Self-Delete)

def delete_profile(session):
    """Delete the logged-in user and ALL their data."""
    resp = rpc(session, 'delete-profile', {})
    # Returns 204 No Content on success
    return resp.status_code in (200, 204)

2. User Lifecycle

Each training episode creates and destroys an isolated user.

import uuid

def create_session_credentials():
    """Generate unique session email and password."""
    sid = uuid.uuid4().hex[:12]
    email = f'session-{sid}@cua-gym.local'
    password = f'cua-{uuid.uuid4().hex[:16]}'
    return email, password, sid

def provision_session(penpot_url):
    """Full flow: register → login → return session with team info."""
    email, password, sid = create_session_credentials()

    session = requests.Session()
    session.headers.update({
        'Content-Type': 'application/json',
        'Accept': 'application/json',
    })

    register_user(session, email, password, 'CUA Agent')
    profile = login(session, email, password)

    return session, {
        'email': email,
        'password': password,
        'session_id': sid,
        'team_id': profile['defaultTeamId'],
        'default_project_id': profile['defaultProjectId'],
    }

3. Project & File API

3.1 Create Project

def create_project(session, team_id, name):
    """Create a project. Returns project dict with 'id'."""
    resp = rpc(session, 'create-project', {
        'teamId': team_id,
        'name': name,
    })
    resp.raise_for_status()
    return resp.json()

3.2 Create Empty File

def create_file(session, project_id, name):
    """Create an empty design file. Returns file dict with 'id'.
    New files have 1 page with 1 root frame."""
    resp = rpc(session, 'create-file', {
        'projectId': project_id,
        'name': name,
    })
    resp.raise_for_status()
    return resp.json()

3.3 Import Template (.penpot ZIP)

CRITICAL: The import-binfile endpoint expects ZIP format (starts with PK header). Files exported from Penpot's web UI in newer versions may use a v2 binary format (header 010b1a86) which is NOT compatible with this API. Always use templates that were exported via the API's export-binfile method.

def import_template(session, project_id, name, zip_bytes):
    """Import a .penpot ZIP file into a project.
    Returns SSE stream with progress events.
    """
    from io import BytesIO
    # MUST remove Content-Type for multipart
    saved_ct = session.headers.pop('Content-Type', None)
    resp = session.post(
        f'{PENPOT_URL}/api/rpc/command/import-binfile',
        data={
            'name': name,
            'project-id': project_id,  # NOTE: kebab-case for multipart
        },
        files={
            'file': ('template.penpot', BytesIO(zip_bytes), 'application/octet-stream'),
        },
        headers={'Accept': 'application/json'},
        timeout=180,
    )
    if saved_ct:
        session.headers['Content-Type'] = saved_ct
    return resp

def import_template_from_path(session, project_id, name, filepath):
    """Import from a local file path."""
    with open(filepath, 'rb') as f:
        zip_bytes = f.read()
    return import_template(session, project_id, name, zip_bytes)

3.4 Export File as ZIP

Export is an SSE (Server-Sent Events) stream that returns a download URL.

import re

def export_file(session, file_id):
    """Export a file as ZIP. Returns ZIP bytes."""
    resp = rpc(session, 'export-binfile', {
        'fileId': file_id,
        'includeLibraries': False,
        'embedAssets': True,
    })

    # Parse SSE to find download URL
    download_url = None
    for line in resp.text.strip().split('\n'):
        if line.startswith('data: ') and '~#uri' in line:
            match = re.search(r'"(https?://[^"]+)"', line)
            if match:
                download_url = match.group(1)

    if not download_url:
        raise RuntimeError('No download URL in export response')

    dl_resp = session.get(download_url, timeout=60)
    dl_resp.raise_for_status()
    return dl_resp.content  # ZIP bytes

3.5 List Project Files

def get_project_files(session, project_id):
    """List all files in a project."""
    resp = rpc(session, 'get-project-files', {'projectId': project_id})
    resp.raise_for_status()
    return resp.json()  # [{'id': '...', 'name': '...', ...}, ...]

3.6 Delete File / Project

def delete_file(session, file_id):
    rpc(session, 'delete-file', {'id': file_id})  # 204

def delete_project(session, project_id):
    rpc(session, 'delete-project', {'id': project_id})  # 204

4. Reading Design State (for Reward Verification)

4.1 Get File Data

def get_file(session, file_id):
    """Get full file data including pages index, components, colors, typographies."""
    resp = rpc(session, 'get-file', {'id': file_id})
    resp.raise_for_status()
    return resp.json()
    # Returns: {
    #   'id': '...',
    #   'name': '...',
    #   'data': {
    #     'pagesIndex': {'<page-id>': {'name': '...', ...}, ...},
    #     'components': {'<comp-id>': {...}, ...},
    #     'colors': {'<color-id>': {'name': '...', 'color': '#hex', ...}, ...},
    #     'typographies': {'<typo-id>': {'name': '...', 'fontFamily': '...', ...}, ...},
    #   }
    # }

4.2 Get Page Shape Tree

def get_page(session, file_id, page_id):
    """Get all shapes on a page. Returns objects dict keyed by shape ID."""
    resp = rpc(session, 'get-page', {
        'fileId': file_id,
        'pageId': page_id,
    })
    resp.raise_for_status()
    return resp.json()
    # Returns: {
    #   'objects': {
    #     '<shape-id>': {
    #       'type': 'frame' | 'rect' | 'circle' | 'text' | 'path' | 'image' | 'group',
    #       'name': 'Button',
    #       'x': 100, 'y': 200,
    #       'width': 300, 'height': 50,
    #       'fills': [{'fillColor': '#3498db', 'fillOpacity': 1}],
    #       'strokes': [...],
    #       'children': ['child-id-1', 'child-id-2'],
    #       'parentId': 'parent-id',
    #       ...
    #     },
    #     ...
    #   }
    # }

4.3 Shape Properties Reference

Each shape object may contain:

PropertyTypeDescription
typestringframe, rect, circle, text, path, image, group, bool, svg-raw
namestringUser-visible layer name
x, ynumberPosition
width, heightnumberDimensions
rotationnumberRotation in degrees
fillsarray[{fillColor: '#hex', fillOpacity: N}, ...]
strokesarray`[{strokeColor: '#hex', strokeWidth: N, strokeAlignment: 'center'
shadowarrayShadow effects
blurobjectBlur effect
opacitynumber0.0 – 1.0
hiddenbooleanVisibility
blockedbooleanLocked state
childrenarrayChild shape IDs (for frames/groups)
parentIdstringParent shape ID
contentobjectText content (for type: text)
selrectobjectSelection rectangle
constraintsobjectResponsive constraints
interactionsarrayPrototype interactions
componentIdstringLink to component (if instance)
componentFilestringSource file for component

4.4 Helper: Inspect All Pages

def inspect_file_shapes(session, file_id):
    """Get complete shape tree for all pages. Returns structured summary."""
    file_data = get_file(session, file_id)
    data = file_data.get('data', {})
    pages_index = data.get('pagesIndex', {})

    result = {
        'components': len(data.get('components', {})),
        'colors': len(data.get('colors', {})),
        'typographies': len(data.get('typographies', {})),
        'pages': [],
    }

    for pid in pages_index:
        pname = pages_index[pid]
        if isinstance(pname, dict):
            pname = pname.get('name', pid)

        page = get_page(session, file_id, pid)
        objects = page.get('objects', {})

        type_counts = {}
        for obj in objects.values():
            if isinstance(obj, dict):
                t = obj.get('type', 'unknown')
                type_counts[t] = type_counts.get(t, 0) + 1

        result['pages'].append({
            'id': pid,
            'name': pname,
            'objectCount': len(objects),
            'typeCounts': type_counts,
        })

    return result

5. Task State Persistence

The state file /tmp/task_penpot_state links initial_setup.py, golden_patch.py, and reward.py.

{
    "penpot_url": "https://cua-gym-figma.xlang.ai",
    "session_email": "session-a1b2c3d4e5f6@cua-gym.local",
    "session_password": "cua-7f8e9d0c1b2a3456",
    "team_id": "...",
    "project_id": "...",
    "file_id": "...",
    "file_url": "https://cua-gym-figma.xlang.ai/view/..."
}
import json

STATE_FILE = '/tmp/task_penpot_state'

def save_state(state):
    with open(STATE_FILE, 'w') as f:
        json.dump(state, f)

def load_state():
    with open(STATE_FILE) as f:
        return json.load(f)

6. initial_setup.py Template

"""
Initial Setup: <task_description>
Task ID: <task_id>
Domain: penpot
"""
import json
import os
import shlex
import subprocess
import time
import uuid

import requests

# --- Config ---
PENPOT_URL = 'https://cua-gym-figma.xlang.ai'

# --- Helper functions ---
def rpc(session, method, payload=None):
    resp = session.post(f'{PENPOT_URL}/api/rpc/command/{method}', json=payload or {})
    return resp

# --- Step 1: Create session user ---
session_id = uuid.uuid4().hex[:12]
session_email = f'session-{session_id}@cua-gym.local'
session_password = f'cua-{uuid.uuid4().hex[:16]}'

session = requests.Session()
session.headers.update({
    'Content-Type': 'application/json',
    'Accept': 'application/json',
})

# Register
resp = rpc(session, 'prepare-register-profile', {
    'email': session_email,
    'password': session_password,
    'fullname': 'CUA Agent',
})
token = resp.json()['token']
rpc(session, 'register-profile', {'token': token})

# Login
profile = rpc(session, 'login-with-password', {
    'email': session_email,
    'password': session_password,
}).json()
team_id = profile['defaultTeamId']

# --- Step 2: Create project ---
project = rpc(session, 'create-project', {
    'teamId': team_id,
    'name': 'Design Task',
}).json()
project_id = project['id']

# --- Step 3: Create or import initial design ---

# OPTION A: Import a pre-made template
# Template must be in ZIP format (exported via API, NOT from Penpot web UI)
# template_path = '/path/to/template.penpot'
# saved_ct = session.headers.pop('Content-Type', None)
# with open(template_path, 'rb') as f:
#     resp = session.post(
#         f'{PENPOT_URL}/api/rpc/command/import-binfile',
#         data={'name': 'Task Design', 'project-id': project_id},
#         files={'file': ('template.penpot', f, 'application/octet-stream')},
#         headers={'Accept': 'application/json'},
#         timeout=180,
#     )
# if saved_ct:
#     session.headers['Content-Type'] = saved_ct
# # Get imported file ID
# files = rpc(session, 'get-project-files', {'projectId': project_id}).json()
# file_id = files[0]['id']

# OPTION B: Create empty file (for "create from scratch" tasks)
file_data = rpc(session, 'create-file', {
    'projectId': project_id,
    'name': 'Task Design',
}).json()
file_id = file_data['id']

# --- Step 4: Save state ---
state = {
    'penpot_url': PENPOT_URL,
    'session_email': session_email,
    'session_password': session_password,
    'team_id': team_id,
    'project_id': project_id,
    'file_id': file_id,
}
with open('/tmp/task_penpot_state', 'w') as f:
    json.dump(state, f)

print(f'Project created: {project_id}')
print(f'File created: {file_id}')
file_url = f'{PENPOT_URL}/view/{file_id}'

# --- Step 5: Launch browser ---
def launch_gui(command, delay_sec=1.0):
    env = os.environ.copy()
    env['DISPLAY'] = ':0'
    subprocess.Popen(
        shlex.split(command),
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        env=env,
    )
    time.sleep(delay_sec)

# Open workspace editor (not viewer)
workspace_url = f'{PENPOT_URL}/#/workspace/{project_id}/{file_id}'
launch_gui(f'google-chrome "{workspace_url}"', delay_sec=3.0)
print(f'GUI_READY: launched browser at {workspace_url}')

7. golden_patch.py Template

Strategy: Export the initial file as a template, then create a new file with the expected final state, replacing the initial file in the state.

For Penpot tasks, the golden patch creates the expected completed design either by:

  • (A) Creating a new file with the expected content via import
  • (B) Modifying state file reference to point to a pre-built golden template
"""
Golden Patch: <task_description>
Task ID: <task_id>
Domain: penpot
Changes: <brief list of what this patch does>
"""
import json
import re

import requests

# --- Config ---
PENPOT_URL = 'https://cua-gym-figma.xlang.ai'

# --- Load state ---
with open('/tmp/task_penpot_state') as f:
    state = json.load(f)

def rpc(session, method, payload=None):
    resp = session.post(f'{PENPOT_URL}/api/rpc/command/{method}', json=payload or {})
    return resp

# --- Login ---
session = requests.Session()
session.headers.update({
    'Content-Type': 'application/json',
    'Accept': 'application/json',
})
rpc(session, 'login-with-password', {
    'email': state['session_email'],
    'password': state['session_password'],
})

# --- Delete initial file ---
rpc(session, 'delete-file', {'id': state['file_id']})

# --- Create golden file ---
# OPTION A: Import golden template
# golden_template = '/path/to/golden_template.penpot'
# saved_ct = session.headers.pop('Content-Type', None)
# with open(golden_template, 'rb') as f:
#     resp = session.post(
#         f'{PENPOT_URL}/api/rpc/command/import-binfile',
#         data={'name': 'Task Design', 'project-id': state['project_id']},
#         files={'file': ('golden.penpot', f, 'application/octet-stream')},
#         headers={'Accept': 'application/json'},
#         timeout=180,
#     )
# if saved_ct:
#     session.headers['Content-Type'] = saved_ct

# OPTION B: Create file with expected content
file_data = rpc(session, 'create-file', {
    'projectId': state['project_id'],
    'name': 'Task Design',
}).json()
new_file_id = file_data['id']

# TODO: Use update-file to add expected shapes/components
# This is the most complex part — see Section 9 for shape manipulation

# --- Update state ---
state['file_id'] = new_file_id
with open('/tmp/task_penpot_state', 'w') as f:
    json.dump(state, f)

print(f'Golden file created: {new_file_id}')

8. reward.py Template

"""
Reward Script: <task_description>
Task ID: <task_id>
Domain: penpot
Scoring: <brief rubric>
"""
import json
import sys

import requests

# --- Load state ---
try:
    with open('/tmp/task_penpot_state') as f:
        state = json.load(f)
except Exception as e:
    print(f'CRITICAL: Cannot read state: {e}')
    print('REWARD: 0.0')
    sys.exit(0)

PENPOT_URL = state['penpot_url']
FILE_ID = state['file_id']

def rpc(session, method, payload=None):
    resp = session.post(f'{PENPOT_URL}/api/rpc/command/{method}', json=payload or {})
    return resp

# --- Login ---
try:
    session = requests.Session()
    session.headers.update({
        'Content-Type': 'application/json',
        'Accept': 'application/json',
    })
    resp = rpc(session, 'login-with-password', {
        'email': state['session_email'],
        'password': state['session_password'],
    })
    assert resp.status_code == 200, f'Login failed: {resp.status_code}'
except Exception as e:
    print(f'CRITICAL: Login failed: {e}')
    print('REWARD: 0.0')
    sys.exit(0)

# --- Fetch design state ---
try:
    file_data = rpc(session, 'get-file', {'id': FILE_ID}).json()
    data = file_data.get('data', {})
    pages_index = data.get('pagesIndex', {})
    components = data.get('components', {})
    colors = data.get('colors', {})
    typographies = data.get('typographies', {})

    # Get first page shapes
    page_ids = list(pages_index.keys())
    all_shapes = {}
    for pid in page_ids:
        page = rpc(session, 'get-page', {'fileId': FILE_ID, 'pageId': pid}).json()
        all_shapes[pid] = page.get('objects', {})
except Exception as e:
    print(f'CRITICAL: Cannot read file: {e}')
    print('REWARD: 0.0')
    sys.exit(0)


# --- Verification ---
def verify_task():
    total_score = 0.0

    # Component 1: <description> (X.X points)
    try:
        # Example: check that a rectangle shape exists
        first_page_shapes = all_shapes[page_ids[0]]
        rect_shapes = [
            s for s in first_page_shapes.values()
            if isinstance(s, dict) and s.get('type') == 'rect'
        ]
        if len(rect_shapes) >= 1:
            print(f'PASS: Rectangle shape exists ({len(rect_shapes)} found) (0.3 pts)')
            total_score += 0.3
        else:
            print('FAIL: No rectangle shapes found')
    except Exception as e:
        print(f'ERROR: Component 1 — {e}')

    # Component 2: <description> (X.X points)
    try:
        # Example: check fill color
        for s in rect_shapes:
            fills = s.get('fills', [])
            if fills and fills[0].get('fillColor', '').lower() == '#3498db':
                print(f'PASS: Correct fill color #3498db (0.3 pts)')
                total_score += 0.3
                break
        else:
            print('FAIL: No shape with expected fill color #3498db')
    except Exception as e:
        print(f'ERROR: Component 2 — {e}')

    # Component 3: <description> (X.X points)
    try:
        # Example: check shape dimensions
        for s in rect_shapes:
            w = s.get('width', 0)
            h = s.get('height', 0)
            if abs(w - 200) < 5 and abs(h - 100) < 5:
                print(f'PASS: Shape dimensions ~200x100 (0.2 pts)')
                total_score += 0.2
                break
        else:
            print('FAIL: No shape with expected dimensions')
    except Exception as e:
        print(f'ERROR: Component 3 — {e}')

    # Component 4: <description> (X.X points)
    try:
        # Example: check text content
        text_shapes = [
            s for s in first_page_shapes.values()
            if isinstance(s, dict) and s.get('type') == 'text'
        ]
        if text_shapes:
            print(f'PASS: Text element exists ({len(text_shapes)} found) (0.2 pts)')
            total_score += 0.2
        else:
            print('FAIL: No text elements found')
    except Exception as e:
        print(f'ERROR: Component 4 — {e}')

    final_score = min(total_score, 1.0)
    print(f'\nScore: {total_score}/1.0')
    print(f'REWARD: {final_score}')
    return final_score

verify_task()

9. Verification Techniques Reference

9.1 Shape Count & Type Verification

# Count shapes by type on a page
type_counts = {}
for obj in page_shapes.values():
    if isinstance(obj, dict):
        t = obj.get('type', 'unknown')
        type_counts[t] = type_counts.get(t, 0) + 1

# Verify minimum counts
assert type_counts.get('rect', 0) >= 3, 'Need at least 3 rectangles'
assert type_counts.get('text', 0) >= 2, 'Need at least 2 text elements'

9.2 Shape Property Verification

# Find shape by name
def find_shape_by_name(shapes, name):
    for sid, s in shapes.items():
        if isinstance(s, dict) and s.get('name') == name:
            return s
    return None

button = find_shape_by_name(page_shapes, 'Submit Button')
if button:
    # Check position
    assert abs(button['x'] - 100) < 10
    assert abs(button['y'] - 200) < 10

    # Check size
    assert abs(button['width'] - 150) < 5
    assert abs(button['height'] - 40) < 5

    # Check fill
    fills = button.get('fills', [])
    assert len(fills) > 0
    assert fills[0].get('fillColor', '').lower() == '#2ecc71'

    # Check stroke
    strokes = button.get('strokes', [])
    if strokes:
        assert strokes[0].get('strokeWidth', 0) == 2

9.3 Layer Hierarchy Verification

# Check parent-child relationships
def get_children(shapes, parent_id):
    parent = shapes.get(parent_id)
    if not parent or not isinstance(parent, dict):
        return []
    child_ids = parent.get('children', [])
    return [shapes[cid] for cid in child_ids if cid in shapes]

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
197
Forks
18
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
penpot
Source
github.com/xlang-ai/cua-gym