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 and server control
op start [--file path.op] # Launch the desktop editor and live MCP server
op start --web [--host H] [--file path.op] # Serve the browser editor and MCP
op start --headless [--file path.op] # Run a windowless file-backed MCP server
op stop # Stop the running editor / MCP server
op status # Check whether MCP HTTP is reachable
op tools # List every MCP tool and input schema
# Skill management
op install [--target T] # Install openpencil-skill for detected AI agents
op uninstall [--target T] # Uninstall openpencil-skill
op skill:export <skill> [--out DIR] # Export an embedded skill
# Document
op open [file.op] # Open file or connect to live canvas
op save <file.op> # Save current document
op get [--type T] [--name N] [--id ID] [--depth N] [--parent P]
op selection [--depth N] # Get current canvas selection
op read-nodes [ids] [--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] [--page PAGE] [--post-process]
op update <id> '<json>' [--page PAGE] [--post-process]
op delete <id> [--page PAGE]
op move <id> [--parent P] [--index N] [--page PAGE]
op copy <id> [--parent P] [--page PAGE]
op replace <id> '<json>' [--page PAGE] [--post-process]
# Batch design
op design '<dsl>' # Batch design DSL (inline, @file, or stdin) [--canvas-width N]
op design @ui.js # Sandboxed JS: I(parent, obj) + loops (.js/.mjs implies --script)
op design '<js>' --script # Same in inline/stdin form; flag follows 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]
# Design-as-code conversion
op design:upsert-vars --key K --file vars.json [--source P] [--hash H]
op design:upsert-component --key K --name N --file node.json [--source P] [--hash H]
op design:upsert-screen --key K --file node.json [--source P] [--hash H]
op design:status [--kind token|component|screen]
op design:lint [--node ID]
# Templates & style assets
op templates [--scene S] [--tag T] # List shipped scene templates (incl. 16:9 decks)
op use-template <id> # Start from a template: takes over a blank
# starter page, otherwise appends its boards
op styles [<id>] [--tag T] [--platform P] # List style guides: shipped corpus +
# your imported DESIGN.md files; a bare id
# prints that guide's markdown
# Export & delivery
op export [--item ID|--selection] --output PATH [--format png|jpeg|webp|pdf] [--scale N]
op export-deck --output PATH [--format pptx|html|pdf] # Active page's boards as a deck
op export-frames --output-dir DIR [--format png|jpeg|webp] # One image per top-level frame
# Import
op import:svg <file.svg> [--parent P] # Import SVG as editable nodes
op import:html <file.html|url> [--parent P] [--viewport-height N] [--out out.op]
# Import a page or live URL as nodes
op import:snapshot <snapshot.json> [--parent P] [--out out.op]
# Import a web-capture snapshot
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 plan (chunks, sharedStyles, rootLayout)
op codegen:submit <planId> '<json>' # Submit a code chunk for the plan
op codegen:assemble <planId> [--framework F] # Assemble final output
op codegen:clean <planId> # Clear codegen state for the plan
Global flags: --port <n>, --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. Nested children arrays are supported, but insert children separately when later operations need stable bindings for them.
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 |
Binding-friendly pattern — insert parent and children separately when they need later references:
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 (Recommended for repeated structures)
Use sandboxed JavaScript when loops or data-driven repetition are clearer than repeated DSL operations:
op design @ui.js # .js / .mjs implies script mode
op design '<js code>' --script # inline or stdin; --script follows the payload
Via MCP, call batch_design with exactly one of nodes_json, operations, or script. Script mode provides two recording builders:
const section = I(null, {type: "frame", name: "Clients", layout: "vertical", width: "fill_container"});
const rows = ["Alice Chen", "Bob Ito", "Sam Rivera"];
for (const name of rows) {
const row = I(section, {type: "frame", layout: "horizontal", width: "fill_container", padding: [12, 16]});
I(row, {type: "text", content: name});
}
// Instantiate a component from an attached UI kit:
K("shadcn/btn-primary", section, {name: "Primary Action"});
I(parent, node) inserts a node and returns its remapped ID. K(kitComponentId, parent, overrides) instantiates a UI-kit component and also returns its remapped ID; use starter/<id>, shadcn/<id>, or <kit-id>/<component-id>. Use null for a root; otherwise pass an ID returned by an earlier recording call. C, U, D, M, R, G, and console.* exist only as no-op compatibility stubs in script mode; use the DSL for update, copy, replace, move, delete, or image-search operations.
Nested children arrays are safe in script mode. Limits are 256 KiB source, 4096 recorded operations, 8 MiB recorded output, 2 seconds of wall-clock evaluation, and 64 MiB of memory.
STRICT JSON Rules
When emitting PenNode JSON (via op insert, the batch DSL, nodes_json, or insert_node), you MUST produce strictly valid JSON. Script mode uses JavaScript object literals instead. Common mistakes that break JSON 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|group|rectangle|ellipse|polygon|line|text|path|text_input|text_area|select|switch|checkbox|slider|radio_group|number_input|progress|tabs|image|icon_font|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.
Starting From a Template
Starting from a blank frame is rarely the fastest route. op templates lists
the shipped catalogue — tutorials, comparisons, carousels and the 16:9
presentation decks — and op use-template <id> brings one in: it takes over an
untouched starter page, and appends its boards to the right of anything else.
The template carries its own palette, so the boards resolve against real
variables rather than hard-coded colours.
op styles lists the style guides available to pin, including any DESIGN.md
the user imported. Prefer the user's own guide when one exists — it is the
material they chose.
| Step | MCP Tool | CLI Equivalent |
|---|---|---|
| Browse templates | list_scene_templates |
op templates [--scene S] [--tag T] |
| Start from one | use_scene_template |
op use-template <id> |
| Browse style assets | list_style_guides |
op styles [<id>] |
Delivering the Result
A design the user cannot open is not finished. Export it rather than describing what you would export.
| Goal | MCP Tool | CLI Equivalent |
|---|---|---|
| One node / page / selection | export_item, export_nodes |
op export --output PATH --format png|pdf |
| A presentation deck | export_deck |
op export-deck --output PATH --format pptx|html|pdf |
| Every frame on the page | export_frames |
op export-frames --output-dir DIR |
| Check a deck before writing it | get_deck_boards |
— |
For decks, pptx is editable PowerPoint and the right default; html is one
self-contained file; pdf is one page per slide. Hidden boards are skipped by
design.
The destination argument is outputPath / outputDir, never filePath.
filePath means "the .op document this call targets" everywhere in this API,
so using it for a destination sends the call at a document that does not exist.
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 chunks, dependencies, shared styles, and root layout |
| 2. Submit | op codegen:submit <planId> '<json>' |
codegen_submit_chunk |
Submit generated code for individual nodes |
| 3. Assemble | op codegen:assemble <planId> --framework react |
codegen_assemble |
Combine all chunks into the final output |
| 4. Clean | op codegen:clean <planId> |
codegen_clean |
Clear server-side codegen state |
codegen:plan returns a planId; pass that value to every submit, assemble, and clean command in the same pipeline.
The plan JSON shape:
{
"chunks": [{
"id": "root",
"name": "Root",
"nodeIds": ["frame-1"],
"role": "root",
"suggestedComponentName": "Root",
"dependencies": []
}],
"sharedStyles": [],
"rootLayout": { "direction": "column", "gap": 0, "responsive": true }
}
The submit JSON shape:
{
"chunkId": "root",
"code": "export function Root() { return <main />; }",
"contract": {
"chunkId": "root",
"componentName": "Root",
"exportedProps": [],
"slots": [],
"cssClasses": [],
"cssVariables": [],
"imports": []
}
}
Supported frameworks: react, html, vue, svelte, flutter, swiftui, compose, react-native.
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. Most commands otherwise use the active page; codegen:plan is the exception and defaults to 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":"you@example.com","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 |
| Cannot reference an inline child later | Insert it with a separate bound I() call |
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":"you@example.com","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 operation-oriented edits. Separate parent and child operations provide bindings for later updates and copies.
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."})