@clawhub-vxcent-98a8019565
Virse AI Design Platform — AI image generation, canvas layout, workspace management, and asset organization. Use this skill whenever the user mentions Virse,...
--- name: virse description: "Virse AI Design Platform — AI image generation, canvas layout, workspace management, and asset organization. Use this skill whenever the user mentions Virse, wants to generate AI images on a canvas, manage design workspaces, search or organize image assets, arrange elements on a canvas, trace creative workflows, or do any visual design task involving canvases and AI generation — even if they don't say 'Virse' explicitly." user-invocable: true allowed-tools: Bash, Read commit_hash: e98ca87 --- # Virse Skill You are an assistant for the **Virse AI Design Platform**. You help users manage workspaces, canvases, generate AI images, organize assets, and build creative workflows. ## Setup All commands in this skill use **`virse_call`** as shorthand, which expands to: ```bash python3 SKILL_DIR/scripts/virse_call.py ... ``` Tool call pattern: ```bash virse_call call <tool_name> '<json_args>' ``` Batch mode (reuses a single MCP session, auto-throttles 0.5s between calls): ```bash virse_call batch '[{"name":"<tool1>","args":{...}},{"name":"<tool2>","args":{...}}]' ``` Full 25-tool reference: Read `SKILL_DIR/tools-reference.md` ## Authentication On first invocation, verify auth: `virse_call call get_account '{}'` - **Success** → Display username, email, CU balance. Continue with user's request. - **HTTP 401 / error** → Read `SKILL_DIR/auth-guide.md` and follow the Automatic Login Flow. Key point: after running `virse_call login`, you **must stop and show the verification URL to the user**, then wait for them to complete browser login before running `virse_call login-poll`. > **Quick auth**: `virse_call save-key virse_sk_YOUR_KEY` or `export VIRSE_API_KEY=virse_sk_YOUR_KEY` ## No Arguments (`/virse`) Call `get_account` and display: ``` Logged in as: <name> (<email>) Organization: <org_name> (<org_type>) Balance: <balance> CU [Team member budget: <credit_used> / <credit_limit> CU] ← only for team orgs ``` ## Routing ### Simple queries → Direct tool call | User intent | Tool | |-------------|------| | "show my workspaces" | `list_workspaces` | | "what's on my canvas" | `get_canvas` | | "check my balance" | `get_account` | | "search for sunset photos" | `search_images` | | "what models are available" | `list_image_models` | | "show my asset folders" | `list_asset_folders` | | "details of element abc1" | `get_element` | | "trace connections from abc1" | `trace_connections` | | "show generation details", "what prompt was used" | `get_asset_detail` (pass `artifact_version_id`) | | "show full text of a note", "read text node content" | `get_asset_detail` (pass `asset_id`) | | "create a new workspace" | `create_workspace` | | "generate an image of X" | `generate_image` (single image) | | "add this image to folder Y" | `add_image_to_asset_folder` | | "upload this image" | `upload_image` | ### Complex tasks → Read the matching playbook Each playbook is in `SKILL_DIR/playbooks/`. Read only the one you need: | User intent keywords | Playbook file | |----------------------|---------------| | "generate N images", "batch", "create a set" | `playbooks/batch-generate.md` | | "workspace overview", "summarize canvas" | `playbooks/workspace-summary.md` | | "find references", "moodboard" | `playbooks/reference-board.md` | | "clean up canvas", "organize", "find orphans" | `playbooks/canvas-cleanup.md` | | "compare models", "try variations" | `playbooks/variation-explorer.md` | | "collect from all workspaces", "consolidate" | `playbooks/cross-workspace-collect.md` | | "organize into folders", "curate assets" | `playbooks/asset-curator.md` | | "refine this prompt", "iterate on concept" | `playbooks/prompt-refiner.md` | | "trace history", "show lineage", "how was this created" | `playbooks/workflow-tracer.md` | | "分析图结构", "工作流结构", "根节点", "画布拓扑", "graph structure" | `playbooks/graph-analysis.md` | ## Workflow Examples Proven workflow methodologies from real production use. Read the relevant example when tackling a similar multi-stage task. | Scenario | Example file | |----------|-------------| | Replicate an existing product's image pipeline for a new product (background → product swap → text overlay → final composite) | `examples/product-listing-pipeline.md` | ## Domain Knowledge ### Creative Director — Model Selection & Prompt Craft **Model selection guide:** - **Fast & cheap default** → `nano-banana-2` (Nano2) - **High quality** → `gemini-3-pro-image-preview` (Nano Pro) or `imagen-4.0-ultra-generate-001` - **Text rendering / complex instructions** → `gpt-image-1.5` - **Style transfer / reference-based** → `flux-kontext-pro` or `flux-kontext-max` (pass source `asset_id`) - **Precise resolution control** → `flux-1.1-pro-ultra` Above are common recommendations. More models are available and may be added over time — run `virse_call call list_image_models '{}'` to get the latest full list with supported parameters. **Prompt tips:** - Front-load the main subject - Add style/lighting/composition details: "cinematic lighting", "flat vector illustration", "35mm photography" - Be specific — "golden hour sunset over snow-capped mountains" beats "sunset" - For FLUX Kontext: provide a reference image via `asset_id` and describe the desired transformation or style blend - **Multi-image references**: `asset_id` accepts a single string or an array of strings (max 10). Auto-edges are created from all source elements. **Element sizing for aspect ratio:** When calling `generate_image` with `aspect_ratio`, calculate `size_width` and `size_height` to match (longer side = 512): | aspect_ratio | size_width | size_height | |-------------|-----------|------------| | `1:1` (default) | 512 | 512 | | `16:9` | 512 | 288 | | `9:16` | 288 | 512 | | `4:3` | 512 | 384 | | `3:4` | 384 | 512 | ### Canvas Architect — Layout & Safety **Layout algorithms:** - Grid: `cols = min(N, 4)`, gap 20px - Flow (L→R): stage_gap 300px, item_gap 30px - Radial: radius 400px, angle = 2π * i / N Before placing elements, call `get_canvas` first to avoid overlapping existing content. Confirm with the user before any destructive operation (`delete_element`, `delete_edge`, `delete_group`). Verify with `get_canvas` after bulk operations. #### Node Context Expansion When a user references or you need to inspect an existing node: 1. **Expand the connection graph**: Use `get_element` to see incoming/outgoing neighbors 2. **Read full content**: For text nodes with an `asset_id`, use `get_asset_detail(asset_id=...)` to get the complete text - ⚠️ The `text` field from `get_element` is truncated by the server (~200 chars) - `get_asset_detail` is the only way to read full text content 3. **Trace the derivation chain**: If the node is a derived output (e.g. a "generation spec"), trace upstream through its input nodes to understand *why* it has that content 4. **Extract styling**: If the node is a layout reference, record its fontSize, size, position spacing, etc. #### API Concurrency Control - Create/update operations: max 3 parallel calls; beyond that, execute sequentially with 1s sleep - Batch edge creation: strictly sequential, 1s interval between each - Prefer `virse_call batch` mode (reuses a single MCP session, auto-throttles at 0.5s) - Reason: MCP server has per-key rate limits; each `virse_call call` creates a new session (3 HTTP roundtrips for handshake) ### Asset Librarian — Search Strategies When search results are poor, reformulate: 1. **Broaden** — Remove overly specific terms 2. **Synonymize** — Try alternatives ("logo" → "brand mark") 3. **Decompose** — Search components separately 4. **Abstract** — Move to higher-level concepts Asset folder links are zero-copy: `add_image_to_asset_folder` links by asset ID — no duplication. Same image can be in multiple folders. Removing from folder doesn't delete the image. ### Workspace Manager — Key Relationships - Each **workspace** (Space) has one **canvas** (Project) - `list_workspaces` returns both `space_id` and `canvas_id` - `canvas_id` must be passed explicitly to every canvas tool call — there is no implicit context - `canvas_id` is required for: `get_canvas`, `get_element`, `trace_connections`, `create_element`, `update_element`, `delete_element`, `create_edge`, `delete_edge`, `create_group`, `delete_group`, `generate_image` ## Content Derivation Workflow Use when: the canvas already has a complete derivation chain for Product A (positioning → methodology → generation spec → images), and you need to create the same structure for Product B. 1. **Read the reference chain**: Pick an output node from the reference product (e.g. a generation spec), use `get_element` to trace all incoming nodes 2. **Extract full content**: Call `get_asset_detail` on every text node in the chain to get untruncated content 3. **Identify template vs. variables**: - Template: structural skeleton (allowed/forbidden inputs, stage goals, brand style constraints, negative constraints) - Variables: product-specific content (scenes, selling points, audience, English prompts) 4. **Fill in new product info**: Replace variable sections with the new product's positioning text 5. **Cross-validate**: After completing all modules, check adjacent modules (especially M5/M6/M7/M8) for audience/scene overlap 6. **Connect edges**: Ensure each new node's incoming edges include all actual input sources (product image, positioning, methodology, brand style) ## Post-Batch Creation Checklist After bulk-creating nodes, run through: - [ ] **Style consistency**: Do all sibling nodes share the same fontSize, size, and spacing? - [ ] **Content deduplication**: Do adjacent modules have unreasonable overlap in scenes, audiences, or selling points? - [ ] **Edge completeness**: Is every output node connected to all its actual input sources? - [ ] **Text integrity**: Spot-check 1-2 nodes with `get_asset_detail` to confirm content was not truncated on write ## Reasoning Guide For complex tasks, think through: **Goal → Pre-conditions (auth, balance, canvas_id) → Dependencies (parallel vs sequential calls) → Risk (destructive ops need confirmation) → Verify (get_canvas after completion)**. **Error recovery** — diagnose first, don't retry blindly: - HTTP 401 → login flow - "not found" → verify ID via `get_canvas` / `list_workspaces` - Insufficient balance → inform user - `generate_image` timeout → check later with `get_element` ## Critical Rules 1. **`canvas_id` and `space_id` are mandatory** — Get them from `list_workspaces` first. Never guess. 2. **Match element size to aspect ratio** — When using `aspect_ratio` in `generate_image`, pass matching `size_width` / `size_height`. See the sizing table above. 3. **Confirm destructive operations** — `delete_element`, `delete_edge`, `delete_group`, `remove_image_from_asset_folder` all require user confirmation. 4. **Check balance before batch ops** — Call `get_account` before generating 3+ images. 5. **Claude cannot see generated images** — Refinement is based on prompt expertise + user feedback. Always ask for feedback between generation rounds. 6. **Think before acting** — For multi-step operations, outline your plan to the user first. This prevents wasted CU and destructive mistakes. 7. **One canvas at a time** — Don't operate across multiple canvases simultaneously unless a cross-workspace playbook is in use. ## Update Check (on first load) Silently check for updates on first load: ```bash python3 SKILL_DIR/scripts/check_update.py --skill-dir SKILL_DIR ``` - **`update_available|{hash}`** → Tell the user: "A newer version of the Virse skill is available. Would you like me to update?" - **`up_to_date` or `check_failed`** → Proceed silently. If user agrees to update and `SKILL_DIR` is a git repo: ```bash git -C SKILL_DIR pull origin main sed -i "s/^commit_hash: .*/commit_hash: $(git -C SKILL_DIR rev-parse --short=7 HEAD)/" SKILL_DIR/SKILL.md ``` If not a git repo, tell the user to re-clone or download the latest version from the repository. Never auto-update without user consent. FILE:playbooks/workspace-summary.md # Workspace Summary Get a full overview of workspace contents. **Trigger keywords:** "what's in this workspace", "workspace overview", "summarize canvas" ## Steps 1. **List workspaces** — `list_workspaces` → user selects one, save `canvas_id` 2. **Get canvas** — `get_canvas(canvas_id=..., include_group_detail=true)` 3. **Analyze** — Count elements by type (image/text), edges, groups 4. **Drill down** (optional) — `get_element` for specific items, `trace_connections(direction="both", depth=2)` for workflow chains 5. **Report** — Element stats, group details, workflow chains ## Output ``` Workspace: <name> Images: X | Text: Y | Groups: Z | Edges: M Group details: ... Workflow chains: ... ``` FILE:playbooks/prompt-refiner.md # Prompt Refiner Iteratively refine a prompt through multiple generation rounds with a visual timeline. **Trigger keywords:** "refine this prompt", "iterate", "keep tweaking", "improve step by step" ## Steps 1. **Check balance** — `get_account` → plan for 3-5 rounds 2. **Choose model** — `list_image_models` → use user-specified or recommend one 3. **Calculate element size** — Based on the chosen `aspect_ratio`, compute `size_w` and `size_h` (longer side = 512, default 512×512 for 1:1). 4. **Round 1** — Generate with initial prompt: ``` generate_image(prompt=user_prompt, model=..., position_x=0, position_y=0, size_width=size_w, size_height=size_h, aspect_ratio=...) create_element(element_type="text", text="Round 1: <prompt>", position_x=0, position_y=-60) create_edge(source=label_id, target=image_id) ``` 5. **Refine** — Apply prompt engineering. Each round, vary **ONE dimension** (style / lighting / composition / detail) — avoid contradictory changes. **Ask user for feedback between rounds** — Claude cannot see generated images. After 3 rounds without convergence, suggest trying a different model. 6. **Rounds 2-N** — Place each round to the right: ``` round_x = (round - 1) * (size_w + 60) generate_image(prompt=refined_prompt, position_x=round_x, position_y=0, size_width=size_w, size_height=size_h, aspect_ratio=...) create_edge(source=prev_image_id, target=new_image_id) # evolution chain ``` Pause after each round for user feedback. 7. **Group** — `create_group(element_ids=[all images + labels])` ## Output Timeline showing prompt evolution across rounds. Best prompt version highlighted. FILE:playbooks/variation-explorer.md # Variation Explorer Compare different models or styles on the same prompt. **Trigger keywords:** "try different models", "compare", "generate variations", "show me what other models produce" ## Steps 1. **Get original** — `get_element(canvas_id=..., id=...)` → note `artifact_version_id` 2. **Get prompt** — `get_asset_detail(artifact_version_id=...)` → extract original prompt, model, aspect_ratio 3. **Select models** — `list_image_models` → pick 3-4 diverse models (excluding original) 4. **Check balance** — `get_account` 5. **Check canvas** — `get_canvas(canvas_id=...)` → verify there is enough space to the right of the original; if not, pick an open area 6. **Determine element size** — Use the original element's `size_width` / `size_height` from `get_element` to match the original dimensions. If the original has a non-1:1 aspect ratio, use those dimensions for variations too. 7. **Generate row** — Place variations in a horizontal row to the right of the original: ``` size_w = original_size_width # from get_element size_h = original_size_height # from get_element gap = 40 for i, model in enumerate(models): x = original_x + (i + 1) * (size_w + gap) y = original_y generate_image(prompt=original_prompt, model=model, size_width=size_w, size_height=size_h, aspect_ratio=original_aspect_ratio, ...) ``` 8. **Add labels** — `create_element(element_type="text", text=model_name, ...)` below each image 9. **Connect** — `create_edge(source=original_id, target=variation_id)` for each 10. **Group** — `create_group(element_ids=[original, ...variations, ...labels])` ## Output Original info + list of variations with model names and element IDs. FILE:playbooks/cross-workspace-collect.md # Cross-Workspace Collect Gather images from multiple workspaces into one place. **Trigger keywords:** "collect from all workspaces", "consolidate", "gather images matching X" ## Steps 1. **List workspaces** — `list_workspaces` → Show user the full scan scope (workspace names and count). **Confirm before proceeding** with the scan. 2. **Scan each** — For each workspace: - `get_canvas(canvas_id=...)` → get all elements - For image elements matching criteria: `get_asset_detail(artifact_version_id=...)` → check model, prompt keywords - Collect matching `asset_id`s with metadata 3. **Report** — Show matches per workspace, ask where to collect 4. **Collect to asset folder** (preferred, zero-copy): - `list_asset_folders` → check existing - `create_asset_folder(name=...)` if needed - `add_image_to_asset_folder(asset_id=..., folder_id=...)` for each match 5. **Or collect to canvas** (alternative): - `upload_image(image_url=..., canvas_id=..., position_x=x, position_y=y)` in grid ## Output Report: N images found across M workspaces, N collected, N skipped (with reasons for skipping). FILE:playbooks/canvas-cleanup.md # Canvas Cleanup Audit and organize a messy canvas. **Trigger keywords:** "clean up", "organize", "find orphans", "tidy up canvas" ## Steps 1. **Get state** — `get_canvas(canvas_id=..., include_group_detail=true)` 2. **Analyze** — Identify: - Disconnected elements (no edges) - Spatial clusters (close together but ungrouped) - Overlapping elements (similar position ranges) - Empty/single-member groups - Outlier elements far from main content 3. **Report & ask** — Present audit findings and suggested actions to the user. **Get explicit confirmation before making any changes.** Never delete without confirmation. 4. **Apply** — Based on user choices: - Re-layout: `update_element` to reposition into clean grid - Group clusters: `create_group(element_ids=[...])` - Remove orphans: `delete_element` (only after user confirms) 5. **Verify** — Run `get_canvas` to verify final state. Report element count before and after cleanup. ## Output Before/after comparison: elements, edges, groups counts (before → after). FILE:playbooks/asset-curator.md # Asset Curator Organize canvas images into persistent asset folder collections. **Trigger keywords:** "save to folders", "organize into collections", "sort by model/style", "curate" ## Steps 1. **Get canvas** — `get_canvas(canvas_id=..., include_group_detail=true)` 2. **Inspect images** — For each image element: - `get_element(id=...)` → get `asset_id`, `artifact_version_id` - `get_asset_detail(artifact_version_id=...)` → get model, prompt, params 3. **Categorize** — Propose categories based on: - Model used (e.g. "Flux Generations", "GPT-Image Outputs") - Prompt themes (e.g. "Landscapes", "Portraits") - Existing canvas groups - User-specified criteria 4. **Confirm with user** — Present proposed categorization 5. **Create folders** — `list_asset_folders` → reuse existing, `create_asset_folder` for new ones 6. **Link images** — `add_image_to_asset_folder(asset_id=..., folder_id=...)` — zero-copy linking ## Output Summary: N images categorized into M folders (new and existing). FILE:playbooks/batch-generate.md # Batch Generate Generate multiple images and arrange them on a canvas. **Trigger keywords:** "generate N images", "batch generate", "create a set of", "lay them out" ## Steps 1. **Get workspace** — `list_workspaces` → save `canvas_id` and `space_id` 2. **Choose model** — `list_image_models` → let user pick or use default 3. **Check balance** — `get_account` → verify enough credits for N generations 4. **Check canvas** — `get_canvas(canvas_id=...)` → find an empty area to avoid overlapping existing elements 5. **Calculate element size** — Based on the chosen `aspect_ratio`, compute `size_w` and `size_h` (longer side = 512): | aspect_ratio | size_w | size_h | |-------------|--------|--------| | 1:1 | 512 | 512 | | 16:9 | 512 | 288 | | 9:16 | 288 | 512 | | 4:3 | 512 | 384 | | 3:4 | 384 | 512 | 6. **Generate in grid** — Call `generate_image` for each image with grid positions offset to the empty area: ``` cols = min(N, 4) gap = 20 for i in range(N): x = start_x + (i % cols) * (size_w + gap) y = start_y + (i // cols) * (size_h + gap) generate_image(prompt=..., model=..., space_id=..., canvas_id=..., position_x=x, position_y=y, size_width=size_w, size_height=size_h, aspect_ratio=...) ``` Adjust prompts per iteration if user wants different styles. 7. **Group results** — `create_group(element_ids=[...])` to bundle all generated images ## Output Report: N images generated, layout dimensions, model used, prompt(s). FILE:playbooks/workflow-tracer.md # Workflow Tracer Trace the creation history and lineage of an element. **Trigger keywords:** "show history", "trace workflow", "how was this created", "lineage" ## Steps 1. **Get workspace** — `list_workspaces` → save `canvas_id` 2. **Get target** — `get_element(canvas_id=..., id=...)` → note `artifact_version_id` 3. **Walk upstream** — `trace_connections(canvas_id=..., id=..., direction="upstream", depth=5)` 4. **Inspect each node** — For each node with `artifact_version_id`: - `get_asset_detail(artifact_version_id=...)` → get prompt, model, timestamp For text nodes with `asset_id`: - `get_asset_detail(asset_id=...)` → get text content 5. **Build lineage** — Assemble chain from earliest ancestor to target: ``` [1] <short_id> (image) — Model: flux-1.1-pro, Prompt: "..." ↓ [2] <short_id> (image) — Model: gpt-image-1, Prompt: "..." ↓ [3] <short_id> (image) ← TARGET ``` ## Output Structured lineage document: each step with model, prompt, and parameters. Total depth and models used. FILE:playbooks/reference-board.md # Reference Board Search for reference images and build a mood/reference board on canvas. **Trigger keywords:** "find references", "build a moodboard", "search for ... and organize" ## Steps 1. **Workspace** — `list_workspaces` → pick or create one with `create_workspace` 2. **Search** — `search_images(query=...)` — split broad queries into multiple searches and merge results 3. **Check canvas** — `get_canvas(canvas_id=...)` → find an empty area to avoid overlapping existing elements; use it as `start_x, start_y` 4. **Add title** — `create_element(element_type="text", text="Reference Board: <keyword>", position_x=start_x, position_y=start_y, fontSize=32)` 5. **Upload in grid** — For top N results (default 9). Use `size_w=512, size_h=512` by default, or adjust if a specific aspect ratio is desired: ``` cols = 3, gap = 20 size_w = 512, size_h = 512 for i, img in enumerate(results[:9]): x = start_x + (i % cols) * (size_w + gap) y = start_y + 150 + (i // cols) * (size_h + gap) upload_image(image_url=img.url, space_id=..., canvas_id=..., position_x=x, position_y=y, size_width=size_w, size_height=size_h) ``` 6. **Connect** — `create_edge` from title to first image 7. **Group** — `create_group(element_ids=[title_id, ...image_ids])` ## Output Report: keyword, images found, images placed, grid layout. FILE:playbooks/graph-analysis.md # Graph Analysis Analyze a canvas as a directed graph — find roots, hubs, chains, and isolated nodes. **Trigger keywords:** "分析图结构", "graph structure", "工作流结构", "workflow structure", "根节点", "root nodes", "画布拓扑", "canvas topology" ## Steps 1. **Get workspace** — `list_workspaces` → save `canvas_id` and `space_id` for the target workspace 2. **Run graph analysis** — Pipe `get_canvas` output to the analysis script: ```bash python3 SKILL_DIR/scripts/virse_call.py call get_canvas '{"canvas_id":"CANVAS_ID"}' | python3 SKILL_DIR/scripts/graph_analysis.py ``` For machine-readable output, add `--format json`. 3. **Present summary** — Show the user: - Total nodes, edges, connected components - Root nodes (workflow entry points): count and list - Hub nodes (highly connected, degree >= 4): count and list - Sink nodes (terminal outputs): count - Isolated nodes (no connections): count - Chain depths from each root 4. **Optional deep-dive** — If the user wants to explore specific nodes: - Use `get_element` to inspect a root or hub node - Use `get_asset_detail` to see generation details (prompt, model) - Use `trace_connections` to explore a specific chain in detail 5. **Cleanup suggestion** — If there are many isolated nodes, suggest running the `canvas-cleanup.md` playbook to audit and organize them. ## Output Graph analysis summary: node/edge counts, classified node lists, chain traces from roots, component breakdown, and isolated node report. FILE:examples/product-listing-pipeline.md # Product Listing Image Pipeline Replicate an existing canvas workflow chain for a new product. Applicable to any multi-stage image generation pipeline — not limited to specific module counts or product types. ## When to use The canvas has a complete image pipeline for one product (from text specs to final composites), and you need to produce the same structure for a different product. ## Core Method: Clone by Reference The fundamental approach is **"read one, replicate many"** — reverse-engineer one complete reference chain, extract the reusable pattern, then re-execute it with new inputs. ### Phase 1: Reverse-Engineer the Reference Pick **one complete chain** from the reference product and trace every node from start to finish. For each node, record: - **Generation params**: model, aspect_ratio, resolution, prompt structure (via `get_asset_detail(artifact_version_id=...)`) - **Layout geometry**: position, size, gap to next stage (compute offsets between stages) - **Edge topology**: which upstream nodes feed into it (methodology? positioning? product photo? prior stage output?) - **Text style** (for text nodes): fontSize, textAlign, verticalAlign Key rule: `get_element` truncates text at ~200 chars. Always use `get_asset_detail(asset_id=...)` to read full content. The goal is to extract a **stage template** — a repeatable recipe for each step in the chain. ### Phase 2: Identify Template vs. Variable Separate what stays the same from what changes per product: | Template (reuse as-is) | Variable (adapt per product) | |------------------------|------------------------------| | Pipeline stage sequence | Product photo asset_id | | Generation model & params | Product-specific prompt content | | Canvas layout offsets & sizing | Headlines, copy, selling points | | Edge topology pattern | Scene descriptions, feature callouts | | Typography style rules | Product name, certifications, specs | ### Phase 3: Execute Stage by Stage Work through the pipeline left-to-right. Each stage depends on the previous stage's output, so they must be sequential. Within a stage, parallel execution across modules is fine (max 3 concurrent). **After each stage completes:** 1. Verify outputs exist (`get_element` to check asset_id is populated) 2. Create edges to maintain the same topology as reference 3. Collect asset_ids needed for the next stage --- ## Stage Recipes ### Background Generation - Input: text spec node containing an English prompt - Action: `generate_image` with prompt extracted from spec - Key params: match reference (model, aspect_ratio, resolution, size) - Position: compute from spec position + reference offset - Edge: spec → background ### Product Swap (Multi-Image Reference) - Input: background image + product photo - Action: `generate_image` with `asset_id` as **array** `[background_asset, product_photo_asset]` - Prompt pattern: "Edit the existing image and replace only the [product type] with the exact [product type] shown in the product reference photo. Preserve the original composition, lighting, and all non-[product] details." - The model sees both images — no need to describe the product in text - Auto-edges created from both sources ### Text Overlay Plan - Input: product positioning + methodology + product photo + swap image - Action: `create_element` (text node) with layout/copy instructions - Content derivation: - Read the product positioning fully (`get_asset_detail`) - Map positioning fields to copy: key message → headline, selling points → feature callouts, usage scenarios → scene labels, demographics → audience tags - Adapt layout instructions based on the image composition (which areas have negative space) - Edges: 1 per upstream source (typically 4: methodology, positioning, product photo, swap image) ### Final Composite - Input: swap image + overlay plan text - Action: `generate_image` with swap image as `asset_id`, prompt derived from overlay plan - Prompt construction: translate the overlay plan's layout instructions + exact copy into a single edit prompt - Edges: auto-edge from asset_id + manual edge from overlay plan **Prompt lessons learned:** - For multi-panel images with bottom text bars: keep instructions simple — "semi-transparent dark bars at the bottom of each panel" works. Over-specifying (opacity %, height %, "flush", "pinned") causes unreliable results. - For single-image overlays: specify placement zone ("right side", "top left") and list exact text lines. - Always end with: "English only. No extra text. No logo. No other design changes." --- ## Operational Rules 1. **Read before write** — always read the full reference chain before creating anything 2. **Full text only** — use `get_asset_detail` for text content, never rely on `get_element` truncated text 3. **Consistent model** — use the same model for all modules within a stage 4. **Concurrency cap** — max 3 parallel `generate_image` calls; prefer `virse_call batch` for edge creation 5. **Stage gating** — complete one stage fully before starting the next; collect all asset_ids between stages 6. **Edge completeness** — replicate the exact edge topology from the reference; every output node should have the same number and type of incoming edges ## Common Pitfalls | Symptom | Root Cause | Fix | |---------|-----------|-----| | Product swap shows wrong/generic product | Only one image passed as asset_id | Use `asset_id` array: `[scene, product_photo]` | | Text content incomplete or garbled | Read from `get_element` truncated field | Use `get_asset_detail(asset_id=...)` | | Overlay copy doesn't fit the new product | Copied reference copy without adapting | Read full positioning text; derive copy from its selling points and key message | | Generated text overlay floats or mispositions | Over-constrained placement in prompt | Simplify — match the sentence structure of a known-good prompt | | Rate limit / batch failures | Too many parallel calls | Max 3 parallel, or use batch mode | | Visual style inconsistent across modules | Mixed models between modules | Pick one model per stage, apply to all modules | FILE:scripts/check_update.py #!/usr/bin/env python3 """Check if a newer version of the skill is available. Works in two modes: 1. Git repo: compare local HEAD with remote via git fetch / ls-remote 2. Non-git install: read commit_hash from SKILL.md and compare with GitHub API Usage: python3 check_update.py --skill-dir /path/to/skill """ import argparse import json import os import re import sys import subprocess import urllib.request import urllib.error GITHUB_REPO = "Atlas-Of-Imagination/virse-skill" GITHUB_BRANCH = "main" def read_local_hash_from_skill_md(skill_dir: str) -> str | None: """Read commit_hash from SKILL.md frontmatter.""" skill_md = os.path.join(skill_dir, "SKILL.md") if not os.path.isfile(skill_md): return None try: with open(skill_md, "r") as f: content = f.read(2048) # frontmatter is near the top m = re.search(r"^commit_hash:\s*(\S+)", content, re.MULTILINE) if m: h = m.group(1) # Ignore placeholder values if h in ("old", "unknown", "none", ""): return None return h return None except Exception: return None def get_remote_hash_github() -> str | None: """Get latest commit hash from GitHub API (no git required).""" url = f"https://api.github.com/repos/{GITHUB_REPO}/commits/{GITHUB_BRANCH}" req = urllib.request.Request(url, headers={ "Accept": "application/vnd.github.v3+json", "User-Agent": "virse-skill-updater", }) try: with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read()) return data.get("sha") except (urllib.error.URLError, json.JSONDecodeError, KeyError, OSError): return None def get_remote_hash_git(skill_dir: str) -> str | None: """Get latest remote hash via git (fetch then rev-parse, fallback to ls-remote).""" # Try fetch + rev-parse try: fetch = subprocess.run( ["git", "-C", skill_dir, "fetch", "origin", GITHUB_BRANCH, "--quiet"], capture_output=True, text=True, timeout=30, ) if fetch.returncode == 0: rev = subprocess.run( ["git", "-C", skill_dir, "rev-parse", f"origin/{GITHUB_BRANCH}"], capture_output=True, text=True, timeout=5, ) if rev.returncode == 0: return rev.stdout.strip() except (subprocess.TimeoutExpired, OSError): pass # Fallback: ls-remote try: ls = subprocess.run( ["git", "-C", skill_dir, "ls-remote", "origin", f"refs/heads/{GITHUB_BRANCH}"], capture_output=True, text=True, timeout=15, ) if ls.returncode == 0 and ls.stdout.strip(): return ls.stdout.strip().split()[0] except (subprocess.TimeoutExpired, OSError): pass return None def get_local_hash_git(skill_dir: str) -> str | None: """Get local HEAD hash via git.""" try: result = subprocess.run( ["git", "-C", skill_dir, "rev-parse", "HEAD"], capture_output=True, text=True, timeout=5, ) if result.returncode == 0: return result.stdout.strip() except (subprocess.TimeoutExpired, OSError): pass return None def main(): parser = argparse.ArgumentParser(description="Check for skill updates") parser.add_argument("--skill-dir", required=True, help="Path to the skill directory") args = parser.parse_args() skill_dir = args.skill_dir if not skill_dir or not os.path.isdir(skill_dir): print("check_failed") print(f"[update-check] skill-dir does not exist: {skill_dir!r}", file=sys.stderr) return is_git_repo = os.path.isdir(os.path.join(skill_dir, ".git")) try: # --- Determine local version --- local_hash = None if is_git_repo: local_hash = get_local_hash_git(skill_dir) if local_hash is None: # Non-git install or git failed: read from SKILL.md frontmatter local_hash = read_local_hash_from_skill_md(skill_dir) if local_hash is None: print("check_failed") print("[update-check] cannot determine local version (no .git and no commit_hash in SKILL.md)", file=sys.stderr) return # --- Determine remote version --- remote_hash = None if is_git_repo: remote_hash = get_remote_hash_git(skill_dir) if remote_hash is None: # Fallback to GitHub API (works without git) remote_hash = get_remote_hash_github() if remote_hash is None: print("check_failed") print("[update-check] cannot reach remote (both git and GitHub API failed)", file=sys.stderr) return # --- Compare --- # Normalize: compare short hashes if local is short (from SKILL.md) if len(local_hash) < 12: # local_hash is short, compare prefixes match = remote_hash[:len(local_hash)] == local_hash else: match = remote_hash == local_hash if not match: short_remote = remote_hash[:7] print(f"update_available|{short_remote}") else: print("up_to_date") except Exception as e: print("check_failed") print(f"[update-check] unexpected error: {e}", file=sys.stderr) if __name__ == "__main__": main() FILE:scripts/graph_analysis.py #!/usr/bin/env python3 """Analyze a Virse canvas as a directed graph. Reads `get_canvas` output from stdin or file. Zero external dependencies. Usage: virse_call call get_canvas '{"canvas_id":"UUID"}' | python3 graph_analysis.py python3 graph_analysis.py canvas_output.txt python3 graph_analysis.py canvas_output.txt --format json """ import json import re import sys from collections import deque def parse_canvas(text): """Parse get_canvas output into nodes and edges. Each element line looks like: [short_id] type (x,y) WxH "label..." -> target1, target2 or without edges: [short_id] type (x,y) WxH "label..." or without label: [short_id] type (x,y) WxH -> target1 [short_id] type (x,y) WxH """ nodes = {} # id -> {type, x, y, w, h, label} out_edges = {} # id -> [target_ids] in_edges = {} # id -> [source_ids] # Pattern: [id] type (x,y) WxH optionally "label" optionally -> targets element_re = re.compile( r'^\s*\[([0-9a-f]+)\]\s+' # [short_id] r'(\w+)\s+' # type r'\((-?[\d]+),(-?[\d]+)\)\s+' # (x,y) r'([\d]+)x([\d]+)' # WxH r'(?:\s+"((?:[^"\\]|\\.)*)")?' # optional "label" r'(?:\s+->\s+(.+))?$' # optional -> targets ) for line in text.splitlines(): m = element_re.match(line) if not m: continue nid = m.group(1) ntype = m.group(2) x, y = int(m.group(3)), int(m.group(4)) w, h = int(m.group(5)), int(m.group(6)) label = m.group(7) or "" targets_str = m.group(8) or "" nodes[nid] = { "type": ntype, "x": x, "y": y, "w": w, "h": h, "label": label[:80], # truncate for readability } targets = [t.strip() for t in targets_str.split(",") if t.strip()] if targets_str else [] out_edges[nid] = targets if nid not in in_edges: in_edges[nid] = [] for t in targets: if t not in in_edges: in_edges[t] = [] in_edges[t].append(nid) return nodes, out_edges, in_edges def classify_nodes(nodes, out_edges, in_edges): """Classify nodes into roots, sinks, hubs, and isolated.""" roots = [] sinks = [] hubs = [] isolated = [] for nid in nodes: out_deg = len(out_edges.get(nid, [])) in_deg = len(in_edges.get(nid, [])) total = out_deg + in_deg if in_deg == 0 and out_deg > 0: roots.append(nid) elif out_deg == 0 and in_deg > 0: sinks.append(nid) if total >= 4: hubs.append(nid) if total == 0: isolated.append(nid) return roots, sinks, hubs, isolated def bfs_chain(start, out_edges, nodes): """BFS from start node, return ordered chain of (id, depth).""" visited = set() queue = deque([(start, 0)]) chain = [] while queue: nid, depth = queue.popleft() if nid in visited: continue visited.add(nid) chain.append((nid, depth)) for target in out_edges.get(nid, []): if target not in visited and target in nodes: queue.append((target, depth + 1)) return chain def find_components(nodes, out_edges, in_edges): """Find connected components treating edges as undirected.""" visited = set() components = [] adj = {} for nid in nodes: adj[nid] = set() for nid, targets in out_edges.items(): for t in targets: if nid in nodes and t in nodes: adj[nid].add(t) adj[t].add(nid) for nid in nodes: if nid in visited: continue component = [] queue = deque([nid]) while queue: curr = queue.popleft() if curr in visited: continue visited.add(curr) component.append(curr) for neighbor in adj.get(curr, []): if neighbor not in visited: queue.append(neighbor) components.append(component) components.sort(key=len, reverse=True) return components def node_summary(nid, nodes, in_edges, out_edges): """One-line summary of a node.""" n = nodes.get(nid) if not n: return f"[{nid}] (not in canvas)" in_deg = len(in_edges.get(nid, [])) out_deg = len(out_edges.get(nid, [])) label = f' "{n["label"]}"' if n["label"] else "" return f'[{nid}] {n["type"]} in={in_deg} out={out_deg}{label}' def format_text(nodes, out_edges, in_edges, roots, sinks, hubs, isolated, components): """Human-readable text output.""" lines = [] total_edges = sum(len(v) for v in out_edges.values()) lines.append("=== Canvas Graph Analysis ===") lines.append(f"Nodes: {len(nodes)} Edges: {total_edges} Components: {len(components)}") lines.append(f"Roots: {len(roots)} Sinks: {len(sinks)} Hubs: {len(hubs)} Isolated: {len(isolated)}") lines.append("") # Roots if roots: lines.append(f"--- Roots ({len(roots)}) — workflow entry points ---") for nid in roots: lines.append(f" {node_summary(nid, nodes, in_edges, out_edges)}") lines.append("") # Hubs if hubs: lines.append(f"--- Hubs ({len(hubs)}) — highly connected (degree >= 4) ---") for nid in hubs: lines.append(f" {node_summary(nid, nodes, in_edges, out_edges)}") lines.append("") # Sinks if sinks: lines.append(f"--- Sinks ({len(sinks)}) — terminal nodes ---") for nid in sinks: lines.append(f" {node_summary(nid, nodes, in_edges, out_edges)}") lines.append("") # Chains from roots if roots: lines.append(f"--- Chains from roots ---") for root_id in roots: chain = bfs_chain(root_id, out_edges, nodes) max_depth = max(d for _, d in chain) if chain else 0 lines.append(f"\nRoot [{root_id}] → depth {max_depth}, {len(chain)} nodes:") for nid, depth in chain: indent = " " + " " * depth n = nodes.get(nid, {}) label = f' "{n.get("label", "")}"' if n.get("label") else "" targets = out_edges.get(nid, []) arrow = f" -> {', '.join(targets)}" if targets else "" lines.append(f'{indent}[{nid}] {n.get("type", "?")}{label}{arrow}') lines.append("") # Components if len(components) > 1: lines.append(f"--- Connected components ({len(components)}) ---") for i, comp in enumerate(components): types = {} for nid in comp: t = nodes.get(nid, {}).get("type", "?") types[t] = types.get(t, 0) + 1 type_str = ", ".join(f"{v} {k}" for k, v in sorted(types.items(), key=lambda x: -x[1])) lines.append(f" Component {i+1}: {len(comp)} nodes ({type_str})") lines.append("") # Isolated if isolated: lines.append(f"--- Isolated ({len(isolated)}) — no connections ---") if len(isolated) <= 20: for nid in isolated: lines.append(f" {node_summary(nid, nodes, in_edges, out_edges)}") else: types = {} for nid in isolated: t = nodes.get(nid, {}).get("type", "?") types[t] = types.get(t, 0) + 1 type_str = ", ".join(f"{v} {k}" for k, v in sorted(types.items(), key=lambda x: -x[1])) lines.append(f" ({type_str})") lines.append(f" First 10:") for nid in isolated[:10]: lines.append(f" {node_summary(nid, nodes, in_edges, out_edges)}") return "\n".join(lines) def format_json(nodes, out_edges, in_edges, roots, sinks, hubs, isolated, components): """JSON output.""" total_edges = sum(len(v) for v in out_edges.values()) def node_info(nid): n = nodes.get(nid, {}) return { "id": nid, "type": n.get("type", ""), "label": n.get("label", ""), "in_degree": len(in_edges.get(nid, [])), "out_degree": len(out_edges.get(nid, [])), "position": {"x": n.get("x", 0), "y": n.get("y", 0)}, "size": {"w": n.get("w", 0), "h": n.get("h", 0)}, } chains = [] for root_id in roots: chain = bfs_chain(root_id, out_edges, nodes) max_depth = max(d for _, d in chain) if chain else 0 chains.append({ "root": root_id, "depth": max_depth, "node_count": len(chain), "nodes": [{"id": nid, "depth": d} for nid, d in chain], }) result = { "summary": { "total_nodes": len(nodes), "total_edges": total_edges, "components": len(components), "roots": len(roots), "sinks": len(sinks), "hubs": len(hubs), "isolated": len(isolated), }, "roots": [node_info(nid) for nid in roots], "hubs": [node_info(nid) for nid in hubs], "sinks": [node_info(nid) for nid in sinks], "chains": chains, "components": [ {"size": len(comp), "node_ids": comp} for comp in components ], "isolated": [node_info(nid) for nid in isolated], } return json.dumps(result, indent=2, ensure_ascii=False) def main(): # Parse args fmt = "text" input_file = None args = sys.argv[1:] i = 0 while i < len(args): if args[i] == "--format" and i + 1 < len(args): fmt = args[i + 1] i += 2 elif not args[i].startswith("-"): input_file = args[i] i += 1 else: print(f"Unknown option: {args[i]}", file=sys.stderr) sys.exit(1) # Read input if input_file: with open(input_file, "r") as f: text = f.read() else: if sys.stdin.isatty(): print("Reading from stdin (pipe get_canvas output or pass a file path)...", file=sys.stderr) text = sys.stdin.read() # Parse and analyze nodes, out_edges, in_edges = parse_canvas(text) if not nodes: print("No elements found in input.", file=sys.stderr) sys.exit(1) roots, sinks, hubs, isolated = classify_nodes(nodes, out_edges, in_edges) components = find_components(nodes, out_edges, in_edges) # Output if fmt == "json": print(format_json(nodes, out_edges, in_edges, roots, sinks, hubs, isolated, components)) else: print(format_text(nodes, out_edges, in_edges, roots, sinks, hubs, isolated, components)) if __name__ == "__main__": main() FILE:scripts/virse_call.py #!/usr/bin/env python3 """Lightweight MCP client for Virse — zero external dependencies. Usage: python3 virse_call.py call <tool_name> '<json_args>' python3 virse_call.py batch '<json_array_of_calls>' python3 virse_call.py list-tools python3 virse_call.py save-key <api_key> python3 virse_call.py login python3 virse_call.py login-poll <device_code> """ import json import os import sys import time import urllib.error import urllib.parse import urllib.request DEFAULT_BASE_URL = "https://dev.virse.ai" TOKEN_PATH = os.path.expanduser("~/.virse/token") PROTOCOL_VERSION = "2025-03-26" def _read_token(): """Read token: VIRSE_API_KEY env > ~/.virse/token file.""" token = os.environ.get("VIRSE_API_KEY", "").strip() if token: return token try: with open(TOKEN_PATH, "r") as f: return f.read().strip() except FileNotFoundError: return "" def _base_url(): return os.environ.get("VIRSE_BASE_URL", DEFAULT_BASE_URL).rstrip("/") def _extract_error_message(error_value): """Safely extract message from a JSON-RPC error (dict or string).""" if isinstance(error_value, dict): return error_value.get("message", json.dumps(error_value)) return str(error_value) def _parse_sse(raw): """Parse SSE text and return the last JSON-RPC message found.""" result = {} for line in raw.splitlines(): if line.startswith("data:"): data_str = line[len("data:"):].strip() if data_str: try: result = json.loads(data_str) except (json.JSONDecodeError, ValueError): pass return result class MCPError(Exception): """Raised when an HTTP request to the MCP server fails.""" pass def _http_post(url, headers, body): """POST JSON, return (response_headers, parsed_json). Raises MCPError on failure.""" data = json.dumps(body).encode("utf-8") req = urllib.request.Request(url, data=data, headers=headers, method="POST") try: resp = urllib.request.urlopen(req, timeout=60) except urllib.error.HTTPError as e: err_body = e.read().decode("utf-8", errors="replace") raise MCPError(f"HTTP {e.code}: {err_body}") except urllib.error.URLError as e: raise MCPError(f"Connection error: {e.reason}") resp_body = resp.read().decode("utf-8") content_type = resp.headers.get("Content-Type", "") if "text/event-stream" in content_type: return resp.headers, _parse_sse(resp_body) return resp.headers, json.loads(resp_body) if resp_body else {} def _init_session(endpoint, headers): """Run MCP initialize + notification handshake. Returns session_id. Raises MCPError on connection/protocol failure. """ init_body = { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": PROTOCOL_VERSION, "capabilities": {}, "clientInfo": {"name": "virse-skill", "version": "0.1.0"}, }, } resp_headers, init_result = _http_post(endpoint, headers, init_body) if init_result.get("error"): msg = _extract_error_message(init_result["error"]) raise MCPError(f"MCP init error: {msg}") session_id = resp_headers.get("mcp-session-id") or "" # Send initialized notification notify_headers = dict(headers) if session_id: notify_headers["mcp-session-id"] = session_id _http_post(endpoint, notify_headers, { "jsonrpc": "2.0", "method": "notifications/initialized", "params": {}, }) return session_id def call_tool(name, args_json): base = _base_url() token = _read_token() endpoint = f"{base}/mcp" headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream;q=0.9"} if token: headers["Authorization"] = f"Bearer {token}" try: session_id = _init_session(endpoint, headers) except MCPError as e: print(str(e), file=sys.stderr) sys.exit(1) # Call tool call_headers = dict(headers) if session_id: call_headers["mcp-session-id"] = session_id args = json.loads(args_json) if isinstance(args_json, str) else args_json call_body = { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": name, "arguments": args}, } try: _, result = _http_post(endpoint, call_headers, call_body) except MCPError as e: print(str(e), file=sys.stderr) sys.exit(1) if result.get("error"): msg = _extract_error_message(result["error"]) print(f"Tool error: {msg}", file=sys.stderr) sys.exit(1) # Extract text content content = result.get("result", {}).get("content", []) for part in content: if part.get("type") == "text": print(part["text"]) def batch_call(calls_json): """Execute multiple tool calls in a single MCP session. calls_json: JSON array like [{"name": "tool_name", "args": {...}}, ...] Reuses one session for all calls, with 0.5s throttle between each. """ calls = json.loads(calls_json) if isinstance(calls_json, str) else calls_json if not isinstance(calls, list) or not calls: print("batch expects a non-empty JSON array of {name, args} objects", file=sys.stderr) sys.exit(1) base = _base_url() token = _read_token() endpoint = f"{base}/mcp" headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream;q=0.9"} if token: headers["Authorization"] = f"Bearer {token}" try: session_id = _init_session(endpoint, headers) except MCPError as e: print(str(e), file=sys.stderr) sys.exit(1) call_headers = dict(headers) if session_id: call_headers["mcp-session-id"] = session_id results = [] for i, call in enumerate(calls): name = call.get("name", "") args = call.get("args", {}) if not name: print(f"[{i}] Skipped: missing 'name'", file=sys.stderr) results.append({"index": i, "error": "missing name"}) continue call_body = { "jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": args}, } try: _, result = _http_post(endpoint, call_headers, call_body) except MCPError as e: print(f"[{i}] {name}: {e}", file=sys.stderr) results.append({"index": i, "name": name, "error": str(e)}) if i < len(calls) - 1: time.sleep(0.5) continue if result.get("error"): msg = _extract_error_message(result["error"]) print(f"[{i}] {name}: error — {msg}", file=sys.stderr) results.append({"index": i, "name": name, "error": msg}) else: content = result.get("result", {}).get("content", []) texts = [p["text"] for p in content if p.get("type") == "text"] output = "\n".join(texts) print(f"[{i}] {name}: OK") print(output) results.append({"index": i, "name": name, "ok": True}) # Throttle between calls if i < len(calls) - 1: time.sleep(0.5) # Summary ok_count = sum(1 for r in results if r.get("ok")) fail_count = len(results) - ok_count print(f"\n--- batch complete: {ok_count} succeeded, {fail_count} failed ---", file=sys.stderr) def list_tools(): base = _base_url() token = _read_token() endpoint = f"{base}/mcp" headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream;q=0.9"} if token: headers["Authorization"] = f"Bearer {token}" try: session_id = _init_session(endpoint, headers) except MCPError as e: print(str(e), file=sys.stderr) sys.exit(1) # List tools list_headers = dict(headers) if session_id: list_headers["mcp-session-id"] = session_id try: _, result = _http_post(endpoint, list_headers, { "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}, }) except MCPError as e: print(str(e), file=sys.stderr) sys.exit(1) if result.get("error"): msg = _extract_error_message(result["error"]) print(f"Error: {msg}", file=sys.stderr) sys.exit(1) tools = result.get("result", {}).get("tools", []) for t in tools: print(f" {t['name']:30s} {t.get('description', '')}") def _http_post_form(url, data): """POST form-urlencoded (for OAuth endpoints, not JSON). Returns (status_code, parsed_json_body). """ encoded = urllib.parse.urlencode(data).encode("utf-8") req = urllib.request.Request( url, data=encoded, method="POST", headers={ "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", }, ) try: resp = urllib.request.urlopen(req, timeout=60) body = resp.read().decode("utf-8") return resp.status, json.loads(body) if body else {} except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace") try: return e.code, json.loads(body) except (json.JSONDecodeError, ValueError): return e.code, {"error": "http_error", "error_description": body} except urllib.error.URLError as e: print(f"Connection error: {e.reason}", file=sys.stderr) sys.exit(1) def login(): """Start Device Flow: POST /device/code, print JSON, exit immediately.""" base = _base_url() status, result = _http_post_form(f"{base}/device/code", {"client_id": "virse-skill"}) if status >= 400 or "error" in result: desc = result.get("error_description", result.get("error", f"HTTP {status}")) print(f"Device code request failed: {desc}", file=sys.stderr) sys.exit(1) print(json.dumps({ "verification_url": result.get("verification_uri_complete", result.get("verification_uri", "")), "user_code": result.get("user_code", ""), "device_code": result.get("device_code", ""), "expires_in": result.get("expires_in", 300), })) def login_poll(device_code): """Poll /device/token until user completes login, then save token.""" base = _base_url() interval = 5 for _ in range(24): # up to 2 minutes status, result = _http_post_form(f"{base}/device/token", { "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": device_code, "client_id": "virse-skill", }) if "access_token" in result: save_key(result["access_token"]) print("Login successful!") return error = result.get("error", "") if error == "authorization_pending": time.sleep(interval) continue if error == "slow_down": interval += 5 time.sleep(interval) continue # Any other error is fatal desc = result.get("error_description", error or f"HTTP {status}") print(f"Login failed: {desc}", file=sys.stderr) sys.exit(1) print("Login timed out (2 min)", file=sys.stderr) sys.exit(1) def save_key(key): os.makedirs(os.path.dirname(TOKEN_PATH), exist_ok=True) with open(TOKEN_PATH, "w") as f: f.write(key) os.chmod(TOKEN_PATH, 0o600) print(f"Token saved to {TOKEN_PATH}") def main(): if len(sys.argv) < 2: print((__doc__ or "").strip()) sys.exit(1) cmd = sys.argv[1] if cmd == "call": if len(sys.argv) < 4: print("Usage: virse_call.py call <tool_name> '<json_args>'", file=sys.stderr) sys.exit(1) call_tool(sys.argv[2], sys.argv[3]) elif cmd == "batch": if len(sys.argv) < 3: print("Usage: virse_call.py batch '<json_array>'", file=sys.stderr) sys.exit(1) batch_call(sys.argv[2]) elif cmd == "list-tools": list_tools() elif cmd == "login": login() elif cmd == "login-poll": if len(sys.argv) < 3: print("Usage: virse_call.py login-poll <device_code>", file=sys.stderr) sys.exit(1) login_poll(sys.argv[2]) elif cmd == "save-key": if len(sys.argv) < 3: print("Usage: virse_call.py save-key <api_key>", file=sys.stderr) sys.exit(1) save_key(sys.argv[2]) else: print(f"Unknown command: {cmd}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main() FILE:auth-guide.md # Virse Authentication Guide > **`virse_call`** refers to `python3 SKILL_DIR/scripts/virse_call.py`. See SKILL.md for path resolution. ## Automatic Login Flow (via virse_call) Use this when `virse_call call get_account '{}'` returns HTTP 401: 1. Run: `virse_call login` Returns JSON with `verification_url` and `device_code`. 2. **STOP and show the user the link.** Tell them clearly: > Please click the link below to log in. I'll wait up to 2 minutes for you to complete authentication: > <verification_url> 3. Run: `virse_call login-poll <device_code>` This polls until the user completes login (up to 2 minutes). 4. **If login-poll succeeds** → retry `get_account` to display user info and continue. 5. **If login-poll times out** → Ask if they'd like to try again. If yes, go back to step 1. ## API Key (Simplest) If you already have a `virse_sk_*` API Key: ```bash # Save to ~/.virse/token virse_call save-key virse_sk_YOUR_KEY # Or set environment variable (per-session) export VIRSE_API_KEY=virse_sk_YOUR_KEY ``` ## Logout ```bash rm ~/.virse/token ``` ## Device Flow — Raw HTTP (for CI / scripts) ### Step 1 — Start device flow ```bash curl -s -X POST https://dev.virse.ai/device/code \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'client_id=virse-skill' ``` Response: ```json { "device_code": "DEVICE_CODE_HERE", "user_code": "ABCD-1234", "verification_uri": "https://dev.virse.ai/device", "verification_uri_complete": "https://dev.virse.ai/device?user_code=ABCD-1234", "expires_in": 600, "interval": 5 } ``` ### Step 2 — Open the URL and sign in Open `verification_uri_complete` in your browser and log in with your Virse account. ### Step 3 — Poll for token ```bash curl -s -X POST https://dev.virse.ai/token \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'grant_type=urn:ietf:params:oauth:grant-type:device_code' \ -d 'device_code=DEVICE_CODE_HERE' \ -d 'client_id=virse-skill' ``` Keep polling every 5 seconds until you get an `access_token` in the response. ### Step 4 — Save the token ```bash virse_call save-key <access_token> ``` ## Token Storage | Method | Location | Priority | |--------|----------|----------| | Environment variable | `VIRSE_API_KEY` | Highest (checked first) | | Token file | `~/.virse/token` | Fallback | ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `VIRSE_API_KEY` | — | API Key (`virse_sk_*`) | | `VIRSE_BASE_URL` | `https://dev.virse.ai` | MCP server base URL | ## Troubleshooting **"HTTP 401" on tool calls** - Token expired or invalid. Re-authenticate with Device Flow or set a new API Key. **"HTTP 403" on tool calls** - API Key does not have permission for this operation. Check your account role. **"Connection refused"** - Check `VIRSE_BASE_URL` is correct. Default: `https://dev.virse.ai` **Device Flow returns "authorization_pending"** - Normal — keep polling. The user hasn't completed browser login yet. **Device Flow returns "expired_token"** - The device code expired (10 min). Restart from Step 1. FILE:tools-reference.md # Virse Tools Reference > **`virse_call`** refers to `virse_skill/scripts/virse_call.py`. See SKILL.md for how your agent resolves this path. All tools are called via: ```bash virse_call call <tool_name> '<json_args>' ``` --- ## Account ### get_account Get current user info (username, email) and CU balance per organization (with org name and type). For team orgs, also shows your member budget (credit_used / credit_limit). **Parameters:** none ```bash virse_call call get_account '{}' ``` --- ## Workspace ### list_workspaces List all workspaces (Spaces) the user has access to. Returns `space_id`, `canvas_id`, and organization context (org name, type) for each. **Parameters:** none ```bash virse_call call list_workspaces '{}' ``` ### create_workspace Create a new workspace and its associated Canvas. | Param | Type | Required | Description | |-------|------|----------|-------------| | name | string | yes | Workspace name | | description | string | no | Workspace description | | visibility | string | no | `private` (default) or `public` | | organization_id | string | no | Organization to create under (defaults to personal org) | ```bash virse_call call create_workspace '{"name": "My Project"}' ``` ### update_workspace Update workspace name, description, or visibility. | Param | Type | Required | Description | |-------|------|----------|-------------| | space_id | string | yes | Space UUID to update | | name | string | no | New name | | description | string | no | New description | | visibility | string | no | `private` or `public` | ```bash virse_call call update_workspace '{"space_id": "UUID", "name": "New Name"}' ``` --- ## Canvas ### get_canvas Get a lightweight overview of a Canvas — element summaries with outgoing connections, plus group summaries. | Param | Type | Required | Description | |-------|------|----------|-------------| | canvas_id | string | yes | Canvas UUID (from `list_workspaces`) | | group_id | string | no | Filter by Group short ID / UUID | | element_type | string | no | Filter by type: `image` or `text` | | include_group_detail | boolean | no | If true, append detailed group info (default false) | ```bash virse_call call get_canvas '{"canvas_id": "UUID"}' ``` ### get_element Get one element's full details by short ID / UUID, plus one-hop incoming/outgoing neighbors. | Param | Type | Required | Description | |-------|------|----------|-------------| | canvas_id | string | yes | Canvas UUID | | id | string | yes | Element short ID or full UUID | ```bash virse_call call get_element '{"canvas_id": "UUID", "id": "abc1"}' ``` ### trace_connections Traverse workflow connections from one element. | Param | Type | Required | Description | |-------|------|----------|-------------| | canvas_id | string | yes | Canvas UUID | | id | string | yes | Origin element short ID or UUID | | direction | string | no | `upstream`, `downstream`, or `both` | | depth | integer | no | Traversal depth (default 1, max 5) | ```bash virse_call call trace_connections '{"canvas_id": "UUID", "id": "abc1", "direction": "both", "depth": 3}' ``` ### create_element Create a new element (image or text node) in the current Canvas. **Text fontSize tip**: Text wraps automatically within the element width. Default fontSize is 50. E.g. fontSize 50, 300×80 box → 6 lowercase, 5 uppercase, or 4 Chinese chars in 1 line. | Param | Type | Required | Description | |-------|------|----------|-------------| | canvas_id | string | yes | Canvas UUID | | element_type | string | yes | `image` or `text` | | position_x | number | yes | X coordinate | | position_y | number | yes | Y coordinate | | size_width | number | no | Width in px (default 200) | | size_height | number | no | Height in px (default 200) | | artifact_version_id | string | no | Image artifact version ID | | asset_id | string | no | Image asset ID | | text / content | string | no | Text content (for text elements) | | fontSize | number | no | Font size (default 50) | | textAlign | string | no | `left`, `center` (default), `right` | | verticalAlign | string | no | `top`, `middle` (default), `bottom` | | z_index | integer | no | Layer order (higher = front) | | title | string | no | Element title | ```bash virse_call call create_element '{"canvas_id": "UUID", "element_type": "text", "position_x": 100, "position_y": 50, "text": "Hello", "fontSize": 32}' ``` ### update_element Update an element (move, resize, or modify content). | Param | Type | Required | Description | |-------|------|----------|-------------| | canvas_id | string | yes | Canvas UUID | | element_id | string | yes | Element short ID or UUID | | position_x | number | no | New X | | position_y | number | no | New Y | | size_width | number | no | New width | | size_height | number | no | New height | | rotation | number | no | Rotation in degrees | | text / content | string | no | New text content | | fontSize | number | no | Font size | | textAlign | string | no | `left`, `center`, `right` | | verticalAlign | string | no | `top`, `middle`, `bottom` | | z_index | integer | no | Layer order | | title | string | no | New title | | artifact_version_id | string | no | New image artifact | | asset_id | string | no | New image asset | ```bash virse_call call update_element '{"canvas_id": "UUID", "element_id": "abc1", "position_x": 200, "position_y": 300}' ``` ### delete_element Delete an element from the Canvas. **Destructive — confirm with user first.** | Param | Type | Required | Description | |-------|------|----------|-------------| | canvas_id | string | yes | Canvas UUID | | element_id | string | yes | Element short ID or UUID | ```bash virse_call call delete_element '{"canvas_id": "UUID", "element_id": "abc1"}' ``` ### create_edge Create a directed edge between two elements. | Param | Type | Required | Description | |-------|------|----------|-------------| | canvas_id | string | yes | Canvas UUID | | source_element_id | string | yes | Source element short ID or UUID | | target_element_id | string | yes | Target element short ID or UUID | ```bash virse_call call create_edge '{"canvas_id": "UUID", "source_element_id": "abc1", "target_element_id": "def2"}' ``` ### delete_edge Delete an edge. **Destructive — confirm with user first.** | Param | Type | Required | Description | |-------|------|----------|-------------| | canvas_id | string | yes | Canvas UUID | | edge_id | string | yes | Edge UUID | ```bash virse_call call delete_edge '{"canvas_id": "UUID", "edge_id": "EDGE_UUID"}' ``` --- ## Groups ### create_group Create a group by bundling elements. Bounding box is computed automatically. | Param | Type | Required | Description | |-------|------|----------|-------------| | canvas_id | string | yes | Canvas UUID | | element_ids | string[] | yes | Element short IDs / UUIDs to include | | title | string | no | Group title | ```bash virse_call call create_group '{"canvas_id": "UUID", "element_ids": ["abc1", "def2"], "title": "My Group"}' ``` ### delete_group Delete a group. Member elements are NOT deleted. **Destructive — confirm with user first.** | Param | Type | Required | Description | |-------|------|----------|-------------| | canvas_id | string | yes | Canvas UUID | | group_id | string | yes | Group short ID or UUID | ```bash virse_call call delete_group '{"canvas_id": "UUID", "group_id": "GRP1"}' ``` --- ## Image Generation & Upload ### generate_image Generate an AI image and place it on a Canvas. A placeholder element appears immediately while the image generates in the background, then fills in when complete. Returns `artifact_version_id` and `element_id`(s). Use `list_image_models` to see all available models and their supported parameters. **Auto-edge**: When using `asset_id` (image-to-image), an edge is automatically created from each source element to the new element. No need to call `create_edge` separately. | Param | Type | Required | Description | |-------|------|----------|-------------| | prompt | string | yes | Image generation prompt | | model | string | yes | Model ID (use `list_image_models`) | | space_id | string | yes | Space ID | | canvas_id | string | yes | Canvas UUID | | position_x | number | yes | X coordinate | | position_y | number | yes | Y coordinate | | width | integer | no | Image width in px | | height | integer | no | Image height in px | | aspect_ratio | string | no | Aspect ratio string — supported values vary by model family (see table below). | | resolution | string | no | `0.5K`, `1K`, `2K`, `3K`, `4K`. Tier availability varies by model — Nano2 supports all tiers; Seedream 4.5 supports up to 4K; Imagen 4 Ultra supports up to 2K. | | num_images | integer | no | Number of images (default 1) | | asset_id | string \| string[] | no | Reference image(s) for image-to-image generation. Pass a single asset_id string or an array of asset_id strings for multi-image references (max 10). Auto-edges are created from all referenced source elements. | | size_width | number | no | Placeholder width (default 512) | | size_height | number | no | Placeholder height (default 512) | **Supported `aspect_ratio` values by model:** | Model | Supported aspect_ratio | Notes | |-------|----------------------|-------| | Nano Banana 2 (`nano-banana-2`) | `1:1`, `3:2`, `2:3`, `3:4`, `4:3`, `9:16`, `16:9`, `21:9` | Passed directly to FAL.ai API | | Gemini 2.5 Flash Image (`gemini-2.5-flash-image`) | `1:1`, `3:2`, `2:3`, `3:4`, `4:3`, `9:16`, `16:9`, `21:9` | Via Vertex AI / APIMART / ONEROUTER | | Gemini 3 Pro Image (`gemini-3-pro-image-preview`) | `1:1`, `3:2`, `2:3`, `3:4`, `4:3`, `9:16`, `16:9`, `21:9` | Via Vertex AI / APIMART / ONEROUTER | | Seedream 4.5 (`seedream-4.5`) | `1:1`, `4:3`, `3:4`, `16:9`, `9:16`, `3:2`, `2:3`, `21:9`, `9:21` | Converted to width/height via resolution base size | | Seedream V5 Lite (`seedream-v5-lite`) | `1:1`, `4:3`, `3:4`, `16:9`, `9:16`, `3:2`, `2:3`, `21:9`, `9:21` | Converted to width/height via resolution base size | | Imagen 4 (`imagen-4.0-*`) | `1:1`, `9:16`, `16:9`, `3:4`, `4:3` | Via Google Vertex AI GenerateImagesConfig | | FLUX 1.1 Pro (`flux-1.1-pro`) | `1:1`, `16:9`, `3:2`, `2:3`, `4:5`, `5:4`, `9:16`, `3:4`, `4:3` | Via BFL API | | FLUX 1.1 Pro Ultra (`flux-1.1-pro-ultra`) | `1:1`, `16:9`, `3:2`, `2:3`, `4:5`, `5:4`, `9:16`, `3:4`, `4:3` | Via BFL API | | FLUX Kontext (`flux-kontext-pro`, `flux-kontext-max`) | `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, `2:3`, `4:5`, `5:4`, `21:9`, `9:21`, `2:1`, `1:2` | Via BFL API | | Seedream 4 (`seedream-4`) | `1:1`, `2:1`, `3:2`, `4:3`, `5:4`, `16:9`, `21:9`, `1:2`, `2:3`, `3:4`, `4:5`, `9:16`, `9:21` | Converted to width/height via resolution base size | | Gemini 2.5 Flash Image Preview (`gemini-2.5-flash-image-preview`) | Not supported | No aspect_ratio parameter | | GPT Image 1 (`gpt-image-1`) | `1:1`, `3:2`, `2:3` | Uses `size` param (1024x1024, 1536x1024, 1024x1536) | | GPT Image 1.5 (`gpt-image-1.5`) | `1:1`, `3:2`, `2:3` | Uses `size` param (1024x1024, 1536x1024, 1024x1536) | ```bash virse_call call generate_image '{"prompt": "cyberpunk city", "model": "flux-1.1-pro", "space_id": "SPACE", "canvas_id": "UUID", "position_x": 0, "position_y": 0}' # Multiple reference images virse_call call generate_image '{"prompt": "edit prompt here", "model": "nano-banana-2", "space_id": "SPACE", "canvas_id": "UUID", "position_x": 0, "position_y": 0, "asset_id": ["ASSET_1", "ASSET_2"]}' ``` ### upload_image Upload an image from URL to a workspace or asset folder. Returns created image asset info. Supports three modes: (1) canvas placement — provide `canvas_id` + position to place the image on a canvas; (2) asset folder — provide `folder_id` to store in an asset folder; (3) local file upload — use `get_upload_token` and POST to the HTTP multipart endpoint `/api/upload_image` for local files. | Param | Type | Required | Description | |-------|------|----------|-------------| | image_url | string | yes | Public image URL | | space_id | string | no | Target workspace ID (if not using folder_id) | | folder_id | string | no | Target asset folder UUID | | filename | string | no | Filename (default: upload.png) | | canvas_id | string | no | Canvas UUID (for placement) | | position_x | number | no | X coordinate for placement | | position_y | number | no | Y coordinate for placement | | size_width | number | no | Element width (default 512) | | size_height | number | no | Element height (default 512) | ```bash virse_call call upload_image '{"image_url": "https://example.com/img.png", "space_id": "SPACE"}' ``` ### list_image_models List all supported AI image generation models with providers, operations, aspect ratios, and max resolution. **Parameters:** none ```bash virse_call call list_image_models '{}' ``` ### get_upload_token Generate a reusable upload token for the HTTP multipart endpoint `POST /api/upload_image`. The token is bound to your IP, expires after 1 hour, and can be reused for multiple uploads. Call this tool again to refresh. **Parameters:** none ```bash virse_call call get_upload_token '{}' ``` **Usage** (canvas placement): ```bash curl -X POST <upload_url> \ -H 'Authorization: Bearer <upload_token>' \ -F [email protected] \ -F space_id=<space_id> \ -F canvas_id=<canvas_id> \ -F position_x=0 -F position_y=0 ``` **Usage** (asset folder upload — omit canvas_id): ```bash curl -X POST <upload_url> \ -H 'Authorization: Bearer <upload_token>' \ -F [email protected] \ -F space_id=<folder_id> ``` Only one file per request. For multiple images, call curl once per file. The full URL is returned by the tool call result. --- ## Search & Assets ### search_images Search the Virse image library by query and return a list of images with metadata. Supports pagination via `page_token` returned in previous results. | Param | Type | Required | Description | |-------|------|----------|-------------| | query | string | yes | Search query | | limit | integer | no | Results to return (default 10, max 50) | | page_token | string | no | Token for next page | ```bash virse_call call search_images '{"query": "sunset landscape", "limit": 10}' ``` ### get_asset_detail Get details of an artifact version or a text asset. Pass `artifact_version_id` to get generation details (prompt, model, parameters, status, output image URLs). Pass `asset_id` to get the full text content of a text asset. At least one of `artifact_version_id` or `asset_id` must be provided. | Param | Type | Required | Description | |-------|------|----------|-------------| | artifact_version_id | string | no | From `get_element` output | | asset_id | string | no | Text asset ID from `get_element` output | ```bash virse_call call get_asset_detail '{"artifact_version_id": "AV_UUID"}' ``` --- ## Asset Folders ### create_asset_folder Create a new asset folder for organizing images. | Param | Type | Required | Description | |-------|------|----------|-------------| | name | string | yes | Folder name | | description | string | no | Description | | visibility | string | no | `private` (default) or `public` | ```bash virse_call call create_asset_folder '{"name": "Logos"}' ``` ### list_asset_folders List all asset folders the current user has access to. Returns `folder_id`, `name`, `visibility`, and `asset_count`. **Parameters:** none ```bash virse_call call list_asset_folders '{}' ``` ### list_asset_folder_images List images in an asset folder with pagination. | Param | Type | Required | Description | |-------|------|----------|-------------| | folder_id | string | yes | Asset folder UUID | | limit | integer | no | Max images (default 50) | | offset | integer | no | Pagination offset (default 0) | ```bash virse_call call list_asset_folder_images '{"folder_id": "FOLDER_UUID"}' ``` ### add_image_to_asset_folder Add an existing image asset to an asset folder (zero-copy link). Idempotent. | Param | Type | Required | Description | |-------|------|----------|-------------| | asset_id | string | yes | Image asset UUID | | folder_id | string | yes | Asset folder UUID | ```bash virse_call call add_image_to_asset_folder '{"asset_id": "ASSET_UUID", "folder_id": "FOLDER_UUID"}' ``` ### remove_image_from_asset_folder Remove an image from an asset folder (unlinks, does NOT delete the image). **Destructive — confirm first.** | Param | Type | Required | Description | |-------|------|----------|-------------| | asset_id | string | yes | Image asset UUID | | folder_id | string | yes | Asset folder UUID | ```bash virse_call call remove_image_from_asset_folder '{"asset_id": "ASSET_UUID", "folder_id": "FOLDER_UUID"}' ```