Penpot — Setup & Verification Guide
SkillMediaHow 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.
No other account needed.
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:
| Property | Type | Description |
|---|---|---|
type | string | frame, rect, circle, text, path, image, group, bool, svg-raw |
name | string | User-visible layer name |
x, y | number | Position |
width, height | number | Dimensions |
rotation | number | Rotation in degrees |
fills | array | [{fillColor: '#hex', fillOpacity: N}, ...] |
strokes | array | `[{strokeColor: '#hex', strokeWidth: N, strokeAlignment: 'center' |
shadow | array | Shadow effects |
blur | object | Blur effect |
opacity | number | 0.0 – 1.0 |
hidden | boolean | Visibility |
blocked | boolean | Locked state |
children | array | Child shape IDs (for frames/groups) |
parentId | string | Parent shape ID |
content | object | Text content (for type: text) |
selrect | object | Selection rectangle |
constraints | object | Responsive constraints |
interactions | array | Prototype interactions |
componentId | string | Link to component (if instance) |
componentFile | string | Source 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