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 <file.op> # 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 '<json>' [--parent P] # Insert node (--index N, --post-process)
op update <id> '<json>' # Update node
op delete <id> # Delete node
op move <id> <parent> [index] # Move node
op copy <id> <parent> # Deep-copy node
op replace <id> '<json>' # Replace node
# Batch design
op design '<dsl>' # Batch design DSL (inline, @file, or stdin) [--canvas-width N]
op design @ui.js # Sandboxed JS script: I(parent, obj) + loops (.js/.mjs implies --script)
op design '<js>' --script # Same, inline/stdin (flag must FOLLOW the payload)
# Layered workflow
op design:skeleton '<json>' # Create section structure
op design:content <id> '<json>' # Populate section content
op design:refine --root-id <id> # Validate + auto-fix (resolves icons) [--canvas-width N]
# Import
op import:svg <file.svg> [--parent P] # Import SVG as editable nodes
op import:figma <file.fig> [--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 <id> # Remove a page
op page rename <id> '<name>' # Rename a page
op page reorder <id> <index> # Move page to position
op page duplicate <id> # Clone page with new IDs
# Variables & Themes
op vars / op vars:set '<json>' # Variables (--replace to replace all)
op themes / op themes:set '<json>' # Themes (--replace to replace all)
op theme:save <file.optheme> # Save current theme as preset file
op theme:load <file.optheme> # Load a theme preset file
op theme:list <directory> # List .optheme presets in directory
# Codegen pipeline
op codegen:plan '<json>' # Submit codegen plan (framework, rootIds, options)
op codegen:submit '<json>' # 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 <path>, --page <id>, --pretty. Inputs: inline string, @filepath, or - (stdin).
Building Designs — Three 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.,
childrenarrays with nested objects, or multiple levels of array nesting). Keep eachI()call to a single level of nesting. For complex nodes with children, use separateI()calls for parent and children, or useop 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"}]})
Approach 3: Script mode (loops & data-driven — RECOMMENDED for repeated structures)
op design @ui.js # .js / .mjs file implies script mode
op design '<js code>' --script # inline or stdin; --script must FOLLOW the payload
Via MCP: call batch_design with the script argument (exactly ONE of nodes_json | operations | script per call). This is the SAME protocol OpenPencil's internal design agents use; follow the same contract:
Write a JavaScript program (no prose, no markdown fences) that builds the design by calling the global function I(parent, node):
const id = I(parent, { ...node... }); // inserts node, RETURNS its id (a string)
parent is null for a root frame, otherwise an id returned by an EARLIER I(...) call. A node is a child of X only if you call I(X, {...}).
I(...) is the ONLY function available — there is no console, and no other builder. Do not call console.log or any helper; just call I(...). Script mode is insert-only; use the DSL ops (Approach 2) for update/move/delete.
USE REAL JAVASCRIPT — const/let, arrays of data, and for...of / .forEach loops — to generate repeated structure (table rows, nav items, cards, list items) by looping over a data array. PREFER a loop over copy-pasting near-identical I(...) calls.
Each node object starts with type ("frame"/"text"/"rectangle"/"ellipse"/"path"/"icon_font") and uses camelCase props (cornerRadius, fontSize, fontWeight, justifyContent, alignItems, clipContent). Do NOT set x/y on children inside layout frames.
Example:
const sec = I(null, {type:"frame", name:"Clients", layout:"vertical", width:"fill_container", gap:0});
const tbl = I(sec, {type:"frame", layout:"vertical", width:"fill_container"});
const rows = [{name:"Alice Chen", status:"Active"}, {name:"Bob Ito", status:"VIP"}];
for (const r of rows) {
const row = I(tbl, {type:"frame", layout:"horizontal", width:"fill_container", padding:[12,16]});
const c1 = I(row, {type:"frame", width:"fill_container"}); I(c1, {type:"text", content:r.name});
const c2 = I(row, {type:"frame", width:"fill_container"}); I(c2, {type:"text", content:r.status});
}
Generate EVERY row/card/item with realistic values. Output ONLY the JavaScript program.
CLI/MCP notes:
- Nested
childrenarrays are SAFE here — the engine serializes each object to perfect single-line JSON, so the DSL's single-level-of-nesting limitation does not apply. Prefer separateI()calls anyway when you need the parent binding. - Node ids are remapped on insert — do not reference an authored
"id"after the call; only the returned binding is meaningful, and only inside this script. - Limits: 256 KiB source, 4096 inserts, 8 MiB recorded output, 2 s CPU, 64 MiB memory. A script truncated mid-statement is best-effort repaired (the complete prefix still runs).
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 <id>or useop 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 — prefericon_fontin 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 '<json>' |
| 2. Populate each section | design_content (with postProcess: true) | op design:content <section-id> '<json>' |
| 3. Validate + auto-fix | design_refine | op design:refine --root-id <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 '<json>' | codegen_plan | Declare framework, root node IDs, and options |
| 2. Submit | op codegen:submit '<json>' | 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", "rootIds": ["frame-1"], "options": { "tailwind": true } }
The submit JSON shape:
{ "nodeId": "card-1", "code": "<Card className=\"...\">...</Card>", "imports": ["Card"] }
Supported frameworks: react, html, vue, svelte, flutter, swiftui, compose, rn (React Native), css.
Multi-Page Documents
op page list # List all pages with IDs
op page add --name "Settings" # Add a new page
op page remove <page-id> # Remove a page
op page rename <page-id> 'New Name' # Rename a page
op page reorder <page-id> 2 # Move page to index 2
op page duplicate <page-id> # Clone page with new IDs
Use --page <id> on any command to target a specific page. Without it, commands operate on the first page.
Common Patterns
Patterns below show op insert --parent commands. Each pattern is copy-paste ready.
Navbar
NAV=$(op insert --parent "$ROOT" '{"type":"frame","role":"navbar","width":"fill_container","height":72,"layout":"horizontal","padding":[0,80],"justifyContent":"space_between","alignItems":"center","fill":[{"type":"solid","color":"#FFFFFF"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"#F3F4F6"}]}}' | ID)
op insert --parent "$NAV" '{"type":"text","content":"Brand","fontSize":20,"fontWeight":700,"fontFamily":"Space Grotesk"}'
LINKS=$(op insert --parent "$NAV" '{"type":"frame","role":"nav-links","layout":"horizontal","gap":32,"width":"fit_content","height":"fit_content"}' | ID)
op insert --parent "$LINKS" '{"type":"text","role":"nav-link","content":"Features","fontSize":15}'
op insert --parent "$LINKS" '{"type":"text","role":"nav-link","content":"Pricing","fontSize":15}'
CTA=$(op insert --parent "$NAV" '{"type":"rectangle","role":"button","padding":[10,24],"cornerRadius":8,"fill":[{"type":"solid","color":"#111111"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"}' | ID)
op insert --parent "$CTA" '{"type":"text","content":"Get Started","fontSize":14,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}'
Hero
HERO=$(op insert --parent "$ROOT" '{"type":"frame","role":"hero","width":"fill_container","height":"fit_content","layout":"vertical","padding":[100,80],"gap":24,"alignItems":"center"}' | ID)
op insert --parent "$HERO" '{"type":"text","role":"heading","content":"Build something great","fontSize":56,"fontWeight":700,"fontFamily":"Space Grotesk","textAlign":"center","letterSpacing":-1.5,"lineHeight":1.1,"textGrowth":"fixed-width","width":800}'
op insert --parent "$HERO" '{"type":"text","role":"subheading","content":"The modern platform for teams who ship fast.","fontSize":18,"textAlign":"center","lineHeight":1.6,"textGrowth":"fixed-width","width":560,"fill":[{"type":"solid","color":"#6B7280"}]}'
BTNS=$(op insert --parent "$HERO" '{"type":"frame","layout":"horizontal","gap":12,"width":"fit_content","height":"fit_content"}' | ID)
B1=$(op insert --parent "$BTNS" '{"type":"rectangle","role":"button","padding":[14,32],"cornerRadius":10,"fill":[{"type":"solid","color":"#111111"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"}' | ID)
op insert --parent "$B1" '{"type":"text","content":"Start Free","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}'
B2=$(op insert --parent "$BTNS" '{"type":"rectangle","role":"button","padding":[14,32],"cornerRadius":10,"fill":[{"type":"solid","color":"#F3F4F6"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"}' | ID)
op insert --parent "$B2" '{"type":"text","content":"View Demo","fontSize":16,"fontWeight":600}'
Feature Card (in horizontal grid, ALL cards must use fill_container)
CARD=$(op insert --parent "$GRID" '{"type":"rectangle","role":"feature-card","width":"fill_container","height":"fill_container","layout":"vertical","padding":28,"gap":16,"cornerRadius":16,"fill":[{"type":"solid","color":"#F9FAFB"}]}' | ID)
op insert --parent "$CARD" '{"type":"path","name":"ZapIcon","width":24,"height":24,"fill":[{"type":"solid","color":"#111111"}]}'
op insert --parent "$CARD" '{"type":"text","content":"Lightning Fast","fontSize":20,"fontWeight":600}'
op insert --parent "$CARD" '{"type":"text","role":"body-text","content":"Sub-second builds with smart caching.","fontSize":15,"lineHeight":1.6,"fill":[{"type":"solid","color":"#6B7280"}]}'
Form Input
GRP=$(op insert --parent "$FORM" '{"type":"frame","role":"form-group","layout":"vertical","gap":8,"width":"fill_container"}' | ID)
op insert --parent "$GRP" '{"type":"text","role":"label","content":"Email","fontSize":14,"fontWeight":500}'
INP=$(op insert --parent "$GRP" '{"type":"rectangle","role":"form-input","width":"fill_container","height":48,"cornerRadius":10,"layout":"horizontal","padding":[0,16],"gap":10,"alignItems":"center","fill":[{"type":"solid","color":"#F9FAFB"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"#E5E7EB"}]}}' | ID)
op insert --parent "$INP" '{"type":"path","name":"MailIcon","width":18,"height":18,"fill":[{"type":"solid","color":"#9CA3AF"}]}'
op insert --parent "$INP" '{"type":"text","content":"[email protected]","fontSize":15,"fill":[{"type":"solid","color":"#9CA3AF"}]}'
Footer
FOOTER=$(op insert --parent "$ROOT" '{"type":"frame","role":"footer","width":"fill_container","height":"fit_content","layout":"horizontal","padding":[48,80],"gap":80,"fill":[{"type":"solid","color":"#F9FAFB"}]}' | ID)
COL1=$(op insert --parent "$FOOTER" '{"type":"frame","layout":"vertical","gap":16,"width":240}' | ID)
op insert --parent "$COL1" '{"type":"text","content":"Brand","fontSize":20,"fontWeight":700,"fontFamily":"Space Grotesk"}'
op insert --parent "$COL1" '{"type":"text","content":"Building the future of design.","fontSize":14,"lineHeight":1.6,"fill":[{"type":"solid","color":"#6B7280"}]}'
COL2=$(op insert --parent "$FOOTER" '{"type":"frame","layout":"vertical","gap":12,"width":"fit_content"}' | ID)
op insert --parent "$COL2" '{"type":"text","content":"Product","fontSize":14,"fontWeight":600}'
op insert --parent "$COL2" '{"type":"text","content":"Features","fontSize":14,"fill":[{"type":"solid","color":"#6B7280"}]}'
op insert --parent "$COL2" '{"type":"text","content":"Pricing","fontSize":14,"fill":[{"type":"solid","color":"#6B7280"}]}'
Common Mistakes
| Mistake | Fix |
|---|---|
| Setting x/y inside layout container | Remove x/y — engine auto-positions |
| Cards with different width strategies | All siblings: same sizing (fill_container) |
fill_container child in fit_content parent | Use fixed width or switch parent to fill_container |
Pure black text #000000 | Use #111111 or #0F172A |
| Heavy drop shadows | Use subtle rgba(0,0,0,0.05-0.12) |
| Emoji as icons | Use path nodes with icon names |
| Lorem ipsum placeholder | Write realistic, concise copy |
| Fixed height on text | Use textGrowth: "fixed-width" instead |
| Space Grotesk for CJK | Use "Noto Sans SC/JP/KR" |
| Negative letterSpacing on CJK | Always 0 for CJK text |
| Missing post-process after insert | Run op design:refine --root-id <id> after building the tree |
| Icons inserted but not visible | Path nodes need design:refine or --post-process to resolve SVG |
Using DSL I() with inline children | DSL parser fails on nested JSON — insert parent and children separately |
Missing postProcess: true in MCP | Always set for MCP tool calls |
Full Example — op insert Workflow (Recommended)
Build a complete mobile login page using op insert --parent. This is the most reliable approach.
#!/bin/bash
set -e
ID() { python3 -c "import sys,json; print(json.load(sys.stdin)['nodeId'])"; }
# Root frame (mobile)
ROOT=$(op insert '{"type":"frame","name":"Login","width":375,"height":812,"layout":"vertical","fill":[{"type":"solid","color":"#FFFFFF"}]}' | ID)
# Header
TOP=$(op insert --parent "$ROOT" '{"type":"frame","width":"fill_container","height":"fit_content","layout":"vertical","padding":[80,32,40,32],"gap":14,"alignItems":"center"}' | ID)
op insert --parent "$TOP" '{"type":"path","name":"ShieldIcon","width":48,"height":48,"fill":[{"type":"solid","color":"#6366F1"}]}'
op insert --parent "$TOP" '{"type":"text","content":"Welcome Back","fontSize":28,"fontWeight":700,"fontFamily":"Space Grotesk","letterSpacing":-0.5,"textAlign":"center"}'
# Form
FORM=$(op insert --parent "$ROOT" '{"type":"frame","width":"fill_container","height":"fit_content","layout":"vertical","padding":[0,32],"gap":20}' | ID)
# Email input
GRP=$(op insert --parent "$FORM" '{"type":"frame","role":"form-group","layout":"vertical","gap":8,"width":"fill_container"}' | ID)
op insert --parent "$GRP" '{"type":"text","role":"label","content":"Email","fontSize":14,"fontWeight":500}'
INP=$(op insert --parent "$GRP" '{"type":"rectangle","role":"form-input","width":"fill_container","height":48,"cornerRadius":10,"layout":"horizontal","padding":[0,16],"gap":10,"alignItems":"center","fill":[{"type":"solid","color":"#F9FAFB"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"#E5E7EB"}]}}' | ID)
op insert --parent "$INP" '{"type":"path","name":"MailIcon","width":18,"height":18,"fill":[{"type":"solid","color":"#9CA3AF"}]}'
op insert --parent "$INP" '{"type":"text","content":"[email protected]","fontSize":15,"fill":[{"type":"solid","color":"#9CA3AF"}]}'
# Login button
BTN=$(op insert --parent "$FORM" '{"type":"rectangle","role":"button","width":"fill_container","height":50,"cornerRadius":12,"fill":[{"type":"solid","color":"#111111"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"}' | ID)
op insert --parent "$BTN" '{"type":"text","content":"Sign In","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]}'
# IMPORTANT: resolve icons + validate layout
op design:refine --root-id "$ROOT"
DSL Example — Landing Page
DSL is suitable for simpler structures. Avoid inline children — insert parent and children as separate operations.
root=I(null, {"type":"frame","name":"Landing","width":1200,"height":0,"layout":"vertical","fill":[{"type":"solid","color":"#FFFFFF"}]})
nav=I(root, {"type":"frame","role":"navbar","width":"fill_container","height":72,"layout":"horizontal","padding":[0,80],"justifyContent":"space_between","alignItems":"center"})
I(nav, {"type":"text","content":"Acme","fontSize":20,"fontWeight":700,"fontFamily":"Space Grotesk"})
links=I(nav, {"type":"frame","role":"nav-links","layout":"horizontal","gap":32,"width":"fit_content","height":"fit_content"})
I(links, {"type":"text","role":"nav-link","content":"Features","fontSize":15})
I(links, {"type":"text","role":"nav-link","content":"Pricing","fontSize":15})
cta=I(nav, {"type":"rectangle","role":"button","padding":[10,24],"cornerRadius":8,"fill":[{"type":"solid","color":"#111111"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"})
I(cta, {"type":"text","content":"Get Started","fontSize":14,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]})
hero=I(root, {"type":"frame","role":"hero","width":"fill_container","height":"fit_content","layout":"vertical","padding":[100,80],"gap":24,"alignItems":"center"})
I(hero, {"type":"text","role":"heading","content":"Ship faster with Acme","fontSize":56,"fontWeight":700,"fontFamily":"Space Grotesk","textAlign":"center","letterSpacing":-1.5,"lineHeight":1.1,"textGrowth":"fixed-width","width":800})
I(hero, {"type":"text","role":"subheading","content":"Turn ideas into production apps in minutes.","fontSize":18,"textAlign":"center","lineHeight":1.6,"textGrowth":"fixed-width","width":560,"fill":[{"type":"solid","color":"#6B7280"}]})
btns=I(hero, {"type":"frame","layout":"horizontal","gap":12,"width":"fit_content","height":"fit_content"})
b1=I(btns, {"type":"rectangle","role":"button","padding":[14,32],"cornerRadius":10,"fill":[{"type":"solid","color":"#111111"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"})
I(b1, {"type":"text","content":"Start Free","fontSize":16,"fontWeight":600,"fill":[{"type":"solid","color":"#FFFFFF"}]})
b2=I(btns, {"type":"rectangle","role":"button","padding":[14,32],"cornerRadius":10,"fill":[{"type":"solid","color":"#F3F4F6"}],"layout":"horizontal","justifyContent":"center","alignItems":"center"})
I(b2, {"type":"text","content":"View Demo","fontSize":16,"fontWeight":600})
feat=I(root, {"type":"frame","role":"section","width":"fill_container","height":"fit_content","layout":"vertical","padding":[80,80],"gap":48,"alignItems":"center"})
I(feat, {"type":"text","role":"heading","content":"Everything you need","fontSize":36,"fontWeight":700,"fontFamily":"Space Grotesk","textAlign":"center","letterSpacing":-0.5})
grid=I(feat, {"type":"frame","role":"feature-grid","width":"fill_container","layout":"horizontal","gap":24})
c1=I(grid, {"type":"rectangle","role":"feature-card","width":"fill_container","height":"fill_container","layout":"vertical","padding":28,"gap":16,"cornerRadius":16,"fill":[{"type":"solid","color":"#F9FAFB"}]})
I(c1, {"type":"path","name":"ZapIcon","width":24,"height":24,"fill":[{"type":"solid","color":"#111111"}]})
I(c1, {"type":"text","content":"Lightning Fast","fontSize":20,"fontWeight":600})
I(c1, {"type":"text","role":"body-text","content":"Sub-second builds with smart caching.","fontSize":15,"lineHeight":1.6,"fill":[{"type":"solid","color":"#6B7280"}]})
c2=C(c1, grid, {})
U(c2+"/0", {"name":"ShieldIcon"})
U(c2+"/1", {"content":"Enterprise Security"})
U(c2+"/2", {"content":"SOC 2 certified with end-to-end encryption."})
c3=C(c1, grid, {})
U(c3+"/0", {"name":"GitBranchIcon"})
U(c3+"/1", {"content":"Git-Native Workflow"})
U(c3+"/2", {"content":"Preview deploys on every push with instant rollback."})