Claude Skill

scenario-workflow-authoring

Use when a task involves creating or editing a Scenario workflow graph through MCP: building an app from a brief, adding or rewiring nodes (models, prompts, approval gates, loops), authoring editor_info, publishing, unpublishing or renaming, importing an exported workflow JSON, m

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download scenario-labs-skills-skills_scenario-workflow-authoring-dc592af.zip · 17 KB
Part of scenario-labs/skills — 97 skills

Install

skills CLI npx skills add https://github.com/scenario-labs/skills/tree/main/skills/scenario-workflow-authoring
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
Git git clone https://github.com/scenario-labs/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Scenario Workflow Authoring

Overview

A workflow has two representations: editor_info (the editable node graph: nodes, edges, inputKeys) and flow (the compiled runnable form). Authoring through MCP means writing the whole editor_info document: there are no per-node editing tools; every change is a read, modify, write of the full graph through workflow_create or workflow_update. Never hand-write flow: workflow_publish compiles editor_info into it and flips status to ready. Editing a ready workflow's editor_info leaves the stale flow running until you publish again.

Read references/editor-info.md before writing any graph: it holds the node type vocabulary, the node choice doctrine (when an llm node is legitimate), the edge direction rule, per-node data contracts, and a validated minimal example. Create, update, publish, copy and delete live in the tool catalog (scenario_tools_search plus the matching executor, see the scenario skill). workflows_list, workflow_get and workflow_run are direct tools: scope and dry_run go in their top-level arguments, never an executor wrapper. Running and pricing: the scenario-workflows skill. If a sibling skill named here is missing from your available skills, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); unattended, proceed from tool schemas and flag the gap.

Quick reference

Step Call Notes
1. Study a graph workflow_get on a working workflow Copy the shape, never ids
2. Model contract model_schema_get Handle names and required inputs
3. Author editor_info + inputs_definition Per the reference file
4. Create workflow_create Non-atomic, see below
5. Publish workflow_publish Compiles flow, needs input+output pins
6. Validate workflow_run with dry_run=true Prices and runs the real validator

