Install
$ agentstack add skill-zseven-w-openpencil-skill-openpencil-design ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
OpenPencil Design
Generate production-quality vector designs by writing PenNode JSON trees. Use the op CLI or MCP tools to create, read, update, and delete nodes on the OpenPencil canvas.
When to Use
- Creating or modifying UI designs in
.opfiles - Using the
opCLI to script design operations - Designing via MCP tools (
batch_design,insert_node,design_skeleton) - Need reference for PenNode schema, roles, or layout rules
Quick Reference — op CLI
# App control
op start [--desktop|--web] # Launch app
op stop # Stop running instance
op status # Check if running
# Document
op open [file.op] # Open file or connect to live canvas
op save # Save current document
op get [--depth N] [--pretty] # Get document tree
op selection [--depth N] # Get current canvas selection
op read-nodes [id...] [--depth N] [--vars] # Read node subtree(s) with optional variable resolution
op layout [--parent P] [--depth N] # Snapshot layout tree with computed positions
op find-space [--direction D] [--width N] [--height N] # Find empty space on canvas
# Node operations
op insert '' [--parent P] # Insert node (--index N, --post-process)
op update '' # Update node
op delete # Delete node
op move [index] # Move node
op copy # Deep-copy node
op replace '' # Replace node
# Batch design
op design '' # Batch design DSL (inline, @file, or stdin) [--canvas-width N]
# Layered workflow
op design:skeleton '' # Create section structure
op design:content '' # Populate section content
op design:refine --root-id # Validate + auto-fix (resolves icons) [--canvas-width N]
# Import
op import:svg [--parent P] # Import SVG as editable nodes
op import:figma [--out out.op] # Convert Figma .fig to .op document
# Pages
op page list # List all pages
op page add [--name N] # Add a new page
op page remove # Remove a page
op page rename '' # Rename a page
op page reorder # Move page to position
op page duplicate # Clone page with new IDs
# Variables & Themes
op vars / op vars:set '' # Variables (--replace to replace all)
op themes / op themes:set '' # Themes (--replace to replace all)
op theme:save # Save current theme as preset file
op theme:load # Load a theme preset file
op theme:list # List .optheme presets in directory
# Codegen pipeline
op codegen:plan '' # Submit codegen plan (framework, rootIds, options)
op codegen:submit '' # Submit a code chunk for a node
op codegen:assemble [--framework F] # Assemble all submitted chunks into final output
op codegen:clean # Clear codegen state
Global flags: --file , --page , --pretty. Inputs: inline string, @filepath, or - (stdin).
Building Designs — Two Approaches
Approach 1: op insert (Recommended)
The most reliable way to build designs. Use --parent to specify the parent node. Capture the returned nodeId to reference later. Always finish with design:refine to resolve icons and validate layout.
# Create root frame, capture its ID
ROOT=$(op insert '{"type":"frame","name":"Page","width":375,"height":812,"layout":"vertical"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['nodeId'])")
# Insert children using --parent
op insert --parent "$ROOT" '{"type":"text","content":"Hello","fontSize":28,"fontWeight":700}'
# Post-process: resolve icons, validate layout
op design:refine --root-id "$ROOT"
Approach 2: Batch Design DSL
One operation per line. Bind results with name= for later reference. Best for simple, flat structures.
> Limitation: The DSL parser cannot handle deeply nested JSON (e.g., children arrays with nested objects, or multiple levels of array nesting). Keep each I() call to a single level of nesting. For complex nodes with children, use separate I() calls for parent and children, or use op insert --parent.
root=I(null, { "type": "frame", "width": 1200, "layout": "vertical" })
nav=I(root, { "type": "frame", "role": "navbar", "height": 72 })
U(nav, { "fill": [{"type": "solid", "color": "#FFFFFF"}] })
card2=C(card1, grid, { "name": "Card 2" })
M(sidebar, main, 0)
D(old_section)
R(old_btn, { "type": "rectangle", "role": "button" })
| Op | Syntax | Action | |----|--------|--------| | I | name=I(parent, { node }) | Insert | | U | U(ref, { updates }) | Update | | C | name=C(source, parent, { overrides }) | Copy | | R | name=R(ref, { node }) | Replace | | M | M(ref, parent, index?) | Move | | D | D(ref) | Delete | | G | name=G(parent, "search", "query") | Generate image via search |
DSL safe pattern — always insert parent and children separately:
btn=I(form, {"type":"rectangle","role":"button","width":"fill_container","height":50,"cornerRadius":12,"fill":[{"type":"solid","color":"#111111"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"})
I(btn, {"type":"text","content":"Submit","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]})
STRICT JSON Rules
When emitting PenNode JSON (via op insert, op design, batch_design, insert_node), you MUST produce strictly valid JSON. Common mistakes that break parsing:
- Every property MUST have both a key and a value. NEVER emit
": 50or: 50without a key name. This often happens when you truncate/reformat — double-check. - Every key MUST be a double-quoted non-empty string.
fillis ALWAYS an array:"fill": [{"type": "solid", "color": "#hex"}]. Shorthand like"fill": "#hex"works but the array form is the canonical shape.strokeis an object with afillarray:"stroke": {"thickness": 1, "fill": [{"type": "solid", "color": "#hex"}]}. NEVER"stroke": {"thickness": 1, "color": "#hex"}or"stroke": "#hex"(parser auto-converts these but the correct shape is preferred).- NO trailing commas before
}or]. - NO comments inside JSON (
//or/* */). - Use straight double quotes
", not smart/curly quotes. contentfor text, NOTtext:{"type": "text", "content": "Hello"}.iconFontNamefor icons, NOTiconNameoricon:{"type": "icon_font", "iconFontName": "lock"}.- Before finalizing the JSON, mentally verify: every key has a value, every value has a key, all brackets balance.
PenNode Schema
Common Properties
{
"type": "frame|rectangle|text|ellipse|line|polygon|path|image|icon_font|group|ref",
"name": "Display Name",
"role": "semantic-role",
"x": 0, "y": 0,
"rotation": 0, "opacity": 1, "visible": true
}
Container Properties (frame, rectangle, group, ellipse)
{
"width": 400, // number | "fill_container" | "fit_content"
"height": 300,
"layout": "vertical", // "none" | "vertical" | "horizontal"
"gap": 16,
"padding": [16, 24], // number | [v, h] | [top, right, bottom, left]
"justifyContent": "center", // "start" | "center" | "end" | "space_between" | "space_around"
"alignItems": "center", // "start" | "center" | "end"
"clipContent": true,
"cornerRadius": 12, // number | [tl, tr, br, bl]
"fill": [{ "type": "solid", "color": "#FFFFFF" }],
"stroke": { "thickness": 1, "fill": [{ "type": "solid", "color": "#E5E7EB" }], "align": "inside", "dashPattern": [5, 3] },
"effects": [{ "type": "shadow", "offsetX": 0, "offsetY": 4, "blur": 12, "spread": 0, "color": "rgba(0,0,0,0.08)" }],
"children": []
}
Text
{
"type": "text",
"content": "Hello", // string or StyledTextSegment[]
"fontSize": 16, "fontFamily": "Inter", "fontWeight": 600,
"textAlign": "center", // "left" | "center" | "right"
"textGrowth": "fixed-width", // "auto" | "fixed-width" | "fixed-width-height"
"lineHeight": 1.5, "letterSpacing": 0,
"fill": [{ "type": "solid", "color": "#111111" }]
}
Rich text: "content": [{ "text": "Bold ", "fontWeight": "bold" }, { "text": "normal" }]
Icons — Two Options
Option A: icon_font (RECOMMENDED — renders directly, no post-processing needed)
{ "type": "icon_font", "name": "Lock Icon", "iconFontName": "lock",
"width": 20, "height": 20,
"fill": [{ "type": "solid", "color": "#6B7280" }] }
Field is iconFontName (NOT iconName, NOT icon). Values are lowercase kebab-case Lucide names: mail, lock, eye, eye-off, chrome, apple, message-circle, x, arrow-right, search, heart, star, check, plus, bell, home, user, settings, chevron-right, download, globe, layers, zap, shield, play.
Works in ALL contexts: CLI, MCP tools, or direct .op files — no design:refine required.
Option B: path (requires post-processing)
{ "type": "path", "name": "HeartIcon", "width": 24, "height": 24,
"fill": [{ "type": "solid", "color": "#111111" }] }
PascalCase + "Icon" suffix. Auto-resolved from Lucide set during post-processing.
> Path icons need post-processing. After inserting path nodes, run op design:refine --root-id or use op insert --post-process. Without this, path icons won't render visually. The standalone MCP server (used by ACP agents) does NOT have hook implementations registered, so path icons will NOT resolve there — prefer icon_font in MCP contexts.
Image
{ "type": "image", "src": "https://example.com/photo.jpg", "width": 400, "height": 300,
"objectFit": "crop", "cornerRadius": 12 }
AI image placeholders (resolved by design:refine):
{ "type": "image", "width": 400, "height": 300,
"imagePrompt": "A modern office workspace with natural light",
"imageSearchQuery": "modern office workspace" }
Image adjustments (all -100 to 100): exposure, contrast, saturation, temperature, tint, highlights, shadows.
Polygon
{ "type": "polygon", "polygonCount": 6, "width": 80, "height": 80, "cornerRadius": 4,
"fill": [{ "type": "solid", "color": "#6366F1" }] }
Icon Font
{ "type": "icon_font", "iconFontName": "lucide:home", "width": 24, "height": 24,
"fill": [{ "type": "solid", "color": "#111111" }] }
Line
{ "type": "line", "x2": 200, "y2": 0,
"stroke": { "thickness": 1, "fill": [{ "type": "solid", "color": "#E5E7EB" }] } }
Fill Types
{ "type": "solid", "color": "#3B82F6" }
{ "type": "linear_gradient", "angle": 135,
"stops": [{ "offset": 0, "color": "#6366F1" }, { "offset": 1, "color": "#8B5CF6" }] }
{ "type": "radial_gradient", "cx": 0.5, "cy": 0.5, "radius": 0.5,
"stops": [{ "offset": 0, "color": "#FFF" }, { "offset": 1, "color": "#000" }] }
{ "type": "image", "url": "https://example.com/texture.jpg", "mode": "fill" }
Image fill modes: fill, fit, crop, tile, stretch. Image fill also supports adjustment filters (exposure, contrast, saturation, etc.).
Ref Node (Component Instance)
{ "type": "ref", "ref": "reusable-frame-id",
"descendants": { "child-id": { "content": "Override text" } } }
References a frame with reusable: true. Override specific descendant properties via descendants.
Design Variables
Reference with $ prefix: "color": "$primaryColor", "gap": "$spacing".
Semantic Roles
Roles declare intent — the engine applies smart defaults. Always prefer roles over manual styling.
| Category | Roles | |----------|-------| | Layout | section, row, column, centered-content, divider, spacer | | Navigation | navbar, nav-links, nav-link | | Interactive | button, icon-button, badge, tag, pill, input, form-input, search-bar | | Cards | card, feature-card, stat-card, pricing-card, image-card | | Content | hero, feature-grid, cta-section, footer, testimonial, stats-section | | Typography | heading, subheading, body-text, caption, label | | Media | avatar, icon, phone-mockup, screenshot-frame | | Table | table, table-row, table-header, table-cell | | Form | form-group |
Key defaults:
navbar→ height: 56-72, horizontal, space_between, center-alignedbutton→ padding: [12, 24], cornerRadius: 8, centeredcard→ vertical, gap: 12, cornerRadius: 12, padding: 24heading→ lineHeight: 1.2, letterSpacing: -0.5body-text→ fill_container, textGrowth: fixed-width, lineHeight: 1.5
Layout Rules
- NEVER set x/y on children inside layout containers — engine positions them
- Siblings must use same width strategy — all
fill_containeror all fixed - NEVER
fill_containerinsidefit_contentparent — circular dependency - Cards in horizontal row: ALL
width: "fill_container",height: "fill_container"
Sizing Decision
| Question | Answer | |----------|--------| | Stretch to fill? | "fill_container" | | Shrink to content? | "fit_content" | | Exact size? | number (px) |
Design Type Sizing
| Type | Width | Height | |------|-------|--------| | Landing page | 1200 | 0 (auto) | | Mobile screen | 375 | 812 | | Dashboard | 1200 | 0 (auto) |
Design Principles
Typography
Display: 40-56px 700 letterSpacing: -1.5 lineHeight: 1.1 "Space Grotesk"
Heading: 28-36px 700 letterSpacing: -0.5 lineHeight: 1.2 "Space Grotesk"
Subheading: 20-24px 600 letterSpacing: -0.25 lineHeight: 1.3 "Space Grotesk"
Body: 15-18px 400 letterSpacing: 0 lineHeight: 1.5 "Inter"
Caption: 13-14px 400 letterSpacing: 0 lineHeight: 1.4 "Inter"
CJK: use "Noto Sans SC/JP/KR", lineHeight >= 1.3, letterSpacing: 0 always.
Color
Primary text: #111111 Secondary: #6B7280 Subtle: #9CA3AF
Background: #FFFFFF Surface: #F9FAFB Border: #E5E7EB
Max 2 saturated colors. WCAG AA: 4.5:1 body, 3:1 large. Dark bg: #0F172A, not #000000.
Spacing (8px grid)
Related: 8-16px Components: 16-24px
Groups: 24-32px Sections: 48-80px Page padding: 80px
Shadows
// Subtle (cards)
{ "type": "shadow", "offsetY": 1, "blur": 3, "color": "rgba(0,0,0,0.05)" }
// Medium (dropdowns)
{ "type": "shadow", "offsetY": 4, "blur": 12, "color": "rgba(0,0,0,0.08)" }
// Elevated (modals)
{ "type": "shadow", "offsetY": 8, "blur": 24, "spread": -4, "color": "rgba(0,0,0,0.12)" }
Copy Rules
Headlines: 2-6 words. Subtitles: max 15 words. Buttons: 1-3 words. No lorem ipsum. No emoji as icons.
Layered Workflow
For complex multi-section pages, use the three-step skeleton → content → refine flow:
| Step | MCP Tool | CLI Equivalent | |------|----------|----------------| | 1. Create section structure | design_skeleton | op design:skeleton '' | | 2. Populate each section | design_content (with postProcess: true) | op design:content '' | | 3. Validate + auto-fix | design_refine | op design:refine --root-id |
design:refine resolves icon names → SVG paths, fixes layout issues, and validates the tree. Always run as the final step.
Codegen Pipeline
For incremental, framework-aware code generation from the design tree:
| Step | CLI Command | MCP Tool | Description | |------|------------|----------|-------------| | 1. Plan | op codegen:plan '' | codegen_plan | Declare framework, root node IDs, and options | | 2. Submit | op codegen:submit '' | codegen_submit_chunk | Submit generated code for individual nodes | | 3. Assemble | op codegen:assemble --framework react | codegen_assemble | Combine all chunks into the final output | | 4. Clean | op codegen:clean | codegen_clean | Clear server-side codegen state |
The plan JSON shape:
{ "framework": "react", "root
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [ZSeven-W](https://github.com/ZSeven-W)
- **Source:** [ZSeven-W/openpencil-skill](https://github.com/ZSeven-W/openpencil-skill)
- **License:** MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.