workflow_create is two calls under the hood: a failed create may still have created a draft whose id is in the error. Recover with workflow_update on that id; re-creating duplicates. Seed step 1 with workflow_get: it returns the full graph of any workflow whose id you have, public ones included (an id or app URL the user supplies, or your own team's from workflows_list). To find a public template, use search with target="workflows", public=true, a keyword query, limit=3, and your scope; for example, query="image" with raw filter: 'status = "ready"'. Read ids from workflows, then fetch the chosen graph with workflow_get; search hits are summaries, not graph documents. Workflow search supports keyword text and filters only, so omit image and semantic options. Use workflows_list for browsing your saved workflows. scripts/fetch_workflow_examples.py bulk-exports trimmed featured-workflow graphs for maintainers (setup in its header).

Worked example: a text-to-image app

  1. recommend with capability: "txt2img" and the user's brief as prompt, following the scenario skill's next_step discipline, then model_schema_get: its input names become the model node's handle names, and its required flag marks what must be wired. Use search instead when the user names a model.
  2. Author editor_info: text1 with data.isInput: true, model1 with type: "model", data.modelId and data.isOutput: true, one edge from model1's input to text1's output (edges name the downstream node as source, see the reference), inputKeys: ["text1"].
  3. workflow_create with name, editor_info, and inputs_definition naming text1 as a string input. The published input key is the node id, which is why run inputs have names like text1.
  4. workflow_publish, then workflow_run with dry_run=true to validate and price. Fix the graph and re-publish if validation fails.

One output per item needs a loop

A multiple-asset input wired straight into a generative model's reference field is one generation conditioned on every item at once: ten product shots in come back as one image blending them, not ten edits. Field names and cardinality are per model, so read them off model_schema_get (array: true marks a list), and read the field's description as well: a few utility tools take a list and return one output per item, which needs no loop. When each item needs its own result, put the model inside a forEach over the list and read the outputs from the forEachEnd (wiring in the reference's forEach section); keep the direct wiring only when the items are meant as joint references for a single output. Inside the loop the current item feeds an array field as a one-item list. forEach runs one billed model job per item, but the dry run prices one iteration whatever the list length (observed: one quote for one or two items, twice that billed for two), so multiply the quote by the item count before quoting the user.

Migrating a graph from another node tool

A pipeline exported by Weavy, ComfyUI, or another node editor does not import: only Scenario's own export round-trips. It is translated node by node, then created, published, and dry-run as above. The mapping table, member resolution, and the report the user gets are in references/foreign-graph-import.md; read it before touching such an export, since its first rule is to reduce the file to a table locally rather than paste it into the conversation.

Common mistakes

  • Writing UI palette names as node types: persisted types are the camelCase vocabulary in the reference, and every generator is type: "model".
  • Wiring edges producer to consumer: persisted edges point the other way.
  • Expecting an editor_info update to change a live app without re-publishing.
  • Retrying a failed workflow_create with a second create instead of workflow_update on the id from the error.
  • Wiring a batch of assets into one model input and expecting one output per item: that is one generation over all of them; loop with forEach.
  • Publishing with no pins: at least one data.isInput node listed in inputKeys and one data.isOutput node.
  • Gating a text node in front of a builder or model: a branch skips only the node wired to its handle, so the consumer stays pending and the job never completes. Gate the node that does the work, or use a CEL ternary for conditional prompt text, per the reference's ifElse section.
  • Double-quoted CEL literals: they evaluate but corrupt the canvas editor, single quotes only.
  • Sending workflow_id to workflow_copy: get, update, publish, run and delete take workflow_id, but copy takes source_workflow_id; the copy inherits everything verbatim and needs its own publish.
Files (skills)
  • references
    • editor-info.md 20.4 KB
      # The editor_info grammar
      
      This is the authoring grammar for Scenario workflows: the `editor_info` document that `workflow_create` and `workflow_update` accept and `workflow_get` returns, plus the `inputs_definition` array that rides with it. `workflow_publish` compiles `editor_info` into the executable `flow`; you never write `flow` by hand.
      
      `editor_info` top-level keys: `nodes` (array), `edges` (array), `inputKeys` (ordered array of node ids for workflow inputs), and optionally `nodeGroups` (a map of group id to `{title, color, locked?}`, cosmetic only). Records fetched from live workflows may carry extra UI fields on nodes (`measured`, `selected`, `dragging`, `width`, `height`); omit them when authoring.
      
      ## Edge direction is inverted: read this first
      
      Persisted edges do not point the way graph intuition says. In `editor_info.edges`:
      
      - `source` is the DOWNSTREAM consumer node, and `sourceHandle` is its INPUT handle.
      - `target` is the UPSTREAM producer node, and `targetHandle` is its OUTPUT handle.
      
      Handle ids follow one scheme:
      
      | Handle side | Id format                  | Example                |
      | ----------- | -------------------------- | ---------------------- |
      | Input       | `${nodeId}-source-${name}` | `model1-source-prompt` |
      | Output      | `${nodeId}-target-${name}` | `text1-target-output`  |
      
      So "text node `text1` feeds the prompt of model node `model1`" is written:
      
      ```json
      {
        "id": "edge1",
        "source": "model1",
        "sourceHandle": "model1-source-prompt",
        "target": "text1",
        "targetHandle": "text1-target-output"
      }
      ```
      
      The name segment comes from the handle's `name` field. Output handle names are commonly `output`, but live workflows also show others (a text node feeding a prompt can carry a `prompt`-named output handle), so when cloning, copy handle ids from the source record instead of assuming `output`. `ifElse` output handles are named `if1` to `ifN` plus `else`.
      
      ## Node type vocabulary
      
      `nodes[].type` uses a camelCase persisted vocabulary. The workflow editor's palette names (image-generator, prompt-builder, if-else, 3d-model, video-studio) are NOT valid persisted types; creation validation refuses unknown types before any network call.
      
      | Persisted type  | Editor palette equivalent                                   |
      | --------------- | ----------------------------------------------------------- |
      | `text`          | Text                                                        |
      | `asset`         | Image, Video, Audio, 3D model (kind in `data.type`)         |
      | `model`         | Every generator, tool, and studio node (kind via `modelId`) |
      | `llm`           | LLM generator                                               |
      | `transformText` | Prompt builder (CEL)                                        |
      | `splitText`     | Split text                                                  |
      | `groupItems`    | Group items                                                 |
      | `sliceAssets`   | Slice assets                                                |
      | `ifElse`        | If/else                                                     |
      | `forEach`       | For-each loop start                                         |
      | `forEachEnd`    | For-each loop end                                           |
      | `approval`      | Approval gate                                               |
      | `aspectRatio`   | Aspect ratio preset                                         |
      | `stickyNote`    | Sticky note (annotation only, never connected)              |
      
      The webapp also persists a `modelInput` type (a generator setting exposed as its own node), but the creation validator rejects it: an export using exposed model inputs fails a `workflow_create` round-trip.
      
      ## Per-type data contracts
      
      Common `data` fields on every node: `title` (display name), `isInput` / `isOutput` (workflow pin flags), `inputHandles` / `outputHandles` (arrays of `{id, name, label, type}`).
      
      | Type            | Data fields                                                                                                                                                   |
      | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | `text`          | `value` (the string)                                                                                                                                          |
      | `asset`         | `type` (`image` \| `video` \| `audio` \| `3d`, note `3d` not `3d-model`), `value` (asset id, or array when `isMultiple: true`), `isRequired?`                 |
      | `model` / `llm` | `modelId`, `form` (scalar-only settings keyed by the model's input names, e.g. `{"guidance": 7.5}`; wired inputs like `prompt` never go here)                 |
      | `transformText` | `value` (a CEL expression, see below); one input handle `<id>-source-input` (type `text`) that takes any number of edges, and one output `<id>-target-output` |
      | `splitText`     | `splitDelimiter` (default `,`)                                                                                                                                |
      | `groupItems`    | `value` (a CEL list expression)                                                                                                                               |
      | `sliceAssets`   | `from`, `count`: STRINGS, not numbers (`"0"`, `"-3"`; negative `from` counts from the end)                                                                    |
      | `aspectRatio`   | `output` (exactly one of `21:9`, `16:9`, `3:2`, `4:3`, `5:4`, `1:1`, `4:5`, `3:4`, `2:3`, `9:16`, `9:21`), `quality`                                          |
      | `ifElse`        | `conditionBlocks` (see below)                                                                                                                                 |
      | `forEach`       | `isConcurrentRuns?` (parallel iterations; ignored on nested loops)                                                                                            |
      | `forEachEnd`    | `parentNodeId` (the paired `forEach` node id)                                                                                                                 |
      | `approval`      | `message` (prompt shown when paused; default "Continue ?")                                                                                                    |
      
      Only `image` and `video` asset nodes support `isMultiple` with an array `value`; `audio` and `3d` asset nodes hold a single asset id, always.
      
      ## Workflow inputs are pinned nodes
      
      A workflow-level input is a node flagged `data.isInput: true`, ordered by `editor_info.inputKeys`. The published input's `name` IS the node id (`text1`, `image5`), which is why run-input keys look positional and non-contiguous. Outputs are `data.isOutput: true` pins on `model`, `llm`, or `forEachEnd` nodes; publish refuses a graph where no model or llm node is reachable from an output pin (the compiled flow would be empty). The webapp UI additionally requires at least one input pin and an output example image before it publishes; keep at least one of each for a usable app.
      
      `inputs_definition` entries mirror the pinned nodes:
      
      - Pinned `text` node: `{"type": "string", "name": "<nodeId>", "label", "description", "placeholder", "required": {"always": true|false}, "prompt": true|false, "default"}`. `prompt: true` only changes how the app displays the input (a text area with the prompt spark feature), never the wiring; `promptSpark` toggles the spark on its own.
      - Pinned `asset` node: `{"type": "file"}` or `"file_array"` when `isMultiple`, plus `"kind"` set to the asset `data.type`.
      
      The full input `type` set observed on live workflows: `string`, `integer` (typed as `number` in some layers), `boolean`, `file`, `file_array`, `string_array`, `model_array`, `number_array`, `inputs_array`. Array-typed inputs silently drop bare scalars at run time, so declare arrays only when the input truly takes a list.
      
      ## Model nodes: handles come from the model schema
      
      A model node's input handles derive from its model's input schema: handle name = input name, so the model input `prompt` becomes handle id `${nodeId}-source-prompt`. Use `model_schema_get` on the chosen model id to learn the input names, types, and required flags: `parameters[].required` is a boolean, unlike the `required` object in workflow `inputs_definition`. Wire required inputs or the workflow cannot run. Scalar-only settings (guidance, seed, output count) go in `data.form`, not handles. Never hardcode model ids: discover them with `search` or `recommend` per team, then schema-check. LoRA and composition model ids (`runs_as` in the schema) were not validated inside graphs at authoring time: prefer plain model ids in `data.modelId`, and dry-run to validate when using one.
      
      ## CEL expressions (transformText)
      
      `transformText` composes text at run time from connected inputs via a CEL expression in `data.value`. No LLM call is involved.
      
      - Variables are named `${nodeId}_${outputHandleName}`: text node `text1` with output handle `output` is `text1_output`. A variable only resolves when the corresponding edge exists; connect first, reference second.
      - String literals must be SINGLE-quoted. Double-quoted literals evaluate in CEL but corrupt the canvas editor's rendering of the expression, observed at authoring time.
      - Concatenate with `+`: `'A portrait of ' + trim(text1_output) + ' in the style of ' + text2_output`.
      - Custom functions: `trim(string)`, `slice(list, start, end)`. Standard CEL is available (`size`, `matches`, `startsWith`, `endsWith`, `contains`, `replace`, `split`, `substring`, `lowerAscii`, `upperAscii`, and the `has` / `all` / `exists` / `map` / `filter` macros). The ternary `condition ? a : b` works, which is how a prompt varies with an input (see the `ifElse` section).
      - Never feed a `splitText` output (a list) into `transformText` (single text); route lists through `forEach` or `sliceAssets`.
      
      `groupItems.data.value` is also CEL, producing a list.
      
      ## ifElse condition blocks
      
      ```json
      {
        "conditionBlocks": [
          {
            "logic": "and",
            "conditions": [
              { "field": "text1_output", "operator": "contains", "value": "night" }
            ]
          }
        ]
      }
      ```
      
      - `field` uses the same `${nodeId}_${handleName}` convention as CEL variables.
      - Operators: `isEmpty`, `isNotEmpty`, `contains`, `notContains`, `equals`, `notEquals`. Numeric comparators are not part of the authoring surface.
      - `value` is required for `contains` / `notContains` / `equals` / `notEquals` and must be omitted for `isEmpty` / `isNotEmpty`; `field` is required always.
      - Block at index i drives output handle `if(i+1)`; the `else` handle is implicit and always last. Never author an `else` block. At run time the first matching block wins.
      - Gate a node by wiring a branch handle into that node's own `conditional` input: `source` the gated node, `sourceHandle: "<id>-source-conditional"` (label "Is Active", type `conditional`), `target` the `ifElse`, `targetHandle` `<ifElseId>-target-if1` (or `-else`). The `ifElse` reads its condition field through one edge into `<ifElseId>-source-input`. On the branch not taken, only that directly gated node is `skipped`. A node reading it through an ordinary edge is not gated: it stays `pending` and the workflow job never completes, though the other branch's image is delivered. So gate the node that does the work (the builder or the model), never a text node in front of it.
      - Merge branches by wiring each branch's output into the same input of one downstream node: publish compiles that into an input taking whichever branch ran (`ref: {"conditional": [...]}` in the flow). Validated live at authoring time: two gated `transformText` builders merged into one model's `prompt` gave the right prompt on each branch, and the job completed.
      - For prompt text that varies with an input, a CEL ternary in one `transformText` needs no `ifElse`: `trim(text2_output) == 'Stormreach' ? text1_output + ', muted palette' : text1_output`, with both text nodes wired into the builder's one input handle (two edges with `sourceHandle: "transform1-source-input"`, each naming its text node's output as `targetHandle`). Also validated live. What never works is one shared builder that names an optional text node behind a branch: the template reads every node it names whichever branch fired, so the text lands every time.
      
      ## forEach loops
      
      `forEach` iterates its list input over the loop body. In the editor the end node is auto-created; when authoring `editor_info` directly you must include BOTH nodes: the `forEach` and a `forEachEnd` whose `data.parentNodeId` is the `forEach` node's id. The loop body's terminal output feeds the end node, and downstream consumers read from the end node's outputs. `isConcurrentRuns: true` parallelizes iterations and is ignored on nested loops. Loop wiring has more moving parts than any other construct: clone a working example (see template cloning below) rather than hand-authoring your first one. Keyword `search` rarely surfaces a public graph that contains a loop, so the handles a validated run used are listed here: the list feeds the `forEach` through `<forEachId>-input-0` and the body reads the current item from `<forEachId>-output-0`; the body's last node feeds `<forEachEndId>-source-results`; and one structural edge links the pair, `source` the `forEach`, `sourceHandle: "forEachEnd-source"`, `target` the `forEachEnd`, `targetHandle: "forEachEnd-target"`, with `data.isForEachEndEdge: true`. Edge direction for the data edges follows the rule above.
      
      ## Approval gates
      
      An `approval` node has ONE input handle (type `approval`) and NO outputs. It attaches to the dedicated approval OUTPUT handle that producer nodes expose (`model`, `llm`, `transformText`, `splitText`, `groupItems`, `sliceAssets`, `ifElse`, `forEachEnd`). Downstream nodes still wire to the producer's regular output handles; the gate only pauses them. Routing data through an approval node is structurally wrong and will not compile. At run time the run reply and the job record carry `metadata.flow` with a per-node `status`; the gate shows as a pending `user-approval` entry there, and `workflow_approve` / `workflow_reject` (with `workflow_id`, `workflow_job_id`, `node_id`) resolve it; reject cancels the workflow or the containing loop iteration.
      
      ## Create, update, publish
      
      - `workflow_create` params: `name` (required), `description`, `editor_info`, `inputs_definition`, `flow`. Author `editor_info` + `inputs_definition` only. `editor_info` is validated before any network call; unknown node types and missing `nodes` / `edges` are refused with nothing created.
      - Creation is two calls under the hood (a create for `name` + `description`, then an update with the content) and is not atomic: if the second step fails you have an orphan draft, and the error carries its id. Recover with `workflow_update` on that id, or `workflow_delete`; re-running `workflow_create` duplicates.
      - Create and update persist a DRAFT. Only `workflow_publish` compiles `editor_info` into `flow` and flips status to `ready`; a draft run returns "Workflow is not ready". Publish is user-consent territory: ask before publishing.
      - Updating `editor_info` on an already `ready` workflow leaves it running the STALE previously compiled flow until you publish again; the response's publish hint is the only signal.
      - Unpublish (reverting a ready app to draft) is a webapp action: the underlying API accepts a `status` field, but the MCP `workflow_update` tool does not expose one. Through MCP, fix a bad app by editing `editor_info` and publishing over the top.
      - `workflow_update` includes `name` only when non-empty (renaming to `""` silently no-ops); `description: ""` does clear.
      - Statuses are exactly `draft`, `ready`, `deleted`. "Has an app" really means `flow` is non-empty.
      - `workflow_copy` (param `source_workflow_id`) copies `flow`, `inputs`, and `editorInfo` verbatim, skipping validation: a corrupted source copies corrupted, inherited model ids stay as-is, and a copied draft still needs publish.
      
      ## Import and export
      
      A webapp export file is `{"version": "1.0", "name", "description", "editorInfo": {...}, "inputs": [...], "tagSet": [...]}`. Importing one through MCP is two calls: `workflow_create` mapping `editorInfo` to `editor_info` and `inputs` to `inputs_definition`, then `workflow_publish` on the returned id. Exports drop node groups and asset values (assets must be re-added), and an export that used exposed model-input nodes fails validation on import.
      
      ## Choosing nodes
      
      - Deliverables are model nodes: a brief asking for N distinct outputs gets N model nodes.
      - `llm` nodes are the top authoring failure mode. Default to none. Legitimate only when the user asked for LLM writing (captioning, rewriting, prompt variations), when fanning one brief into N different prompts via `forEach`, or when text must be computed at run time from an upstream asset; "refine the prompt" is not a reason. N samples of ONE prompt is one model node with its output-count parameter (name varies per model: `samples`, `nbImages`, `count`; check the schema), never an `llm` or `forEach` fan-out.
      - Composing a prompt from several outputs is `transformText`, not `llm`.
      - Tool-style models (upscale, remove background) define BOTH input and output handles, so pick the model before wiring. Studio-style composition models are fixed to their built-in model.
      
      ## Layout
      
      `position: {x, y}` is metadata but keeps the canvas usable: nodes are about 340px wide, so chain left to right in x steps of about 400 and stack independent nodes in y steps of about 260, starting at `(0, 0)`.
      
      ## Minimal working example
      
      This exact payload was validated live at authoring time with a real `modelId` substituted: create returned a draft with `_publishHint`, publish compiled the edge into `{"name": "prompt", "ref": {"node": "workflow", "name": "text1"}}` on the model's flow node and flipped status to `ready`, and a dry run returned `{"creativeUnitsCost": ..., "job": {}}`. One pinned text input feeding one pinned image model:
      
      ```json
      {
        "name": "prompt-to-image",
        "description": "Minimal text to image app",
        "editor_info": {
          "nodes": [
            {
              "id": "text1",
              "type": "text",
              "position": { "x": 0, "y": 0 },
              "data": {
                "title": "Prompt",
                "value": "A red fox in the snow",
                "isInput": true,
                "outputHandles": [
                  {
                    "id": "text1-target-output",
                    "name": "output",
                    "label": "Output",
                    "type": "text"
                  }
                ]
              }
            },
            {
              "id": "model1",
              "type": "model",
              "position": { "x": 400, "y": 0 },
              "data": {
                "title": "Image generator",
                "modelId": "<discover via search, never hardcode>",
                "isOutput": true,
                "form": {},
                "inputHandles": [
                  {
                    "id": "model1-source-prompt",
                    "name": "prompt",
                    "label": "Prompt",
                    "type": "prompt"
                  }
                ]
              }
            }
          ],
          "edges": [
            {
              "id": "edge1",
              "source": "model1",
              "sourceHandle": "model1-source-prompt",
              "target": "text1",
              "targetHandle": "text1-target-output"
            }
          ],
          "inputKeys": ["text1"]
        },
        "inputs_definition": [
          {
            "type": "string",
            "name": "text1",
            "label": "Prompt",
            "required": { "always": true },
            "prompt": true,
            "default": "A red fox in the snow"
          }
        ]
      }
      ```
      
      After `workflow_create` returns the id: `workflow_publish`, then `workflow_run` with `dry_run: true` and `inputs: {"text1": "..."}` to price and validate, then run for real: the reply carries a job for `jobs_wait` (re-call with `pending_job_ids`), as after `model_run`. To check what a run actually sent, read the workflow job's `metadata.flow` (in the run reply, or `job_get` with `verbose: true`): each node's `status` (`success`, `skipped`, `pending`), a transform node's `output` text, and a model node's child `jobId`, whose verbose record holds the prompt in `metadata.input.prompt`. The asset's `metadata.prompt` is not the text.
      
      ## Template cloning
      
      Before authoring a first graph of any new shape, `workflow_get` a working workflow of a similar shape and copy its structure: node types, edge topology, handle id patterns, pin placement. Never reuse its node ids, asset ids, or model ids as constants. Prefer recently updated workflows as templates: the editor silently migrates older persisted shapes on load (renamed types, injected handles), but the API returns them raw, so an old workflow can teach you a grammar the validator no longer accepts.
      
    • foreign-graph-import.md 10.7 KB
      # Importing a graph from another node tool
      
      The import path in the main reference round-trips Scenario's own export and nothing else. A graph built in Weavy, ComfyUI, or another node editor is translated, not imported: read the source graph, map each node onto the Scenario vocabulary, author `editor_info`, publish, dry-run, and report what did not map. The result reproduces the pipeline's shape; per-node output differs because the models differ, so never promise parity with the source tool's renders.
      
      ## Read the source as a table
      
      Every node editor exports the same three things under different names: a node list (a type or class per node, its parameter values, a position), a connection list (from one node's output to another node's input), and the nodes exposed as the pipeline's inputs and outputs. Field names differ per tool and per version, so open the file and locate all three rather than assuming a layout. Weavy exports a JSON with nodes and edges. A ComfyUI workflow export carries `nodes[]` (each with `type`, `widgets_values`, `inputs[]`, `outputs[]`) and `links[]` as arrays of `[id, from_node, from_slot, to_node, to_slot, type]`; its API-format export keys nodes by id with `class_type` and `inputs`, where a two-element array value such as `["4", 0]` is a link to node `4`'s first output.
      
      Reduce the file to one row per node (source id, kind, model or operation named, parameters, incoming connections) locally, with a script or by hand. Exports run to thousands of lines; pasting one into the conversation spends the context the mapping needs.
      
      ## Map each row
      
      | Source node                                                                                 | Scenario                                    | How                                                                                                                                                                                                                                                                                  |
      | ------------------------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
      | Text constant, prompt box                                                                   | `text`                                      | `data.value`; pin it (`isInput: true`, in `inputKeys`) when the source exposed it                                                                                                                                                                                                    |
      | Image, video, audio, or 3D input; file upload                                               | `asset`                                     | `data.type` from the media kind; pin it. `upload_asset` the source's bundled file only when it must be the default value                                                                                                                                                             |
      | Prompt template, string concatenation, text merge                                           | `transformText`                             | One CEL expression over the connected inputs, single-quoted literals                                                                                                                                                                                                                 |
      | LLM prompt writer or captioner                                                              | `llm`                                       | Preserve LLM steps explicitly present in the source, including prompt enhancement; do not add new enhancement steps during translation                                                                                                                                               |
      | Generator or editor (text-to-image, image-to-image, video, upscale, background removal, 3D) | `model`                                     | Resolve the member below, then `model_schema_get`: input names become handles, scalar settings (`seed`, guidance, output count) go in `data.form` only where the schema has a field of the same meaning; drop the rest and list them                                                 |
      | Checkpoint, LoRA, VAE, CLIP, sampler, scheduler, latent nodes (ComfyUI plumbing)            | folded into the `model` node they feed      | The checkpoint or LoRA names the member; the sampler's `seed`, `cfg`, and `steps` and the latent node's width, height, and batch map to `form` fields of the same meaning where the schema has them (size fields or an aspect preset, the output-count field); nothing else survives |
      | Branch, switch, condition                                                                   | `ifElse`                                    | String operators only (`contains`, `equals`, `isEmpty` and their negations); a numeric comparison has no mapping                                                                                                                                                                     |
      | Loop, batch, for-each, iterate                                                              | `forEach` plus `forEachEnd`                 | Both nodes, `parentNodeId` on the end node; the body's terminal feeds the end node and consumers read from it. Clone a working loop of the same shape and validate its downstream wiring with a dry run                                                                              |
      | Split, list pick, first N, group                                                            | `splitText`, `sliceAssets`, `groupItems`    | `sliceAssets` takes `from` and `count` as strings                                                                                                                                                                                                                                    |
      | Preview, save, output, export                                                               | `data.isOutput: true` on the producing node | No node of its own                                                                                                                                                                                                                                                                   |
      | Human review, approve, pause                                                                | `approval`                                  | Attached to the producer's approval handle, never on the data path                                                                                                                                                                                                                   |
      | Code, script, HTTP request, webhook, custom plugin, third-party integration                 | unmapped                                    | Listed for the user; insert pinned `text` or `asset` nodes for missing downstream values and wire them into consumer handles; list gaps whose value types cannot be represented                                                                                                      |
      
      Resolve a generator's member by what the source names. A provider model named in the node (a FLUX, Kling, Seedream, or Gemini member) is a name: `search` with `target="models"`, `public=true`, and the name as `query`, then read the hits rather than taking the first: the source names a generation that may be gone, so take the newest member the task fits whose record carries no `deprecated` tag (a `deprecated:<replacement_id>` tag names the successor). A checkpoint file whose name carries a provider family (`flux1-dev.safetensors` names FLUX) is a name, not a capability: the file never travels, the family does. When no hit fits, or every fit is deprecated with no successor tag, the node falls through to the capability path below and the report says the family was substituted. A model the source tool hosted itself and whose file name identifies no public model (a merge, a downloaded LoRA, a tool-specific default) is a capability: `recommend` with `capability` from its enum (`txt2img`, `img2img`, `img2video`) and the node's job as `prompt` in the user's own words, then the `next_step` discipline from the `scenario` skill. A hit `recommend` marks `requires_plan_upgrade` is not mappable for this team; record it as unmapped rather than substituting silently. Never carry the source's model identifiers into `data.modelId`, and prefer plain model ids over a LoRA's own id inside a graph.
      
      ## Emit, validate, report
      
      1. Author `editor_info` per the main reference: persisted edges point consumer to producer, handle ids follow `${nodeId}-source-${name}` and `${nodeId}-target-${name}`, `inputKeys` keeps the source's exposed-input order, layout runs left to right in the source's topological order.
      2. `workflow_create` with the source's name (or one the user confirms when the export carries none) and a `description` that lists every unmapped node and dropped parameter, so the gap travels with the workflow into the editor. A failed create may still have created a draft: `workflow_update` on the id in the error, never a second create.
      3. `workflow_publish`, then `workflow_run` with `dry_run=true` and representative `inputs`. It prices the whole graph and runs the real validator, so a wiring error surfaces here at zero cost; fix `editor_info` and publish again. Quote the price before any real run.
      4. Report, per node: the Scenario member it received, the parameters that did not transfer, the nodes that did not map, and what the user finishes in the editor. The user's test inputs, not the source tool's sample renders, decide whether the translation holds.
      
      ## Common mistakes
      
      - Keeping one Scenario node per source node: ComfyUI plumbing collapses into the generator, and preview nodes vanish into an output pin.
      - Reading the source's edge direction into `editor_info`: Scenario persists edges the other way.
      - Mapping a source LoRA to a Scenario LoRA by name and running it by its own id: a LoRA's schema carries `runs_as`, and LoRA ids inside graphs were not validated at authoring time.
      - Copying a setting because its number fits the target range: `cfg` and `guidance` can mean different things. Transfer a value only when the schema establishes the same meaning and range; otherwise omit it, keep the target default, and list the source setting as dropped. A caveat about changed behavior does not make copying it valid.
      - Delivering the graph without the dry run: it is the only validator that reads the whole `flow`, and a customer's pipeline fails on a handle nobody wired.
      
  • scripts
    • fetch_workflow_examples.py 8 KB
      #!/usr/bin/env python3
      # /// script
      # requires-python = ">=3.9"
      # dependencies = ["scenario-sdk"]
      # ///
      """Fetch public featured Scenario workflows as structural wiring examples.
      
      Built on the official Scenario Python SDK (scenario-sdk on PyPI): run it
      with `uv run scripts/fetch_workflow_examples.py` (uv reads the inline
      dependency block above) or `pip install scenario-sdk` first. Credentials
      come from the SDK's standard environment variables, SCENARIO_SDK_API_KEY
      and SCENARIO_SDK_API_SECRET.
      
      Lists public workflows, keeps the ones tagged featured (or sc:featured),
      and writes one trimmed JSON file per workflow. Public listings never
      include flow or editorInfo (per the API reference, the full parameter is
      ignored there), so each tagged hit's editor graph comes from a per-id
      retrieve. The trim keeps node types, edge wiring, and input keys, and
      drops content values (prompts, asset ids, parameter values): each file is
      a structural reference for wiring a similar workflow, never content to
      reuse.
      
          SCENARIO_SDK_API_KEY=... SCENARIO_SDK_API_SECRET=... \\
              uv run scripts/fetch_workflow_examples.py --output-dir workflow-examples
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import os
      import sys
      
      import scenario_sdk
      from scenario_sdk import Scenario
      
      # Documented maximum page size (default is 50).
      PAGE_SIZE = 100
      
      DEFAULT_TAGS = ("featured", "sc:featured")
      
      # Structural node data worth keeping: pin flags, model binding, and the
      # forEachEnd loop pairing. Everything else in a node's data is content
      # (prompt text, asset references, parameter values) and is dropped.
      NODE_DATA_KEYS = ("type", "modelId", "title", "isInput", "isOutput", "parentNodeId")
      
      EDGE_KEYS = ("source", "sourceHandle", "target", "targetHandle")
      
      
      def iter_public_workflows(client: Scenario, max_pages: int, page_size: int = PAGE_SIZE):
          page = client.workflows.list(privacy="public", page_size=page_size)
          pages_walked = 1
          while True:
              yield from page.workflows
              if pages_walked >= max_pages or not page.has_next_page():
                  break
              page = page.get_next_page()
              pages_walked += 1
      
      
      def _pick(source: dict, keys: tuple[str, ...]) -> dict:
          return {key: source[key] for key in keys if source.get(key) is not None}
      
      
      def trim_node(node: dict) -> dict:
          trimmed = _pick(node, ("id", "type"))
          data = node.get("data")
          if isinstance(data, dict):
              kept = _pick(data, NODE_DATA_KEYS)
              if kept:
                  trimmed["data"] = kept
          return trimmed
      
      
      def trim_edge(edge: dict) -> dict:
          return _pick(edge, EDGE_KEYS)
      
      
      def input_keys(workflow, editor_info: dict) -> list[str]:
          # The authoring grammar keeps the ordered pin list at editorInfo.inputKeys
          # (verified against live records); check there before any fallback.
          keys = editor_info.get("inputKeys")
          if isinstance(keys, list) and keys and all(isinstance(k, str) for k in keys):
              return keys
          # Fall back to the typed inputs carried by the workflow record.
          names = []
          for item in getattr(workflow, "inputs", None) or []:
              name = getattr(item, "name", None)
              if isinstance(name, str):
                  names.append(name)
          return names
      
      
      def trim_workflow(workflow) -> dict | None:
          """Trim a workflow record to its structural wiring, or None without an editor graph."""
          editor_info = getattr(workflow, "editor_info", None)
          if not isinstance(editor_info, dict):
              return None
          nodes = editor_info.get("nodes")
          if not isinstance(nodes, list) or not nodes:
              return None
          edges = editor_info.get("edges")
          if not isinstance(edges, list):
              edges = []
          return {
              "id": workflow.id,
              "name": workflow.name or "",
              "description": workflow.description or "",
              "nodes": [trim_node(node) for node in nodes if isinstance(node, dict)],
              "edges": [trim_edge(edge) for edge in edges if isinstance(edge, dict)],
              "inputKeys": input_keys(workflow, editor_info),
          }
      
      
      def write_example(output_dir: str, trimmed: dict) -> str:
          os.makedirs(output_dir, exist_ok=True)
          # Defensive: an id must never escape the output directory.
          safe_id = str(trimmed["id"]).replace("/", "_").replace("\\", "_")
          path = os.path.join(output_dir, f"{safe_id}.json")
          with open(path, "w", encoding="utf-8") as handle:
              json.dump(trimmed, handle, indent=2, ensure_ascii=False)
              handle.write("\n")
          return path
      
      
      def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
          parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
          parser.add_argument("--output-dir", default="./workflow-examples", help="where the trimmed JSON files go")
          parser.add_argument("--max-pages", type=int, default=5, help="page cap for the listing (default 5)")
          parser.add_argument(
              "--tag",
              action="append",
              help="tag to keep, repeatable (default: featured and sc:featured)",
          )
          args = parser.parse_args(argv)
          if args.max_pages < 1:
              parser.error("--max-pages must be at least 1")
          return args
      
      
      def describe_error(err: Exception) -> str:
          if isinstance(err, (scenario_sdk.AuthenticationError, scenario_sdk.PermissionDeniedError)):
              return (
                  f"Scenario API refused the credentials (HTTP {err.status_code}):"
                  " check SCENARIO_SDK_API_KEY / SCENARIO_SDK_API_SECRET"
              )
          if isinstance(err, scenario_sdk.APIStatusError):
              return f"Scenario API request failed with HTTP {err.status_code}"
          return f"Scenario API request failed: {err}"
      
      
      def main(argv: list[str] | None = None) -> int:
          args = parse_args(argv)
          key = os.environ.get("SCENARIO_SDK_API_KEY", "").strip()
          secret = os.environ.get("SCENARIO_SDK_API_SECRET", "").strip()
          if not key or not secret:
              print(
                  "SCENARIO_SDK_API_KEY and SCENARIO_SDK_API_SECRET must be set: the"
                  " official scenario-sdk reads your Scenario API key and secret from"
                  " these environment variables.",
                  file=sys.stderr,
              )
              return 1
      
          client = Scenario()
          wanted = set(args.tag) if args.tag else set(DEFAULT_TAGS)
          kept = 0
          try:
              for workflow in iter_public_workflows(client, args.max_pages):
                  # Tag filtering happens client side: the documented tags query
                  # parameter does not state how multiple tags combine, and this
                  # needs any-of semantics.
                  if not set(workflow.tag_set or []) & wanted:
                      continue
                  trimmed = trim_workflow(workflow)
                  if trimmed is None:
                      # Public listings never include the editor graph (the API
                      # reference: full is ignored there), so the full record is
                      # the only place to read it from.
                      try:
                          trimmed = trim_workflow(client.workflows.retrieve(workflow.id).workflow)
                      except scenario_sdk.APIError as err:
                          # APIError also covers connection and timeout errors, which
                          # are siblings of APIStatusError: one flaky retrieve must
                          # skip that workflow, never abort the whole export.
                          print(f"skipping {workflow.id}: {describe_error(err)}", file=sys.stderr)
                          continue
                  if trimmed is None:
                      print(f"skipping {workflow.id}: tagged but has no editor graph", file=sys.stderr)
                      continue
                  path = write_example(args.output_dir, trimmed)
                  kept += 1
                  print(
                      f"{trimmed['id']}: {trimmed['name']}"
                      f" ({len(trimmed['nodes'])} nodes, {len(trimmed['edges'])} edges,"
                      f" {len(trimmed['inputKeys'])} inputs) -> {path}"
                  )
          except scenario_sdk.ScenarioError as err:
              print(describe_error(err), file=sys.stderr)
              return 1
      
          if kept == 0:
              print(f"No public workflows tagged {sorted(wanted)} with an editor graph were found.")
          print(f"Wrote {kept} workflow example(s) to {args.output_dir}")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • SKILL.md 7.5 KB
    ---
    name: scenario-workflow-authoring
    description: "Use when a task involves creating or editing a Scenario workflow graph through MCP: building an app from a brief, adding or rewiring nodes (models, prompts, approval gates, loops), authoring editor_info, publishing, unpublishing or renaming, importing an exported workflow JSON, migrating a graph built in Weavy, ComfyUI, or another node tool, copying a workflow, or turning a prompt chain into an app. Running or pricing a workflow is scenario-workflows. Keywords: node graph, editor_info, CEL."
    license: MIT
    ---
    
    # Scenario Workflow Authoring
    
    ## Overview
    
    A workflow has two representations: `editor_info` (the editable node graph: `nodes`, `edges`, `inputKeys`) and `flow` (the compiled runnable form). Authoring through MCP means writing the whole `editor_info` document: there are no per-node editing tools; every change is a read, modify, write of the full graph through `workflow_create` or `workflow_update`. Never hand-write `flow`: `workflow_publish` compiles `editor_info` into it and flips status to `ready`. Editing a ready workflow's `editor_info` leaves the stale `flow` running until you publish again.
    
    Read [references/editor-info.md](references/editor-info.md) before writing any graph: it holds the node type vocabulary, the node choice doctrine (when an `llm` node is legitimate), the edge direction rule, per-node data contracts, and a validated minimal example. Create, update, publish, copy and delete live in the tool catalog (`scenario_tools_search` plus the matching executor, see the `scenario` skill). `workflows_list`, `workflow_get` and `workflow_run` are direct tools: scope and `dry_run` go in their top-level arguments, never an executor wrapper. Running and pricing: the `scenario-workflows` skill. If a sibling skill named here is missing from your available skills, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); unattended, proceed from tool schemas and flag the gap.
    
    ## Quick reference
    
    | Step              | Call                                 | Notes                                    |
    | ----------------- | ------------------------------------ | ---------------------------------------- |
    | 1. Study a graph  | `workflow_get` on a working workflow | Copy the shape, never ids                |
    | 2. Model contract | `model_schema_get`                   | Handle names and required inputs         |
    | 3. Author         | `editor_info` + `inputs_definition`  | Per the reference file                   |
    | 4. Create         | `workflow_create`                    | Non-atomic, see below                    |
    | 5. Publish        | `workflow_publish`                   | Compiles `flow`, needs input+output pins |
    | 6. Validate       | `workflow_run` with `dry_run=true`   | Prices and runs the real validator       |
    
    `workflow_create` is two calls under the hood: a failed create may still have created a draft whose id is in the error. Recover with `workflow_update` on that id; re-creating duplicates. Seed step 1 with `workflow_get`: it returns the full graph of any workflow whose id you have, public ones included (an id or app URL the user supplies, or your own team's from `workflows_list`). To find a public template, use `search` with `target="workflows"`, `public=true`, a keyword `query`, `limit=3`, and your scope; for example, `query="image"` with raw `filter: 'status = "ready"'`. Read ids from `workflows`, then fetch the chosen graph with `workflow_get`; search hits are summaries, not graph documents. Workflow search supports keyword text and filters only, so omit image and semantic options. Use `workflows_list` for browsing your saved workflows. [scripts/fetch_workflow_examples.py](scripts/fetch_workflow_examples.py) bulk-exports trimmed featured-workflow graphs for maintainers (setup in its header).
    
    ## Worked example: a text-to-image app
    
    1. `recommend` with `capability: "txt2img"` and the user's brief as `prompt`, following the `scenario` skill's `next_step` discipline, then `model_schema_get`: its input names become the model node's handle names, and its `required` flag marks what must be wired. Use `search` instead when the user names a model.
    2. Author `editor_info`: `text1` with `data.isInput: true`, `model1` with `type: "model"`, `data.modelId` and `data.isOutput: true`, one edge from `model1`'s input to `text1`'s output (edges name the downstream node as `source`, see the reference), `inputKeys: ["text1"]`.
    3. `workflow_create` with `name`, `editor_info`, and `inputs_definition` naming `text1` as a string input. The published input key is the node id, which is why run inputs have names like `text1`.
    4. `workflow_publish`, then `workflow_run` with `dry_run=true` to validate and price. Fix the graph and re-publish if validation fails.
    
    ## One output per item needs a loop
    
    A multiple-asset input wired straight into a generative model's reference field is one generation conditioned on every item at once: ten product shots in come back as one image blending them, not ten edits. Field names and cardinality are per model, so read them off `model_schema_get` (`array: true` marks a list), and read the field's description as well: a few utility tools take a list and return one output per item, which needs no loop. When each item needs its own result, put the model inside a `forEach` over the list and read the outputs from the `forEachEnd` (wiring in the reference's forEach section); keep the direct wiring only when the items are meant as joint references for a single output. Inside the loop the current item feeds an array field as a one-item list. `forEach` runs one billed model job per item, but the dry run prices one iteration whatever the list length (observed: one quote for one or two items, twice that billed for two), so multiply the quote by the item count before quoting the user.
    
    ## Migrating a graph from another node tool
    
    A pipeline exported by Weavy, ComfyUI, or another node editor does not import: only Scenario's own export round-trips. It is translated node by node, then created, published, and dry-run as above. The mapping table, member resolution, and the report the user gets are in [references/foreign-graph-import.md](references/foreign-graph-import.md); read it before touching such an export, since its first rule is to reduce the file to a table locally rather than paste it into the conversation.
    
    ## Common mistakes
    
    - Writing UI palette names as node types: persisted types are the camelCase vocabulary in the reference, and every generator is `type: "model"`.
    - Wiring edges producer to consumer: persisted edges point the other way.
    - Expecting an `editor_info` update to change a live app without re-publishing.
    - Retrying a failed `workflow_create` with a second create instead of `workflow_update` on the id from the error.
    - Wiring a batch of assets into one model input and expecting one output per item: that is one generation over all of them; loop with `forEach`.
    - Publishing with no pins: at least one `data.isInput` node listed in `inputKeys` and one `data.isOutput` node.
    - Gating a text node in front of a builder or model: a branch skips only the node wired to its handle, so the consumer stays pending and the job never completes. Gate the node that does the work, or use a CEL ternary for conditional prompt text, per the reference's `ifElse` section.
    - Double-quoted CEL literals: they evaluate but corrupt the canvas editor, single quotes only.
    - Sending `workflow_id` to `workflow_copy`: get, update, publish, run and delete take `workflow_id`, but copy takes `source_workflow_id`; the copy inherits everything verbatim and needs its own publish.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related