agentsclimarketplace

Builder agent

Skill itential/builder-skills/.claude/skills/builder-agent

Use this skill when someone has an approved solution design and is ready to build. Trigger it for phrases like "solution design is approved", "go ahead and build", "implement the design", "create the workflows", "build everything per the design", or "the design is locked — implement it". Also trigger it when a build is failing mid-way and needs debugging, or when /qa-agent hands back a failing test case for a fix. This skill implements the approved solution-design.md end-to-end — creating all workflows, templates, projects, and configs, and testing each component individually. If the user has a solution-design.md and wants to turn it into working automation, this is the right skill. Invoke after /solution-arch-agent produces an approved solution-design.md. Hands off to /qa-agent once the build is complete — /qa-agent owns acceptance testing and the as-built record.From its SKILL.md

Install
npx -y skills add itential/builder-skills --skill builder-agent

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 13 stars13 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

144.5 KB, as published. Nobody here has run it

Builder Agent

Stage: Build Owns: Implementing the approved design. Receives from: /solution-architecture (approved solution-design.md + complete workspace) Produces: Deployed assets (workflows, templates, projects) Hands off to: /qa-agent (acceptance testing + as-built record)


Stage Expectations

Build

Engineer providesApproved solution-design.md (all platform data already present in workspace)
Agent doesBuilds all components per design, tests each piece individually, reports delivery outcomes
Engineer actionReviews delivery and resolves open build questions
DeliverableDeployed assets (workflows, templates, projects)
Customer receivesDelivered project — all workflows, templates, and configs individually tested, packaged, and access granted. Formal acceptance testing and sign-off happen next, in /qa-agent.

Build implements the approved plan. The builder never re-pulls discovery data — it uses what the Solution Architecture Agent left in the workspace. If any required file is missing, stop and surface as an upstream failure.

Testing during Build is component-level, not acceptance-level. "Test each piece" here means confirming a task or workflow runs without error and wires variables correctly — not verifying the delivered solution satisfies the customer's stated acceptance criteria end-to-end. That's /qa-agent's job, running against the completed build with real test data the engineer confirms. Don't skip component testing because "QA will catch it" — a structurally broken workflow wastes a live acceptance-test run.


This skill covers everything needed to build and test Itential automation assets: projects, workflows, templates, and command templates.

Workspace Contract

The builder receives a complete workspace. All discovery data is already present. Solution-design (or setup for explore mode) has already pulled everything.

Required files (must exist before build starts):

{use-case}/
  .auth.json              ← auth token
  .env                    ← credentials (for re-auth if token expires)
  openapi.json            ← API reference (pulled by solution-arch-agent or explore)
  tasks.json              ← task catalog (pulled by solution-arch-agent or explore)
  apps.json               ← app/adapter type names (pulled by solution-arch-agent or explore)
  adapters.json           ← adapter instances (pulled by solution-arch-agent or explore)
  applications.json       ← app health (pulled by solution-arch-agent or explore)

May also exist (spec-contingent):

  use-case-memory.md      ← living context: IDs, decisions, gotchas, open items — READ THIS FIRST
  customer-spec.md        ← approved HLD (Requirements)
  feasibility.md          ← approved feasibility assessment
  customer-context.md     ← business rules (if provided)
  solution-design.md      ← approved Solution Design / LLD
  devices.json            ← device inventory
  workflows.json          ← existing workflows
  device-groups.json      ← device groups
  task-schemas.json       ← fetched on demand during build (append-only, never pre-populated)
  test-report.md          ← if returning from /qa-agent with a failing test case — has the exact case ID, expected vs actual, and evidence

The builder NEVER re-pulls bootstrap or discovery data. If tasks.json, apps.json, or adapters.json is missing, stop and tell the user — that's an upstream failure, not something to silently fix.

Exception — .auth.json bootstrap: If .auth.json is missing but .env exists with AUTH_METHOD=oauth, CLIENT_ID, and CLIENT_SECRET, the builder MUST authenticate and create .auth.json before proceeding — do NOT stop and report an upstream failure. See the Bootstrap Authentication section below.

The only API calls the builder makes are:

  • Auth bootstrap — POST /oauth/token when .auth.json is missing (see below)
  • Create — POST workflows, templates, projects
  • Update — PUT to edit assets
  • Test — POST jobs/start, GET job status
  • Schema fetch — task schemas not yet in task-schemas.json (append to file after fetching)
  • Re-auth — if token expires, use .env to refresh .auth.json

Bootstrap Authentication

When .auth.json is missing but .env has AUTH_METHOD=oauth with CLIENT_ID and CLIENT_SECRET, authenticate automatically before proceeding.

The correct Itential SaaS/Cloud OAuth endpoint is:

POST {PLATFORM_URL}/oauth/token
Content-Type: application/x-www-form-urlencoded

Body (form-encoded, NOT JSON — JSON returns 415):

grant_type=client_credentials&client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}

Critical:

  • Content-Type MUST be application/x-www-form-urlencoded — NOT application/json. Sending JSON returns HTTP 415.
  • The /login endpoint does NOT support OAuth client credentials on SaaS instances — always use /oauth/token.
  • On success, write .auth.json with the token so all subsequent API calls just work.

Helper script: ${CLAUDE_PLUGIN_ROOT}/scripts/oauth_bootstrap.py — reads .env, POSTs to /oauth/token, writes .auth.json. The builder should run this automatically when .auth.json is missing and .env has AUTH_METHOD=oauth.


Build Lifecycle

0. Memory file              → create or read use-cases/{name}/use-case-memory.md
1. Decompose                → identify parent/child split before writing any code
2. Create project           → container for all assets
3. Discover tasks           → search tasks.json, fetch schemas
4. Build children first     → each child workflow independently testable
5. Build templates          → Jinja2 (config gen) or TextFSM (output parsing)
6. Build command templates  → MOP pre/post checks with validation rules
7. Build orchestrator last  → parent wires tested children via childJob
8. Add assets to project    → move/copy into the project
9. Set project membership   → resolve spec members, PATCH immediately after import
10. Test each component     → jobs/start, check results (component-level, not acceptance-level)
11. Debug                   → check job.error, filesystem-first
12. Reconcile               → diff built vs designed, update artifacts
13. Update memory file      → record IDs, decisions, gotchas, test results, open items
14. Update this skill       → if you hit a platform behavior not documented here, add it before closing out
15. Hand off to /qa-agent   → build is complete; real IDs are in solution-design.md §D

Step 0 — memory file:

At the start of every session, check for use-cases/{use-case}/use-case-memory.md:

  • Exists → read it before doing anything else. It tells you the platform, project ID, what's already built, decisions made, and open items. Don't re-discover what's already documented.
  • Missing → create it now from ${CLAUDE_PLUGIN_ROOT}/helpers/use-case-memory.md template. Fill in Platform URL, Stage: build, Status: active immediately.

Step 13 — update memory file after every session:

Before closing out any build session, update use-case-memory.md with:

  • Any new asset IDs (project ID, workflow UUIDs, transformation IDs, adapter names)
  • Any architectural decisions made and why
  • Any gotchas hit and how they were fixed
  • Test results (date, what was tested, outcome)
  • Updated open items list
  • Stage and Status if they changed — mid-build, Stage stays build; only update it to test at the Step 15 handoff

The memory file is what makes it possible to pick up a use-case after weeks without re-discovering everything from scratch.

Step 14 — how to update this skill:

  • New platform behavior (error shape, field constraint, task gotcha) → add detail to the relevant body section (### query, ### childJob, ### Projects, etc.), then add a one-liner to the Gotchas pre-flight list under the right category.
  • New pattern or workflow recipe → add to ## Workflow Patterns and, if the pattern is reusable, export the project from the platform and save it to ${CLAUDE_PLUGIN_ROOT}/helpers/assets/. Add a row to the Helper Templates table in this file pointing to it.
  • Do NOT create a new top-level section for a single finding — put it where a builder would look when working on that topic.

Step 15 — hand off to /qa-agent: Once every component has been individually tested and solution-design.md Section D has real IDs (project ID, workflow IDs) instead of placeholders, the build is complete. Update use-case-memory.md to Stage: test before ending the session. Tell the engineer the build is done and route to /qa-agent for acceptance testing and the as-built record — don't write as-built.md here.

If /qa-agent hands back a failing test case: fix the specific issue it identifies (it gives you the case ID, expected vs. actual, and evidence — a job ID or static-check output). Don't re-examine the whole build; the failure report tells you exactly what broke. Once fixed, tell the engineer so /qa-agent can re-run just that case.


Guides

Guide 1: Build a workflow end-to-end

Follow these steps in order. Do not skip any step.


STOP. Before writing a single line of task JSON — run these commands.

The asset projects in helpers/assets/ are real, production-tested imports. Read them first. Do not guess task structure from memory. Do not copy from helpers/create/ for task bodies. helpers/create/ is for API wrappers (project/workflow creation endpoints) only — not task JSON.

# 1. Find which asset project matches your use case
ls ${CLAUDE_PLUGIN_ROOT}/helpers/assets/

# 2. Extract the workflow most similar to what you're building
jq '[.components[] | select(.type=="workflow")] | .[].document.name' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-servicenow.json

# 3. Read its full task map — this is your reference
jq '[.components[] | select(.type=="workflow") | select(.document.name | test("WORKFLOW_NAME"; "i"))] | first | .document | {tasks, transitions}' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-servicenow.json

# 4. Extract the specific task type you need
jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "TASK_NAME")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-servicenow.json

Replace vendor-servicenow.json with whichever asset file best matches your use case:

  • Adapter tasks (ServiceNow, Infoblox) → vendor-servicenow.json, vendor-infoblox-nios-ddi.json
  • Network device tasks (CLI, MOP) → vendor-cisco-ios.json, vendor-arista-eos.json, vendor-juniper-junos.json
  • IPAM/inventory → vendor-netbox.json
  • Data transformations → itential-platform-data-manipulation.json
  • Config management (RunCommandTemplate, itential_cli) → itential-platform-configuration-management.json
  • LCM action workflows → helpers/assets/lcm/lcm-vxlan-fabric-services-project.json

Step 0: Decompose before you build.

Before writing any JSON, identify the parent/child split from the solution design. Ask for each phase:

  • Can this phase be run and tested on its own? → Child workflow
  • Does it loop over multiple items (devices, records)? → Child workflow with loopType
  • Is it reusable across other use cases? → Child workflow
  • Is it a simple sequential step with no independent test value? → Task in orchestrator

Build order is always: children first, orchestrator last. The orchestrator is just childJob calls to tested children — it should not contain raw adapter tasks unless there is no logical way to split.

Step 0a: Before building a per-item transform loop, default to runCode + Enable Query instead of chaining WorkFlowEngine utility tasks.

If a phase does ANY of the following, don't reach for forEach + query×N + evaluation + newVariable + merge + push/objectToString/join/makeData — build it as a single runCode (Python, GatewayManager) task instead:

  • Loops over a list, extracting 2+ fields per item
  • Branches per item on a field value (manufacturer, status, type) to pick between two or more output shapes
  • Reshapes each item into a different structure before handing it to the next task

One real example collapsed 15–17 WorkFlowEngine tasks into 2 (runCode + one query to pull the result back out) — see the helper asset below. Then check whether that trailing query task is even necessary: if its only job is handing ONE field of an object to ONE downstream task's field, use Enable Query instead (a decorator on the consuming field, no task at all — see ### "Enable Query" below). Confirmed to work on both top-level fields and fields nested inside another field's object (e.g. runCode's data.devices, runService's params.devices) — not confirmed on evaluation operands ({"task":"job","variable":"x"} is a different reference shape than a plain $var string; don't assume it works there without testing).

When NOT to use runCode: the per-item work needs to call another platform task (adapter, app method, childJob) for each item — runCode only runs Python against the data payload it's given; it cannot invoke other Itential tasks. Use forEach+childJob/adapter-task for that instead.

Read the real, verified example before building any transform loop:

# See the whole pattern: NetBox devices -> per-vendor secret mapping -> reshaped records,
# via one runCode task + Enable Query, both the "reuse a script" and "native tasks" versions
jq '[.components[] | select(.type=="workflow")] | .[].document.name' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/netbox-inventory-sync-runcode-enablequery.json

# Extract the runCode task itself (clusterId, language, code, data, safety, packages)
jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "runCode")] | .[0].value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/netbox-inventory-sync-runcode-enablequery.json

# See an Enable Query decorator in place on a real task (top-level AND nested-field examples)
jq '[.components[].document.tasks // {} | to_entries[] | select(.value.variables.decorators // [] | length > 0)] | .[] | {task: .value.name, incoming: .value.variables.incoming, decorators: .value.variables.decorators}' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/netbox-inventory-sync-runcode-enablequery.json

Read a full workflow from asset projects before building any multi-workflow solution:

# Parent → childJob → evaluation pattern
jq '[.components[] | select(.type=="workflow") | select(.document.name | test("Upgrade|Runner"))] | first | .document | {name,tasks,transitions}' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-cisco-ios.json

# childJob loop with data_array
jq '[.components[] | select(.type=="workflow") | select(.document.name | test("Chunk|Loop"))] | first | .document | {name,tasks,transitions}' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/itential-platform-configuration-management.json

Step 1: Find tasks. Search tasks.json for the tasks you need:

jq '.[] | select(.name | test("keyword"; "i")) | {name, app, type, location, canvasName, displayName}' {use-case}/tasks.json

Step 2: Resolve adapter app names. For adapter tasks, the app in tasks.json is WRONG. Look up the correct name:

jq '.[] | select(.name | test("keyword"; "i")) | {name, type}' {use-case}/apps.json

Also get the adapter instance name:

jq '.results[] | select(.package_id | test("keyword"; "i")) | {id, state}' {use-case}/adapters.json

You now have three values: app (from apps.json), adapter_id (from adapters.json .id), and displayName (from tasks.json).

Step 3: Fetch task schemas. Get the full input/output schema for every task you'll use:

POST /automation-studio/multipleTaskDetails?dereferenceSchemas=true
{
  "inputsArray": [
    {"location": "Adapter", "pckg": "Servicenow", "method": "createChangeRequest"},
    {"location": "Application", "pckg": "WorkFlowEngine", "method": "query"}
  ]
}

Use the pckg value from apps.json (Step 2), NOT tasks.json. Save the response to {use-case}/task-schemas.json.

Step 4: Map schema to workflow task JSON. For each task, transform the schema into a workflow task:

Schema response:

{
  "name": "createChangeRequest",
  "variables": {
    "incoming": {
      "body": {"type": "object", "description": "Request body"}
    },
    "outgoing": {
      "result": {"type": "object", "description": "Response"}
    }
  }
}

Becomes this workflow task (extract a real adapter task from an asset project first — e.g. jq '[.components[].document.tasks // {} | to_entries[] | select(.value.location == "Adapter")] | first | .value' ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-servicenow.json):

{
  "a1b2": {
    "name": "createChangeRequest",
    "canvasName": "createChangeRequest",
    "summary": "Create Change Ticket",
    "description": "Creates a ServiceNow change request",
    "location": "Adapter",
    "locationType": "Servicenow",
    "app": "Servicenow",
    "type": "automatic",
    "displayName": "ServiceNow",
    "variables": {
      "incoming": {
        "body": "$var.e1a1.merged_object",
        "adapter_id": "$var.job.adapter_id"
      },
      "outgoing": {
        "result": null
      },
      "error": "",
      "decorators": []
    },
    "groups": [],
    "actor": "Pronghorn",
    "scheduled": false,
    "nodeLocation": {"x": 700, "y": 600}
  }
}

Mapping rules:

  • name, canvasName → from tasks.json
  • app, locationType → from apps.json (NOT tasks.json)
  • displayName → from tasks.json
  • location → "Adapter" or "Application" (from tasks.json)
  • type → from tasks.json directly — do not guess. It is per-task, not per-app. Read it alongside name, app, location, and canvasName: jq '.[] | select(.name == "taskName") | {name, app, type, canvasName, location}' tasks.json
  • actor → "Pronghorn" for all tasks except childJob (which uses "job")
  • incoming → each schema key becomes a variable. Wire with $var for top-level values
  • outgoing → set to null (capture later with $var.taskId.outVar)
  • Add adapter_id to incoming for adapter tasks (not in schema, always required)
  • Add error and decorators to variables block

Step 5: Handle object inputs. If a task's incoming variable is type: "object" (like body), you CANNOT put $var references inside it — they won't resolve. Use a merge task before it:

{
  "e1a1": {
    "name": "merge",
    "canvasName": "merge",
    "summary": "Build Request Body",
    "app": "WorkFlowEngine",
    "type": "operation",
    "variables": {
      "incoming": {
        "data_to_merge": [
          {"key": "short_description", "value": {"task": "job", "variable": "short_description"}},
          {"key": "description", "value": {"task": "job", "variable": "description"}}
        ]
      },
      "outgoing": {"merged_object": null}
    },
    "actor": "Pronghorn"
  }
}

Then wire the adapter task's body to "$var.e1a1.merged_object".

Step 6: Handle opaque schemas. Some task schemas show body: {type: "object"} with no inner field details. The adapter validates internally. To discover required fields:

  1. Try creating with minimal fields — the error message lists what's missing (e.g., "must have required property 'summary'")
  2. Check openapi.json for the adapter's endpoint schema
  3. Call the adapter directly: POST /{adapter_id}/{method} with {} body — read the validation error

Step 7: Wire transitions. Every adapter task needs BOTH success and error transitions:

"transitions": {
  "a1b2": {
    "b2c3": {"type": "standard", "state": "success"},
    "ef01": {"type": "standard", "state": "error"}
  }
}

If both success and error need to reach workflow_end, route error to an intermediate newVariable task first (JSON can't have duplicate keys).

Step 8: Add inputSchema/outputSchema. List all job variables the workflow expects as input and produces as output.

Step 9: Pre-submit checklist.

  • Task IDs are hex-only ([0-9a-f]{1,4})
  • app and locationType values come from apps.json .name, NOT tasks.json and NOT the adapter instance name (e.g., EmailOpensource not email)
  • adapter_id is the adapter instance name (e.g., email), NOT the type name
  • adapter_id values come from adapters.json .results[].id — NEVER from the spec's adapter identity table. The spec is a design document; adapters.json is the source of truth for the target environment.
  • canvasName values come from tasks.json canvasName field
  • Every adapter task has adapter_id in incoming
  • Every adapter task has an error transition
  • evaluation tasks have both success AND failure transitions
  • evaluation operators are from the closed enum (contains, !contains, <, <=, >, >=, ==, !=) — no others exist
  • evaluation operand_2 literal values containing regex metacharacters (., (, ), [, ], ?, +, *, |) are properly escaped, OR stored in a newVariable constant-holder task to avoid incomingRefs cache issues after API PUT
  • No $var.<taskId>.<out> references inside nested forEach bodies — use $var.job.<varName> instead
  • Incoming variable types match task schema exactly (arrays for to/cc/bcc, numbers for page/pageSize, etc.)
  • No $var references inside nested objects (use merge/makeData)
  • merge uses "variable", childJob uses "value"
  • No {task:"job", variable:"x"} in merge/childJob for workflow-internal variables — {task:"job"} refs add x to inputSchema.required, prompting operators for values that should be internal. Use the producing task ref instead (query→return_data, newVariable→value, makeData→output, merge→merged_object)
  • If a query downstream of a childJob returns null despite the child succeeding: check whether "obj": "$var.<childJobId>.job_details" is resolving — on some platform versions it is treated as a literal string. Fix: insert a merge task between childJob and query using {"task": "<childJobId>", "variable": "job_details"} in data_to_merge, then point obj to $var.<mergeId>.merged_object (see Guide 4)
  • childJob has actor: "job", all others have actor: "Pronghorn"
  • workflow_end transition is empty {}
  • Canvas layout follows the vertical spacing convention — non-forked sequences on a constant-x spine, fork branches offset to spine±264 and stay in their own column until convergence
  • No transition lines cross task nodes (the spine column is empty between a fork and its convergence point)
  • Sequential y-delta ~108px (tight grid)
  • LCM Create actions only: the instance-write merge task's data_to_merge covers every field in the resource model's schema.required array — missing even one field causes an instance write failure after provisioning (resources are orphaned from LCM). Read the model's schema.required before building the merge task: jq '.schema.required' helpers/assets/lcm/<model>.json
  • ViewData manual tasks: view is a top-level field; incoming.variables is present (even if {}); displayName: "Tools", no actor field
  • restCall downstream query: path targets body field directly (e.g., "access_token") — NOT "response.access_token" (restCall has no wrapper, unlike adapter tasks)
  • childJob loop: if child workflow has inputSchema.required fields beyond what each data_array element contains, use the forEach enrichment pattern (forEach → merge → arrayPush) to add shared fields into each element before the childJob loop; set variables: {} on the childJob
  • forEach body: incoming contains ONLY data_array (no job_id); loop body tasks have no external error transitions; last body task has an empty {} transition; $var.job.<varName> inside loop body instead of $var.<taskId>.<output>
  • makeData with childJob-sourced merge: if a merge task references a childJob variable, do NOT wire that merge's merged_object into makeData.incoming.variables — use query to extract individual values first
  • objectToString: omit replacer/space from incoming if unused — do NOT pass replacer: [] (silently whitelists zero properties, output is always {}) or null (validate-time type warning)

Complete working example: Read the ServiceNow "Create Change Request" workflow before building — it demonstrates merge → adapter create → query → adapter update with error transitions:

jq '[.components[] | select(.type=="workflow") | select(.document.name | test("Create Change"))] | first | .document' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-servicenow.json

How the example works — what each task does and why:

workflow_start → e1a1 (merge) → a1b2 (createChangeRequest) → b2c3 (query) → c3d4 (updateChangeRequest) → workflow_end
                                  ↓ error                                      ↓ error
                                ef01 (newVariable) ────────────────────────────→ workflow_end
Task IDTaskWhy it's thereKey fields
e1a1mergeBuilds the body object. $var can't resolve inside nested objects, so merge assembles the object from individual variables.data_to_merge uses "variable" (NOT "value"). Needs at least 2 items.
a1b2createChangeRequestAdapter call. body wired to $var.e1a1.merged_object (merge output).app/locationType from apps.json (Servicenow), NOT tasks.json (ServiceNow). adapter_id added manually (not in schema). type: "automatic".
b2c3queryExtracts the change ID from the adapter response.query: "response.id" — adapters transform responses, don't assume native API shape.
c3d4updateChangeRequestSecond adapter call using the extracted ID.changeId wired from $var.job.changeId (set by query's outgoing).
ef01newVariableError handler. Adapter error transitions route here.Exists because JSON can't have duplicate keys — can't route both success and error to workflow_end from the same task.

Field mapping — where each value comes from:

Workflow task fieldSourceExample
nametasks.json .namecreateChangeRequest
canvasNametasks.json .canvasNamecreateChangeRequest (can differ: arrayPush→push)
appapps.json .name (adapter type name)Servicenow, EmailOpensource (NOT email, NOT ServiceNow from tasks.json)
locationTypeSame as app for adapters, null for applicationsServicenow, EmailOpensource
displayNametasks.json .displayNameServiceNow, email
locationtasks.json .locationAdapter or Application
typetasks.json .type — read directly, do not guess (per-task, not per-app)varies
actor"Pronghorn" always, except childJob which uses "job"Pronghorn
adapter_idadapters.json .results[].id (adapter instance name)servicenow-prod, email — this goes in incoming, NOT in the task-level app field
incoming varsFrom task schema (multipleTaskDetails)body, changeId
outgoing varsFrom task schema, set to nullresult

Guide 2: Debug a failed job

Step 1: Get the job:

GET /operations-manager/jobs/{jobId}

Step 2: Check data.status. If "error", read data.error[]:

data.error[].task → failing task ID
data.error[].message.IAPerror.displayString → human-readable error

Step 3: Match the error to a fix:

Error messageCauseFix
"Schema validation failed on must have required property 'X'"Missing field in adapter bodyAdd the field to merge task
"Method not found"Wrong task name or appCheck tasks.json and apps.json
"No available transitions"Missing error transitionAdd "state": "error" transition
"Cannot find workflow"childJob ref broken after project moveUpdate workflow field with @projectId: prefix
"Referenced job variable: undefined"merge uses "value" instead of "variable"Change to "variable" in data_to_merge
Job stuck in "running"No error transition on failed taskAdd error transition

Step 4: Fix locally, PUT to update, re-run. Don't recreate — updating preserves the ID.

Guide 2b: Work with any unfamiliar adapter task

Follow Guide 1 Steps 1-6 for discovery. Quick reference for the lookup commands:

# Step 1 — find the task
jq '.[] | select(.app | test("meraki";"i")) | {name, app, displayName}' {use-case}/tasks.json

# Step 2 — get the correct app name (tasks.json app field is often wrong for adapters)
jq '.[] | select(.name | test("meraki";"i")) | {name, type}' {use-case}/apps.json

# Step 2 — get the adapter instance name
jq '.results[] | select(.package_id | test("meraki";"i")) | {id, state}' {use-case}/adapters.json

# Step 3 — fetch the task schema
# POST /automation-studio/multipleTaskDetails?dereferenceSchemas=true
# {"inputsArray": [{"location": "Adapter", "pckg": "<app from apps.json>", "method": "<task name>"}]}

You now have three values: app (from apps.json), adapter_id (from adapters.json .id), displayName (from tasks.json). Two things to pay extra attention to beyond Guide 1:

Enforce data types from the schema. When the schema says "type": "array", you MUST pass an array — even for single values:

  • "to": "[email protected]" → WRONG. Use "to": ["[email protected]"]
  • "pageSize": "100" → WRONG if schema says number. Use "pageSize": 100
  • "cc": "" → OK only if schema allows string; if array, use "cc": []

Always check task-schemas.json for the exact type of each incoming field before wiring.

Inspect the actual response before wiring a query path. Adapter responses are transformed — they do not match the native API's structure. After a successful test run:

  1. GET /operations-manager/jobs/{jobId} — find the task in data.tasks by its task ID
  2. Read the task's outgoing variables — that is the real response object
  3. Use jq to explore: jq '.data.tasks["a1b2"]' job.json
  4. Wire the query path from what you see — not from the upstream API docs

End-to-end sequence:

1. tasks.json search   → found "getDevice", app "networkAdapter"
2. apps.json lookup    → correct app name is "NetworkAdapter" (capital N)
3. adapters.json       → adapter_id is "network-prod-1"
4. multipleTaskDetails → incoming: {deviceId: string}, outgoing: {result: object}
5. Build + test        → job completes
6. Inspect job         → result is {"response": {"hostname": "...", "model": "..."}}
7. Wire query path     → "response.hostname" (NOT "result.hostname" or "data.hostname")

Guide 3: Add a task to an existing workflow

Step 1: Extract the task structure from an asset project that uses the same task type:

# Adapter task (e.g., ServiceNow)
jq '[.components[].document.tasks // {} | to_entries[] | select(.value.location == "Adapter")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-servicenow.json

# Application task (WorkFlowEngine)
jq '[.components[].document.tasks // {} | to_entries[] | select(.value.app == "WorkFlowEngine" and .value.name != "childJob")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/itential-platform-configuration-management.json

# childJob
jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "childJob")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-cisco-ios.json

Step 2: Fill in the fields using the mapping rules from Guide 1 Step 4.

Step 3: Generate a hex task ID (e.g., d4e5) — must be [0-9a-f]{1,4}.

Step 4: Add the task to tasks and add transitions. Remember error transitions on adapter tasks.

Step 5: Update via PUT /automation-studio/automations/{id} with {"update": {...}}.

Guide 4: Build a childJob (parent calls child workflow)

childJob has two modes. Both are tested and verified on a live platform.

Mode A: Single child — pass variables with {"task","value"}

The parent passes specific variables to one child workflow run.

Parent childJob task:

{
  "a1a1": {
    "name": "childJob",
    "canvasName": "childJob",
    "summary": "Run Single Child",
    "location": "Application",
    "locationType": null,
    "app": "WorkFlowEngine",
    "type": "operation",
    "displayName": "WorkFlowEngine",
    "variables": {
      "incoming": {
        "task": "",
        "workflow": "My Child Workflow",
        "variables": {
          "deviceName": {"task": "job", "value": "targetDevice"},
          "action": {"task": "static", "value": "validate"}
        },
        "data_array": "",
        "transformation": "",
        "loopType": ""
      },
      "outgoing": {"job_details": null}
    },
    "actor": "job"
  }
}

Variable passing rules (uses "value", NOT "variable"):

  • {"task": "job", "value": "targetDevice"} → passes the parent's targetDevice job variable to the child as deviceName
  • {"task": "static", "value": "validate"} → passes the literal string "validate"
  • {"task": "b2c3", "value": "return_data"} → passes a previous task's output (preferred for runtime data)

WARNING — {task:"job"} refs in childJob variables add fields to inputSchema.required — same behavior as merge (see ### merge section). Only use {task:"job", value:"x"} for genuine workflow inputs. For runtime data produced by earlier tasks, use {task:"<taskId>", value:"<outVar>"} to reference the producing task directly.

WRONG for task output refs in childJob: {"task": "b2c3", "variable": "return_data"} — "variable" is for merge/evaluation only. In childJob, ALL refs (job, static, AND task output) use "value". Using "variable" causes undefined.indexOf() at job start time (P6.4.0+) — the workflow fails before any task runs.

Extracting single child output:

{
  "b2b2": {
    "name": "query",
    "variables": {
      "incoming": {
        "pass_on_null": false,
        "query": "taskStatus",
        "obj": "$var.a1a1.job_details"
      },
      "outgoing": {"return_data": "$var.job.childStatus"}
    }
  }
}

Query uses flat variable names — "taskStatus", NOT "variables.job.taskStatus".

If the query returns null even though the childJob succeeded — the $var form in obj may not resolve on your platform version. Use the merge+taskRef workaround:

a1a1 (childJob) → m1m1 (merge: captures job_details via taskRef) → b2b2 (query: reads merged_object)
{
  "m1m1": {
    "name": "merge",
    "variables": {
      "incoming": {
        "data_to_merge": [
          {"task": "a1a1", "variable": "job_details"},
          {"task": "static", "value": {}}
        ]
      },
      "outgoing": {"merged_object": null}
    }
  },
  "b2b2": {
    "name": "query",
    "variables": {
      "incoming": {
        "pass_on_null": false,
        "query": "taskStatus",
        "obj": "$var.m1m1.merged_object"
      },
      "outgoing": {"return_data": "$var.job.childStatus"}
    }
  }
}

The static {} second item is required — merge needs at least 2 items in data_to_merge.

Mode B: Loop — one child per item in data_array

Each element in data_array becomes the child's input variables for that iteration. Set variables: {} (empty).

Parent childJob task:

{
  "a1a1": {
    "name": "childJob",
    "canvasName": "childJob",
    "summary": "Run Child Per Device",
    "variables": {
      "incoming": {
        "task": "",
        "workflow": "My Child Workflow",
        "variables": {},
        "data_array": "$var.job.devices",
        "transformation": "",
        "loopType": "parallel"
      },
      "outgoing": {"job_details": null}
    },
    "actor": "job"
  }
}

Input: devices is an array of objects. Each object becomes one child's variables:

{
  "devices": [
    {"deviceName": "IOS-CAT8KV-1", "action": "backup"},
    {"deviceName": "IOS-CAT8KV-2", "action": "check"},
    {"deviceName": "EOS-AWS-1", "action": "backup"}
  ]
}

Extracting loop output: Query "loop" to get the results array:

{
  "b2b2": {
    "name": "query",
    "variables": {
      "incoming": {
        "pass_on_null": false,
        "query": "loop",
        "obj": "$var.a1a1.job_details"
      },
      "outgoing": {"return_data": "$var.job.childResults"}
    }
  }
}

If the query returns null (platform-version-specific $var resolution issue), use the same merge+taskRef workaround described above (Mode A) — capture job_details via {"task": "a1a1", "variable": "job_details"} in merge, then query $var.m1m1.merged_object.

Loop element completeness — required fields must be in each element (not in variables).

The platform validates the child workflow's inputSchema.required against each element's keys only. Static variables set on the childJob task are NOT counted toward satisfying required fields. If your loop elements only contain per-iteration fields (e.g., subnet_name, subnet_cidr) but the child also requires shared fields (e.g., subscription_id, region), the validation fails before any iteration runs.

Fix — forEach enrichment pattern: enrich each element with the shared fields before the childJob loop, then set variables: {} on the childJob:

forEach (loop over elements) → merge (add shared fields to current_item) → arrayPush (append enriched element to new array)
                                                                                    ↓ (after forEach success)
childJob (data_array: enrichedArray, variables: {})
// forEach outgoing binds current_item to job var
{"outgoing": {"current_item": "$var.job.currentElement"}}

// merge combines current element + shared fields
{"data_to_merge": [
  {"task": "forEachId", "variable": "current_item"},
  {"key": "subscription_id", "value": {"task": "job", "variable": "subscription_id"}},
  {"key": "region", "value": {"task": "job", "variable": "region"}}
]}
// → $var.mergeId.merged_object is the enriched element

// arrayPush appends to accumulator
{"incoming": {"job_variable": "enrichedElements", "item_to_push": "$var.mergeId.merged_object"}}

// childJob uses the enriched array and no static variables
{"data_array": "$var.job.enrichedElements", "variables": {}, "loopType": "parallel"}

Loop output shape (each element is a flat spread of the child's job variables):

[
  {"status": "complete", "childJobLoopIndex": 0, "deviceName": "IOS-CAT8KV-1", "action": "backup", "taskStatus": "success"},
  {"status": "complete", "childJobLoopIndex": 1, "deviceName": "IOS-CAT8KV-2", "action": "check", "taskStatus": "success"},
  {"status": "complete", "childJobLoopIndex": 2, "deviceName": "EOS-AWS-1", "action": "backup", "taskStatus": "success"}
]

Use "[**].taskStatus" in a query to extract one field from all iterations.

childJob checklist

  • actor is "job" (NOT "Pronghorn")
  • task is "" (empty string)
  • job_details outgoing is null
  • All incoming fields present — even unused ones: "data_array": "", "transformation": "", "loopType": ""
  • Variables use {"task","value"} NOT $var (single mode)
  • variables is {} when using data_array (loop mode)
  • Child workflow's inputSchema.required matches what you're passing
  • loopType: "" (single), "parallel" (simultaneous), "sequential" (one at a time)
  • If a downstream query of a childJob returns null: the "obj": "$var.<childJobId>.job_details" form may not resolve on this platform version — use merge+taskRef workaround (see "Extracting single child output" above)

Building the child workflow

The child workflow must:

  1. Accept inputs via inputSchema that match what the parent passes
  2. Set output variables via newVariable or task outgoing → $var.job.x
  3. Handle errors internally (try-catch pattern) so it always completes:
task --success--> newVariable("taskStatus" = "success") -> workflow_end
task --error--> newVariable("taskStatus" = "error") -> workflow_end

The parent can then check taskStatus from job_details to decide what to do.


Projects

MethodEndpointDescription
POST/automation-studio/projects/importImport a project (preferred — atomic)
POST/automation-studio/projectsCreate an empty project
GET/automation-studio/projects/{projectId}Get a project
PATCH/automation-studio/projects/{projectId}Update a project
DELETE/automation-studio/projects/{id}Delete a project
GET/automation-studio/projects/{id}/exportExport project as JSON
POST/automation-studio/projects/{projectId}/components/addAdd components (legacy)
DELETE/automation-studio/projects/{projectId}/components/{componentId}Remove component

Preferred: Import a project (atomic — all assets in one call)

Always use import instead of create + add components. Import creates the project with all workflows, templates, and MOP templates inside it in a single atomic call. No intermediate state, no broken childJob refs, no project-locking issues.

POST /automation-studio/projects/import

Build all assets locally first, then import everything at once:

{
  "project": {
    "_id": "24-char-hex-mongodb-objectid",
    "iid": 1,
    "name": "My Project",
    "description": "...",
    "thumbnail": "",
    "backgroundColor": "#FFFFFF",
    "components": [
      {
        "iid": 1,
        "type": "workflow",
        "reference": "uuid-of-workflow",
        "folder": "/",
        "document": { "...full workflow object..." }
      },
      {
        "iid": 2,
        "type": "mopCommandTemplate",
        "reference": "@projectId: Template Name",
        "folder": "/",
        "document": { "...full MOP object..." }
      }
    ],
    "created": "2026-03-13T00:00:00.000Z",
    "createdBy": {"_id": "000000000000000000000000", "provenance": "CloudAAA", "username": "admin@itential"},
    "lastUpdated": "2026-03-13T00:00:00.000Z",
    "lastUpdatedBy": {"_id": "000000000000000000000000", "provenance": "CloudAAA", "username": "admin@itential"}
  }
}

Import format rules (different from create/export):

FieldImport formatNotes
encodingVersionOMIT from workflow documentsCauses silent component failure if included
created_by (workflow){username, provenance, firstname, inactive, sso} — NO _idDifferent from project-level createdBy
createdBy (project){_id, username, provenance} — HAS _idDifferent from workflow-level
_id (project)Pre-compute 24-char hex stringSo childJob refs can use @{projectId}:
Workflow nameClean names — no prefixImport adds @projectId: automatically
childJob workflowMust include @{projectId}: prefixPre-wire using the same _id
reference (workflow)UUID stringBecomes the workflow's uuid
reference (MOP)@{projectId}: Template NameString reference
iid (components)Sequential integers starting at 1Incrementing ID

Response:

{
  "message": "Successfully imported project",
  "data": {"_id": "...", "name": "...", "components": [...]},
  "metadata": {"failedComponents": []}
}

Check metadata.failedComponents — empty array means success.

Why import instead of create + move

ProblemCreate + moveImport
childJob refsBreak on move — manual fix neededPre-wired with @projectId: — just work
Project lockingRace conditions during moveSingle atomic call
Intermediate stateWorkflows exist outside projectNever
API callsCreate + create each asset + move + fix refsOne POST
ReproducibilityHard to replayproject-import.json is the artifact

Legacy: Create + add components (avoid if possible)

Only use this for adding a single asset to an existing project after initial import.

POST /automation-studio/projects/{projectId}/components/add
{
  "components": [
    {"type": "workflow", "reference": "uuid-...", "folder": "/"}
  ],
  "mode": "move"
}

Warning: Both move and copy rename assets with @projectId: prefix but do NOT update internal references (childJob workflow fields, template names). You must fix these manually.

Component types: workflow, template, transformation, jsonForm, mopCommandTemplate, mopAnalyticTemplate

components/add called more than once on the same project can corrupt iid tracking. Confirmed on a project created via empty POST /automation-studio/projects (not import): a first components/add call (e.g. 5 components) gets assigned sequential iids, but the project's own componentIidIndex can come back reporting a value lower than the actual max iid just used. A second components/add call trusts that stale index as its starting point and assigns new iids that collide with ones from the first call — two different components end up sharing the same iid, silently corrupting anything that references components by iid (notably folders[].children[].iid groupings).

Diagnostic sign: after adding components in more than one call, GET the project and check components[].iid for duplicates across different types.

PATCH does not fix this. Sending a corrected components/folders array with de-duplicated, hand-assigned iids via PATCH /automation-studio/projects/{projectId} returns 200, but the platform recalculates/discards your custom iid values server-side — a GET right after still shows the same collision. Don't try to patch your way out of it.

Fix — pick one: (a) batch every component you're adding into a single components/add call (confirmed reliable — produces clean sequential iids), or (b) rebuild the project from scratch via projects/import, embedding each component's full document directly rather than moving in pre-existing standalone assets.

Update membership (full replacement)

Before patching, always ask the engineer: "Who else should have access to this project? (usernames or group names)"

Do not auto-discover or assume groups. Wait for the answer, resolve each name to a reference ID by scanning existing projects, then PATCH.

PATCH /automation-studio/projects/{projectId}

Use the helper: ${CLAUDE_PLUGIN_ROOT}/helpers/update/update-project-members.json

Include ALL members in every PATCH — this is a full replacement. Omitting an existing member removes them.

To resolve a username or group name to a reference ID, scan existing projects:

for pid in $(curl -s "$BASE/automation-studio/projects?limit=100" \
  -H "Authorization: Bearer $TOKEN" | jq -r '.data[]._id'); do
  curl -s "$BASE/automation-studio/projects/$pid" \
    -H "Authorization: Bearer $TOKEN" \
    | jq -r '.data.members[]? | [.type, .reference, (.username // .name)] | @tsv'
done | sort -u

If a name cannot be resolved, ask the engineer for the reference ID — do not guess.

Resolve membership references from spec

MANDATORY: Import sets the OAuth service account as project owner — not the UI user from the spec. The engineer specified in the spec's Project Membership table will be locked out of the project unless you PATCH membership immediately after import. This runs in Phase 3 (Import), not Phase 6 (Deliver).

There is no user/group lookup API on the Itential platform. The only way to resolve a username (e.g., [email protected]) or group name (e.g., solutions-engineers) to a platform reference ID is by scanning existing projects' members.

Step 1: Build a membership lookup table.

The list endpoint (GET /automation-studio/projects?limit=50) does NOT include username/name on member objects — only individual GET /automation-studio/projects/{id} calls do. Scan all projects to build the lookup:

# Get all project IDs
PROJECT_IDS=$(curl -s -H "Authorization: Bearer $TOKEN" \
  "$PLATFORM_URL/automation-studio/projects?limit=100" \
  | jq -r '.data[]._id')

# Build lookup table from individual GETs
> {use-case}/membership-lookup.txt
for pid in $PROJECT_IDS; do
  curl -s -H "Authorization: Bearer $TOKEN" \
    "$PLATFORM_URL/automation-studio/projects/$pid" \
    | jq -r '.data.members[]? | [.type, .reference, (.username // .name), .provenance] | @tsv'
done | sort -u >> {use-case}/membership-lookup.txt

Output format (TSV): type reference username/name provenance

Step 2: Match spec members to references.

For each member in the spec's Project Membership table, find their reference ID in membership-lookup.txt:

grep "[email protected]" {use-case}/membership-lookup.txt
# → account  699a67bb...  [email protected]  CloudAAA

Step 3: PATCH membership immediately after import.

PATCH /automation-studio/projects/{projectId}
{
  "members": [
    {"type": "account", "role": "owner", "reference": "699a67bb..."},
    {"type": "group", "role": "editor", "reference": "67c859..."}
  ]
}

If a username or group cannot be resolved from the lookup table, stop and ask the engineer. Do not guess reference IDs or skip members.

Baseline members (when no spec membership is defined): If there is no Project Membership table in the spec, or when doing a freeform build/import outside the spec lifecycle, ask the engineer: "Which user accounts or groups should have access to this project?" — do not assume or skip. Once you have the names, resolve them via the lookup table above and PATCH immediately. Without this step the engineer will be locked out of the project in the IAP UI. See #63

Project Thumbnail

OperationEndpoint
SetPUT /automation-studio/projects/{id}/thumbnail — body: {"imageData": "<data-URI>", "backgroundColor": "<hex>"}
GetGET /automation-studio/projects/{id}/thumbnail — returns {"data": {"image": "<data-URI>", "backgroundColor": "<hex>"}}

imageData must be a full data URI — not raw base64. Passing raw base64 without the data:image/png;base64, prefix returns HTTP 200 and stores the value, but the UI renders a black/blank image with no error.

data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...

Build the data URI in Python:

import base64, io
buf = io.BytesIO()
img.save(buf, format='PNG')
data_uri = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
  • Optimal dimensions: 330 × 100 px — matches the project card aspect ratio in Automation Studio
  • Accepted formats: jpg, jpeg, png — max 1000 KB
  • backgroundColor (hex, e.g. "#1B2A4A") sets the card background color visible before the image loads

JSON Forms

JSON Forms have their own dedicated skill — itential-json-forms. See that skill for the form structure (struct / schema / uiSchema / bindingSchema), the static-enum vs. REST-bound vs. cascading dropdown (aka field dependency) patterns, the full API reference (including the bulk-only DELETE), and the manual-trigger wiring (legacyWrapper: false).

Helper templates for forms still live under ${CLAUDE_PLUGIN_ROOT}/helpers/:

  • create-json-form.json — static-enum dropdowns
  • create-json-form-rest-bound.json — REST-bound or cascading dropdowns

Operations Manager (Automations & Triggers)

MethodEndpointDescription
POST/operations-manager/automationsCreate an automation
GET/operations-manager/automationsList automations
POST/operations-manager/triggersCreate a trigger
PATCH/operations-manager/triggers/{id}Update a trigger
GET/operations-manager/triggersList triggers

Create a Manual Trigger with JSON Form

This is a two-step process: create the automation, then create a manual trigger that binds to it.

Use the helper template: ${CLAUDE_PLUGIN_ROOT}/helpers/create/create-ops-manager-automation.json

Critical: legacyWrapper must be false. When creating a manual trigger with a JSON form, set legacyWrapper: false. The default is true, which wraps form field values under formData, breaking the mapping to workflow job variables. With legacyWrapper: false, form field values map directly to workflow input variables by name.

Required trigger fields: name, type ("manual"), enabled, actionType ("automations"), actionId, formId, legacyWrapper


Task Discovery

Pull Task Catalog

{use-case}/tasks.json should already exist — pulled by /solution-arch-agent or /explore during feasibility. Do not re-pull if the file exists. If missing, fetch it:

GET /workflow_builder/tasks/list → save to {use-case}/tasks.json
GET /automation-studio/apps/list → save to {use-case}/apps.json

Search locally:

grep -i "template" {use-case}/tasks.json
jq '.[] | select(.app == "ConfigurationManager") | .name' {use-case}/tasks.json

Look up task wiring in asset projects first

Before fetching schemas from the API, check if an asset project already has the task wired up. If it does, you get the exact field structure for free — no API call needed.

# Does any asset project use this task? Find it by task name:
grep -rl '"name": "TASK_NAME"' ${CLAUDE_PLUGIN_ROOT}/helpers/assets/

# Extract the wired task from the matching project:
jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "TASK_NAME")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/MATCHING_FILE.json

# See which tasks a specific workflow uses:
jq '[.components[] | select(.type=="workflow") | select(.document.name | test("WORKFLOW"; "i"))] | first | .document.tasks | to_entries[] | {id:.key, name:.value.name, app:.value.app}' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/MATCHING_FILE.json

Asset project → best match by task type:

TaskBest asset to check
ServiceNow adapter tasksvendor-servicenow.json
Infoblox / DNS / IPAM tasksvendor-infoblox-nios-ddi.json
NetBox tasksvendor-netbox.json
itential_cli, RunCommandTemplate, MOP tasksitential-platform-configuration-management.json, vendor-cisco-ios.json
transformation (JST)vendor-netbox.json, itential-platform-data-manipulation.json
childJob, evaluation, query, newVariableany vendor project
LCM action workflow taskshelpers/assets/lcm/lcm-vxlan-fabric-services-project.json

Get Full Task Schemas (only if not found in assets)

Single task:

GET /automation-studio/locations/{location}/packages/{pckg}/tasks/{method}?dereferenceSchemas=true

Multiple tasks:

POST /automation-studio/multipleTaskDetails?dereferenceSchemas=true
{
  "inputsArray": [
    {"location": "Application", "pckg": "WorkFlowEngine", "method": "query"},
    {"location": "Adapter", "pckg": "Servicenow", "method": "createChangeRequest"}
  ]
}

Mapping from tasks.json → schema endpoint:

tasks.json fieldMaps to
location (Application/Adapter){location}
app (e.g., TemplateBuilder){pckg}
name (e.g., renderJinjaTemplate){method}

IMPORTANT: The pckg value must come from apps.json, NOT tasks.json. The names can differ (e.g., tasks.json says ServiceNow but apps.json says Servicenow).

Before fetching schemas:

  1. Search asset projects (above) — if found, use the wired example directly
  2. Check if {use-case}/task-schemas.json exists — search it next
  3. Only call multipleTaskDetails for tasks not found in either place
  4. After fetching, append to {use-case}/task-schemas.json

nodeLocation Spacing Convention

Workflows are laid out top-to-bottom (vertical) by default — this is the Itential best practice for readability and consistency, and matches the conventions used in the platform's working examples. Use horizontal only when the engineer explicitly asks for it.

Vertical Layout (default)

RuleValue
Sequential tasks (y-delta)+108px
Fork branch offset from spine (x-delta)±264px
Spine xa constant column (e.g. x=600)

Clean canvas principles:

  • The spine is a constant x — non-forked sequences (start, single-thread tasks, end, convergence points) sit on it.
  • Forks split off the spine — at a fork point, both outgoing branches leave the spine column. Place one at spine - 264 and the other at spine + 264. The spine column stays empty between the fork and the convergence point so transition lines don't cross task nodes. Direction (which branch goes left vs. right) is the engineer's call — pick whatever keeps the picture clean.
  • Branches stay in their own column until they converge.
  • Convergence tasks (workflow_end, merges, error sinks) return to the spine x.
  • Tight y-spacing — the canvas grid is dense; ~108px between sequential rows reads well. Don't pad to +250 or +360.
  • Preserve Studio-arranged positions — if an engineer has arranged a workflow in Automation Studio, treat its nodeLocation values as authoritative. Always read from the live export before reimporting. Never recalculate positions from scratch on a workflow that has already been arranged.

Example — fork with a shared error handler (same pattern as ServiceNow "Create Change Request" in helpers/assets/vendor-servicenow.json):

workflow_start                        (x=600, y=200)
e1a1 merge                            (x=600, y=312)
a1b2 createCR  ── fork point ──       (x=600, y=420)
b2c3 query   [success branch]         (x=336, y=540)
c3d4 updateCR [success branch]        (x=336, y=636)   ef01 newVar [shared error handler]  (x=864, y=636)
workflow_end                          (x=600, y=804)

For a childJob phase with query + evaluation (single-thread, no fork → all on spine):

y=312  — childJob     (x=600)
y=420  — query        (x=600)   ← extracts taskStatus from job_details
y=528  — evaluation   (x=600)

Horizontal Layout (only when requested)

If the engineer explicitly asks for horizontal, swap x and y throughout: phases advance on x, fork branches offset on y, spine becomes a constant y row. Same magnitudes, opposite axes.


Workflows

Workflow Structure

POST /automation-studio/automations

Body wraps the workflow in {"automation": {...}}:

{
  "automation": {
    "name": "My Workflow",
    "description": "Does something useful",
    "type": "automation",
    "canvasVersion": 3,
    "encodingVersion": 1,
    "font_size": 12,
    "tasks": {
      "workflow_start": {
        "name": "workflow_start",
        "groups": [],
        "nodeLocation": {"x": 600, "y": 200}
      },
      "a1b2": {
        "name": "query",
        "canvasName": "query",
        "summary": "Extract Data",
        "description": "Extracts field from response",
        "location": "Application",
        "locationType": null,
        "app": "WorkFlowEngine",
        "type": "operation",
        "displayName": "WorkFlowEngine",
        "variables": {
          "incoming": {
            "pass_on_null": false,
            "query": "hostname",
            "obj": "$var.job.deviceData"
          },
          "outgoing": {
            "return_data": "$var.job.deviceName"
          },
          "error": "",
          "decorators": []
        },
        "groups": [],
        "actor": "Pronghorn",
        "scheduled": false,
        "nodeLocation": {"x": 600, "y": 312}
      },
      "workflow_end": {
        "name": "workflow_end",
        "groups": [],
        "nodeLocation": {"x": 600, "y": 420}
      }
    },
    "transitions": {
      "workflow_start": {
        "a1b2": {"type": "standard", "state": "success"}
      },
      "a1b2": {
        "workflow_end": {"type": "standard", "state": "success"}
      },
      "workflow_end": {}
    },
    "groups": [],
    "inputSchema": {
      "type": "object",
      "properties": {
        "deviceData": {"title": "deviceData", "type": "object"}
      },
      "required": ["deviceData"]
    },
    "outputSchema": {
      "type": "object",
      "properties": {
        "deviceName": {"title": "deviceName", "type": "string"}
      }
    }
  }
}

Update a workflow:

PUT /automation-studio/automations/{id}
{"update": { ...same structure as automation object... }}

Project-scoped name required on PUT. If the workflow belongs to a project, the name field in the update body must include the @<projectId>: prefix — even if the workflow was created without it:

{"update": {"name": "@69f10abc: My Workflow", "tasks": {...}, "transitions": {...}}}

Sending the bare name ("name": "My Workflow") returns {"error": {"message": "Name must begin with '@projectId: '"}}.

Asymmetry: workflow CREATE (POST /automation-studio/automations) does NOT require the prefix — the platform applies it when the workflow is added to a project. But PUT-update always requires it for project-member workflows.

Always read the workflow before updating (GET /automation-studio/workflows/detailed/{name} or export the project) to get the current scoped name. See Rule 24 and issue #55.

Task Fields

FieldApplication TasksAdapter Tasks
nameMethod name from tasks.jsonMethod name from tasks.json
canvasNameFrom tasks.json canvasName field (may differ from name: arrayPush→push)Same
location"Application""Adapter"
locationTypenullSame as app
appApp name (e.g., WorkFlowEngine)From apps.json (NOT tasks.json)
type"automatic" or "operation" — read from tasks.json .type, do not guess
actor"Pronghorn""Pronghorn"
displayNameApp nameMay differ from app

Adapter tasks also require adapter_id in incoming variables — the adapter instance name from health/adapters.

Task Access Control (groups)

The groups field on a task definition is task-level GBAC — group-based access control that restricts which IAP groups can see, claim, and complete a manual task in the Job Inbox.

FieldTypeMeaning
groups (plural)string[]GBAC. Each entry is a group's MongoDB _id (24-char hex). Empty [] means no task-level restriction.
group (singular, optional)stringCanvas display category (e.g., "Tools", "JsonForms"). Set by the Studio canvas. NOT access control — easy to confuse with groups.
{
  "name": "ViewData",
  "type": "manual",
  "app": "WorkFlowEngine",
  "view": "/workflow_engine/task/ViewData",
  ...
  "groups": ["69e65b4189b39131a9b8cce1"]
}

Look up group IDs:

  • GET /authorization/groups — list groups (each has _id and name)
  • GET /authorization/groups/<id> — resolve a single group

Two GBAC scopes — both use the same string[] shape (group _ids) but apply at different levels:

  • Per-task groups (on the task definition, sibling of name/app/type) — gates access to a single manual task.
  • Top-level workflow groups (sibling of tasks/transitions at the workflow level) — gates access to the workflow as a whole.

Tasks of any type can carry groups, but only type: "manual" tasks surface in the Job Inbox where GBAC actually gates user access. Leave it as [] on automatic tasks unless platform-specific docs say otherwise.

Edge cases not yet documented — verify on your platform before relying on:

  • Semantics with multiple group IDs in the array (likely OR — any-of — but unverified)
  • Interaction between task-level and workflow-level groups (additive vs. override)
  • Whether groups accepts a $var job-variable for dynamic group resolution (almost certainly no — design-time only — but worth confirming)

Task IDs

Task IDs must be hex-only: [0-9a-f]{1,4}. Non-hex IDs (e.g., apush) cause $var references to silently fail.

Transitions

"transitions": {
  "workflow_start": {
    "a1b2": {"type": "standard", "state": "success"}
  },
  "a1b2": {
    "c3d4": {"type": "standard", "state": "success"},
    "err1": {"type": "standard", "state": "error"}
  },
  "c3d4": {
    "workflow_end": {"type": "standard", "state": "success"}
  },
  "err1": {
    "workflow_end": {"type": "standard", "state": "success"}
  },
  "workflow_end": {}
}

Transition states:

  • success — task completed without error (all tasks)
  • error — task encountered errors (all tasks)
  • failure — evaluation didn't match or query returned undefined (evaluation/query only)
  • loop — forEach loop iteration (forEach only)

Transition types:

  • standard — moves forward
  • revert — moves backward to a previous task (retry loops)

MANDATORY: Every adapter/external task needs an error transition. Without one, errors cause "Job has no available transitions" and the job gets stuck forever.

JSON duplicate key problem: If both success and error need to go to workflow_end, you can't use workflow_end as a key twice. Route error to an intermediate task (e.g., newVariable to set error status), then route that to workflow_end.

Create Response Shape

Both workflow and template creation return {created, edit} — NOT {message, data, metadata}:

{
  "created": {"_id": "...", "name": "..."},
  "edit": "/automation-studio/#/edit?..."
}

$var Resolution Rules

$var only resolves as direct top-level incoming variable values:

WiringWorks?Why
"deviceName": "$var.job.x"YesDirect top-level value
"variables": {"key": "$var.job.x"}NONested inside object
"body": {"data": "$var.job.x"}NONested — stored as literal string

Workaround: Use merge, makeData, or query to build the nested object, then reference the task's output with $var.taskId.merged_object.

Task ID validation: $var.taskId.x only resolves when taskId matches [0-9a-f]{1,4}. Non-hex IDs silently fail.

Prefer task-to-task wiring: When a task's output feeds directly into the next task's input, wire it as $var.<taskId>.<outVar> instead of bouncing through $var.job.x. Only use job variables when: (a) values cross non-adjacent tasks, (b) values need to be visible in job output, or (c) multiple downstream tasks need the same value. Direct task-to-task wiring reduces clutter and makes data flow easier to trace.

incomingRefs cache — what PUT does and doesn't fix:

ScenarioResultFix
New tasks added via PUTincomingRefs generated — task-to-task $var refs resolve immediatelyNone needed
Existing task field values changed via PUTincomingRefs NOT regenerated — literals/changed taskRefs resolve to nullOpen in Studio → Save
POST /workflow_builder/workflows/saveDoes NOT regenerate incomingRefs eitherOpen in Studio → Save
Evaluation silently returns false after PUTStale operand cacheConstant-holder workaround below, or Studio save
Workflow hangs at workflow_start (status: running forever) after PUTAny task's incomingRefs staleRecreate via fresh POST — more PUTs won't fix it

Constant-holder workaround (API-only, no Studio save needed): store operand_2 literal values in a newVariable task and reference via {"task": "k_const", "variable": "value"} — taskRef resolution bypasses the cache.

makeData static input strings do NOT resolve after API create/PUT. The input and outputType fields are backed by job_data (type static). Workaround: use newVariable with value: [...] (array literal) — newVariable.value resolves correctly after API create without a Studio save.

task: "static" values broadly are backed by job_data written at Studio-save time. Any static value (template strings, query paths, model IDs, inline constants in childJob variables dicts) resolves as null at runtime on a freshly API-imported workflow until saved through Automation Studio.

Outgoing must write to job var for cross-task $var to be readable by downstream tasks. Pattern: "outgoing": {"result": "$var.job.raw_result"} then downstream: "obj": "$var.job.raw_result". If outgoing is null, the value is accessible via task iteration (GET /operations-manager/tasks/{iterationId}) but NOT via $var.taskId.result in downstream tasks at runtime. Use job vars for any result you need to pass forward.

POST /automation-studio/workflows/validate — runs pre-flight schema validation before create or update. Returns {errors: [], warnings: []}. An empty errors array means the workflow is schema-valid. Run this on every workflow before POSTing or PUTting.


Utility Tasks (WorkFlowEngine)

These are built-in tasks that require no adapter. They handle data manipulation and control flow.

query

Extract nested values from objects using dot-path syntax.

Incoming: pass_on_null (boolean), query (string — dot-path), obj (object — usually $var ref) Outgoing: return_data (any) Transitions: success (found), failure (null/undefined when pass_on_null: false)

{
  "incoming": {
    "pass_on_null": false,
    "query": "response.id",
    "obj": "$var.a1b2.result"
  },
  "outgoing": {
    "return_data": "$var.job.changeId"
  }
}

IMPORTANT: Don't guess the query path for adapter responses. Adapters transform upstream API responses — the field path in the adapter's output is NOT the same as the native API's response structure. The adapter's result outgoing is always a {response, headers, metrics} object, never a primitive. When the upstream API returns a simple string (like Infoblox's _ref), it's at result.response, not result directly. Always verify the actual response shape from a test job (GET /operations-manager/jobs/{jobId} → data.tasks) before wiring a path.

merge

Build an object from multiple resolved values. Primary workaround for $var not resolving inside nested objects.

Incoming: data_to_merge (array, min 2 items) Outgoing: merged_object (object)

IMPORTANT: The field is "variable" NOT "value" in the reference objects inside data_to_merge.

Reference format in data_to_merge:

  • {"task": "job", "variable": "varName"} — pull from a user-supplied job variable (input to the workflow)
  • {"task": "static", "variable": "literalValue"} — literal value
  • {"task": "taskId", "variable": "outVar"} — pull from a previous task's output

WARNING — {task:"job"} references add fields to inputSchema.required. The platform scans every data_to_merge entry in merge tasks (and every variables entry in childJob) for {task:"job"} references and automatically adds that variable name to inputSchema.required. This means using {task:"job", variable:"changeId"} for a variable that was produced internally by a query task will prompt operators to supply changeId as a workflow input — even though it should never come from the user.

Rule: only use {task:"job"} for variables that are genuine workflow inputs. For anything produced by an earlier task, use the producing task's ref directly:

Value sourceCorrect ref form
User workflow input{"task": "job", "variable": "x"}
query output{"task": "queryTaskId", "variable": "return_data"}
merge output{"task": "mergeTaskId", "variable": "merged_object"}
newVariable output{"task": "newVarTaskId", "variable": "value"}
makeData output{"task": "makeDataTaskId", "variable": "output"}
parse output{"task": "parseTaskId", "variable": "return_data"}
adapter task output{"task": "adapterTaskId", "variable": "result"}
{
  "incoming": {
    "data_to_merge": [
      {"key": "hostname", "value": {"task": "static", "variable": "IOS-CAT8KV-1"}},
      {"key": "details", "value": {"task": "job", "variable": "deviceInfo"}},
      {"key": "config", "value": {"task": "a1b2", "variable": "renderedTemplate"}}
    ]
  },
  "outgoing": {
    "merged_object": "$var.job.requestBody"
  }
}

Gotchas: Requires at least 2 items (1 item = silently null). Outgoing MUST declare "merged_object": null (empty {} makes it unreachable). Duplicate keys produce arrays — merging {"ip": "1.2.3.4"} and {"ip": "1.2.3.4"} yields {"ip": ["1.2.3.4", "1.2.3.4"]}, not an overwrite. To avoid this, pass a pre-built object as a single workflow input variable instead of merging multiple objects with the same keys.

parse

Convert a JSON string into a JavaScript object. Essential after extracting result.stdout from runService (which is always a string, even when the script printed valid JSON).

Incoming: stringToParse (string — the JSON string to parse) Outgoing: result (object — the parsed object)

{
  "name": "parse",
  "canvasName": "parse",
  "summary": "Parse JSON String",
  "location": "Application",
  "locationType": null,
  "app": "WorkFlowEngine",
  "type": "operation",
  "displayName": "WorkFlowEngine",
  "variables": {
    "incoming": {
      "stringToParse": "$var.a1b2.return_data"
    },
    "outgoing": {
      "result": "$var.job.parsedOutput"
    }
  },
  "actor": "Pronghorn"
}

Common pattern — runService → query → parse:

runService → query(result.stdout) → parse(stringToParse) → use parsed fields

After parse, fields are accessible: $var.parseTask.result.hostname, $var.parseTask.result.status, etc.

evaluation

Conditional branching. MUST have BOTH success AND failure transitions.

Incoming: all_true_flag (boolean), evaluation_groups (array) Outgoing: return_value (boolean) Transitions: success (true), failure (false)

Operator enum — closed set. Only these 8 are valid:

contains, !contains, <, <=, >, >=, ==, !=

regex, match, matches, contains_key, in, startsWith — do not exist. An invalid operator silently returns false with empty outgoing and finish_state: failure. No error message. Always validate against this list before wiring. Source of truth: openapi.json at components/schemas/workflow_engine_wfEngineCommon_evaluationItem/properties/operator/enum.

contains is regex-based, not substring. operand_2 is interpreted as a regex pattern. A literal like 9.2(4) is parsed as regex — . matches any char, (4) becomes a capture group — and may match unintended strings or fail to match the intended one. Escape regex metacharacters in literal patterns: 9\.2\(4\) not 9.2(4).

contains also works for object-key presence — it is the universal "does X contain Y" operator. On a string operand it does regex matching; on an object operand it tests key presence. There is no separate contains_key operator.

Direct evaluation test (no workflow needed):

POST /workflow_engine/runEvaluationGroups
{"evaluation_groups":[{"operator":"AND","evaluations":[{"operand_1":"<test input>","operator":"contains","operand_2":"<pattern>"}]}]}

Returns true/false. Invalid operators silently return false. Use this to validate operators and escape patterns before wiring them into a workflow.

incomingRefs cache — API PUT does not regenerate it for existing task changes. See the incomingRefs table in $var Resolution Rules. Diagnostic sign: GET /operations-manager/tasks/{iterationUUID} shows incomingRefs[n].taskId: null or taskPointer: "/variables/outgoing/undefined".

Operand reference format (uses "variable", same as merge):

  • {"task": "job", "variable": "varName"}
  • {"task": "static", "variable": "literalValue"}
{
  "incoming": {
    "all_true_flag": true,
    "evaluation_groups": [{
      "all_true_flag": true,
      "evaluations": [{
        "operand_1": {"variable": "status", "task": "job"},
        "operator": "==",
        "operand_2": {"variable": "success", "task": "static"}
      }]
    }]
  },
  "outgoing": {"return_value": null}
}

Operand can drill into a nested field via an inline "query" key — no separate query task needed. Add a query sibling alongside task/variable in the operand object, using the same dot-path syntax as a standalone query task. This lets evaluation read a nested field of another task's output directly:

{
  "operand_1": {"task": "f6f6", "variable": "mop_template_results", "query": "result"},
  "operator": "==",
  "operand_2": {"task": "static", "variable": true}
}

This replaces the older pattern of a query task extracting the field into a job variable before evaluation reads it (query task → $var.job.postCheckPassed → {"task": "job", "variable": "postCheckPassed"}) — one task instead of two, and it also avoids adding that intermediate job variable to inputSchema.required (see the {task:"job"} warning above). Confirmed live: mop_template_results is a MOP command-template result object; query: "result" pulls its top-level result boolean without a separate extraction step.

childJob

Run another workflow as a sub-job. Read a live childJob example first:

jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "childJob")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-cisco-ios.json

Critical differences from normal tasks:

  • actor MUST be "job" — not "Pronghorn"
  • task MUST be "" (empty string)
  • outgoing.job_details MUST be null — do NOT override with $var.job.X
  • All incoming fields required — even unused ones: "data_array": "", "transformation": "", "loopType": ""

childJob cannot resolve project-scoped workflow names. {"workflow": "@<projectId>: <name>"} fails at runtime with "Error starting child job: Cannot find workflow ..." — and so does the bare unprefixed name ({"workflow": "<name>"}), even when the calling workflow is moved into the same project as the target. Confirmed with an isolated minimal test: a standalone parent calling a standalone (unprojected) child by bare name succeeds (fails later for an unrelated reason, but the child IS found); the identical parent calling a project-scoped child by either name form fails to find it. Ruled out incomingRefs staleness as the cause — persisted after a full delete-and-recreate of the parent. If you need to call a workflow that lives in a project, inline its task(s) directly into the calling workflow instead of childJob-ing it — there is no known workaround for calling it as a child.

A merge referencing a childJob's output must use "value", not "variable" — e.g. {"task": "<childJobId>", "value": "job_details"}. This contradicts merge's general rule (merge uses "variable", childJob uses "value" — see the Variable Syntax Reference table) but matches childJob's own internal convention for its own output. Using "variable" here doesn't fail at runtime — it fails workflow create/update outright, with the unhelpful generic error "Cannot read properties of undefined (reading 'task')" (no task ID named in the error; bisect the task graph to find it if you hit this).

Variables use {"task", "value"} syntax — NOT $var:

{
  "incoming": {
    "task": "",
    "workflow": "My Child Workflow",
    "variables": {
      "deviceName": {"task": "job", "value": "deviceName"},
      "configData": {"task": "a1b2", "value": "return_data"}
    },
    "data_array": "",
    "transformation": "",
    "loopType": ""
  },
  "outgoing": {"job_details": null}
}

childJob uses "value". merge/evaluation use "variable". Do NOT mix them.

Variable passing:

  • {"task": "static", "value": [...]} — literal value
  • {"task": "job", "value": "varName"} — parent job variable (must exist at start)
  • {"task": "taskId", "value": "outVar"} — previous task's output (preferred for runtime data)

Loop modes: loopType: "" (single), "parallel" (multiple simultaneous), "sequential" (one at a time). With loops, use data_array (each element becomes a child job's variables) and set variables: {}.

Querying childJob output:

{
  "name": "query",
  "variables": {
    "incoming": {
      "query": "taskStatus",
      "obj": "$var.f48f.job_details",
      "pass_on_null": false
    }
  }
}

Use flat variable names, NOT nested paths. For loop output: "[**].fieldName".

forEach

Iterate over an array. Deprecated — prefer childJob with loopType. Still common in existing workflows.

Incoming: data_array (array) — ONLY data_array. Do NOT include job_id in incoming — it triggers errors. Outgoing: current_item (any)

Transition pattern (critical):

forEach --state:loop--> firstBodyTask -> ... -> lastBodyTask --(empty {})
forEach --state:success--> nextTaskAfterLoop

forEach constraints (all four are required)

  1. incoming must only contain data_array — do NOT include job_id or any other field. Adding job_id causes errors at runtime.

  2. $var.<taskId>.<output> does NOT resolve inside the loop body — string references like $var.n01.current_item silently resolve to null inside a forEach body. Use $var.job.<varName> instead (bind the forEach's outgoing to a job variable and reference that). This applies to ALL reference styles — even taskRef objects {"task": "outerTask", "variable": "current_item"} are unreliable inside a nested body.

  3. Loop body tasks cannot transition to tasks outside the loop — no error transitions from loop body tasks to external error handlers. The forEach task itself handles exit via state: "error" on the forEach transition. Handle errors within the loop body, then let the forEach's error transition route out.

  4. The last loop body task signals loop-back with an empty {} transition — do NOT add an explicit loop-back target pointing to forEach.

"transitions": {
  "forEachTaskId": {
    "firstBodyTask": {"type": "standard", "state": "loop"},
    "nextTaskAfterLoop": {"type": "standard", "state": "success"},
    "errorHandlerTask": {"type": "standard", "state": "error"}
  },
  "lastBodyTask": {}
}

newVariable

Create or set a job variable at runtime.

Incoming: name (string), value (any) Outgoing: value (any)

{
  "incoming": {"name": "taskStatus", "value": "success"},
  "outgoing": {"value": "$var.job.taskStatus"}
}

GOTCHA: $var inside value does NOT resolve. The literal string is stored. Use merge + query to build dynamic values.

makeData

Construct data with <!var!> variable substitution.

Incoming: input (string with <!var!> placeholders), outputType ("string"/"json"/"number"/"boolean"), variables (object) Outgoing: output (any)

The variables field must be a resolved object. Use merge first to build it, then pass via $var.taskId.merged_object:

merge (build variables object) → makeData (use $var.taskId.merged_object as variables)

WARNING — makeData.incoming.variables cannot use $var references to a merge that sources childJob output. When a merge task's data_to_merge contains a childJob reference (e.g., {"task": "childJobId", "variable": "job_details"}), the platform cannot compile $var.<mergeId>.merged_object as a taskRef for makeData.incoming.variables — it is stored as a literal static string. Template substitution then operates on the literal string and emits unresolved placeholders.

query.incoming.obj does NOT have this limitation — it resolves $var.<mergeId>.merged_object correctly even when the merge references childJob output.

Fix: extract individual values from the childJob-sourced merge using query tasks, then pass those resolved scalars to makeData via a second merge (that contains only non-childJob refs). Do NOT feed a childJob-sourced merge directly into makeData's variables.

delay

Pause execution. Incoming: time (integer, seconds). Outgoing: time_in_milliseconds.

push / pop / shift

Array manipulation on job variables by name (plain string, NOT $var reference).

{
  "incoming": {
    "job_variable": "collectedResults",
    "item_to_push": "$var.c3d4.return_data"
  }
}

GOTCHA: Pass "myArray", NOT "$var.job.myArray".

deepmerge

Same as merge but merges nested objects recursively instead of overwriting top-level keys. Use when combining objects that share nested keys.

Incoming: data_to_merge (array, min 2 items — same format as merge) Outgoing: merged_object (object)

transformation

Perform JSON transformation using JST (JSON Schema Transformation).

Incoming: tr_id (string — transformation ID), variableMap (object — maps transformation inputs to data locations), options (object, optional — e.g., {"extractOutput": true}) Outgoing: outgoing (any)

Used in childJob mode 3 (loop with transformation) to reshape each data_array element before passing to the child.

decision

Multi-way branching based on conditions. Unlike evaluation (binary true/false), decision branches to different tasks based on multiple conditions.

Incoming: decisionArray (array of decision objects with conditions and target task IDs) Outgoing: return_value (string — the ID of the next task)

restCall

Make external HTTP calls from within a workflow. Use when calling APIs not exposed through adapters.

Response shape — no wrapper. restCall returns the already-parsed JSON body directly as the outgoing value. There is no response or result wrapper. Query paths target body fields directly:

Correct:   "query": "access_token"
Wrong:     "query": "response.access_token"   ← no response wrapper
Wrong:     "query": "result.access_token"     ← no result wrapper

This is the opposite of adapter tasks (e.g., genericAdapterRequest), which always wrap the upstream response in {response, headers, metrics}. Don't cross-apply the adapter query paths to restCall output — you'll get null every time.

modify

Modify data by querying into an object and replacing with a new value.

Incoming: object_to_update (any), query (string — json-query path), new_value (any) Outgoing: updated_object (any)

validateJsonSchema

Validate JSON data against a JSON schema.

Incoming: jsonData (object), schema (object) Outgoing: result (object — {"valid": true} or {"valid": false})

Additional Utility Tasks (60+)

Search tasks.json for the full catalog:

jq '.[] | select(.app == "WorkFlowEngine") | {name, summary}' {use-case}/tasks.json
CategoryExamples
StringstringConcat, replace, split, toLowerCase, toUpperCase, trim, substring
ArrayarrayConcat, arrayPush, sort, join, arraySlice, map, reverse
Objectassign, keys, values, objectHasOwnProperty, setObjectKey
TimegetTime, addDuration, convertTimezone, calculateTimeDiff
Parse/Transformparse, transformation, stringify
ToolsrestCall, csvStringToJson, excelToJson, asciiToBase64

Reach for purpose-built tasks before chaining primitives. Two tasks that are commonly underused:

  • setObjectKey (WorkFlowEngine) — writes a value directly into a nested key of an existing object. Use instead of query + merge when updating a single field on an object already in $var.job.*.
  • renderJinja2ContextWithCast (ConfigurationManager) — renders a Jinja2 template with the full job context automatically injected, plus optional type casting on the output. Use instead of merge → renderJinja2 → query chains when the template needs access to existing job variables. Outputs renderedTemplate accessible via $var.<taskId>.renderedTemplate.

Fetch full schemas with POST /automation-studio/multipleTaskDetails?dereferenceSchemas=true.

runCode (GatewayManager) — real Python instead of chaining WorkFlowEngine utility tasks

runCode ships arbitrary Python to a Gateway5 cluster for execution — no pre-configured IAG service needed, unlike runService. It is the single biggest lever for collapsing a forEach + query×N + evaluation + merge×N + push/objectToString/join chain (the kind of per-item transform loop that's the source of most WorkFlowEngine utility-task gotchas in this file) into 1-2 tasks.

Prerequisites (per official docs):

  • Install Gateway Manager 1.0.10 or later with Platform 6.4.0 or later.
  • Register an active Gateway 5.4.0 or later cluster with Itential Platform using Gateway Manager.
  • Verify that the Python version on the Gateway 5 host is compatible with your scripts and packages. The task uses the default Python interpreter on the host, which is the version invoked by python or python3 on the iagctl command line. Gateway does not enforce a minimum version — run python --version on the Gateway 5 host to check.
  • Confirm that the gateway:code role from GatewayManager is assigned to your group.

When to reach for it: any per-item data transform/lookup/branching loop over a list — the exact shape that otherwise needs forEach (with its job_id-omission and empty-last-transition rules), query per field, evaluation for branching, merge for reassembly, and (for arrays of objects) the objectToString/push/join dance. One runCode task does the whole loop in real Python in a single execution, then one query pulls the result back into a job variable.

Real-world example: a NetBox-devices-to-inventory-nodes mapping (per-device manufacturer lookup, per-vendor secret-path branching, record reshaping) that originally took forEach + 5 query + 1 evaluation + 4 newVariable + 2 merge + push/objectToString/join/makeData (17 tasks) collapsed to 2 tasks (runCode + query), then to 1 task once the trailing query was replaced by an Enable Query decorator too (see below) — with identical, verified output at every stage. Full before/after: helpers/assets/netbox-inventory-sync-runcode-enablequery.json.

Fields (confirmed via live multipleTaskDetails?dereferenceSchemas=true — {"location":"Application","pckg":"GatewayManager","method":"runCode"}):

FieldRequiredNotes
clusterIdyesGateway5 cluster ID to execute on
languageyesEnum — currently only "python"
codeyesThe Python source as a plain string
datayesObject passed to the script as JSON on stdin
safety.timeoutyesSeconds, 1-1000
packagesnoArray of pip requirement strings (e.g. "requests==2.31.0"), installed before execution — omit if stdlib-only

Size limit (per official docs): code is limited to 2 MB. You cannot save code that exceeds this limit. The limit applies to code text only — installed packages do not count toward it.

Package caching (per official docs): the first time you run a task with a new set of packages, the runner installs them into a virtual environment. Subsequent runs reuse the cached environment, so there's no install overhead. Any change to the packages list triggers a fresh install.

Script contract (the "IAG runCode standard" — see real examples in helpers/assets/vendor-cisco-ios.json and vendor-juniper-junos.json):

import sys, json
data = json.load(sys.stdin)          # read the `data` object
# ... do the work ...
print(json.dumps(result))            # last line to stdout is the result

Anything printed to stderr is captured separately and does NOT pollute the parsed result — use it for debug logging inside the script if needed.

Outgoing result object: stdout (raw string — use when the consumer needs a JSON string, e.g. an IAG service's string-typed param), stdout_json (already-parsed JSON — use when the consumer wants a real array/object, e.g. a task field typed array/object; absent/null if stdout wasn't valid JSON), stderr, return_code (0 = success), status ("completed"/"error"), started_at/finished_at/elapsed_ms. Pick stdout vs stdout_json based on what the NEXT task's field type actually wants — don't always reach for stdout_json by default.

Errors and timeout (per official docs): the task catches unhandled Python exceptions and captures the traceback in result.stderr — the task still completes and the workflow does NOT follow the error transition for an in-script exception. safety.timeout stops execution if exceeded; when exceeded, result.status becomes "error" and result.error contains the platform-specific failure reason. The timeout applies to your code only, not to package installation. If the task cannot run on the gateway at all (not enabled, lost connectivity), the task itself fails and no result output is produced — that error is visible in the task's Error tab in Operations Manager instead, as a distinct {state, domain, code, message} shape (e.g. "message": "Gateway with id test-code-task is not enabled").

Execution status vs. exit code (per official docs) — three genuinely different failure modes, don't conflate them:

  • status: "completed" + return_code: 0 — the script ran and exited cleanly.
  • status: "completed" + a non-zero return_code — the script ran but exited with a failure code; your code executed, the script itself signaled failure.
  • status: "error" — a platform-level failure (e.g. a timeout). The script may not have run, or it stopped mid-execution; check result.error. This is distinct from a Gateway connectivity failure (see above), where the task itself errors and no result fields are populated at all.

stdout_json absent after a successful run (per official docs): if result.stdout_json doesn't appear after return_code: 0, the most common causes are (a) stdout contained non-JSON content, a mix of JSON and other output, or no output, or (b) a value in the output object wasn't JSON-serializable, so json.dumps() raised a TypeError and wrote a traceback to stderr instead of JSON to stdout. Inspect result.stdout and result.stderr directly to tell which applies.

Gotcha: secret-vault placeholder strings ($SECRET_.../$KEY_...) written as literal Python string constants inside code are NOT resolved by runCode or by the workflow engine — they pass through as plain text, identical to writing the same literal into a newVariable task. That's expected; the vault resolves them later, at actual device-connection time, not during this task's execution. This is not the only option, though — per official docs, runCode has its own external-secret mechanism: reference $GATEWAYSECRET_(alias-name) as a literal string in your script, and Gateway resolves the alias at execution time. The secret value is never sent back to Itential Platform. This requires a secret provider/alias already set up on the Gateway side — not yet confirmed live in this repo, but documented here since it directly answers "is there any safe way to use secrets in a runCode script."

"Enable Query" — inline query decorator on any incoming field (aka "in-task-query", called "task query" in official docs)

A per-field Studio toggle, not a task or endpoint — invisible to tasks.json/openapi.json/multipleTaskDetails searches. Appears under any incoming field with Variable Source: Job or Task selected (a checkbox labeled "Enable Query"). Lets you query a nested path out of that field's resolved value inline, eliminating a separate query task that would otherwise sit between the producing task and the field that needs one piece of its output.

Confirmed shape (via a live before/after diff of a Studio edit — not guessed):

  "incoming": {
-   "someField": "$var.job.someVar",
+   "someField": "$var.job.someVar#/nested/path",
    ...
  },
- "decorators": []
+ "decorators": [
+   { "type": "query", "pointer": "/incoming/someField", "displayPath": ".nested.path" }
+ ]
  • The field's existing $var.job.<name> or $var.<taskId>.<name> reference gets a #/<path> suffix appended directly onto the string (URL-fragment style, /-delimited).
  • The task's decorators array (always present, usually []) gets one entry: pointer is JSON-Pointer-style pointing at the field (/incoming/<fieldName>); displayPath is the dot-notation the Studio UI shows — its .-separated segments map 1:1 to the #/... suffix's /-separated segments (.result.stdout → #/result/stdout).
  • Doesn't create a task, doesn't touch outgoing — purely a decorator on an existing field's existing value.

Verified working at runtime, not just in Studio: deleted a query task whose only job was extracting one field from another task's object output, and instead pointed the downstream consumer's own field at the source directly with the #/... suffix + decorator. The job variable the deleted task used to populate was confirmed absent from the job's final variable dump (proving the task really was gone), yet the consuming task still received the correct nested value and the job completed successfully with real data flowing through.

Confirmed scope: works on both a top-level plain-string incoming field AND a field nested inside an object (e.g. runCode's data.devices) — confirmed by deleting the upstream query task entirely and verifying the job variable it used to write was absent from the job's final state while the downstream task still received the correct value. For a nested field, the #/<path> suffix goes on the nested value itself ("data": {"devices": "$var.job.someVar#/nested/path"}) and pointer uses the full nested path (/incoming/data/devices).

When to reach for it: any spot where a query task's only purpose is pulling one field out of an object and handing it to exactly one downstream task's field. Skip it if the queried value feeds more than one consumer, or needs further transformation (evaluation, string ops) before use — a real query task is still the right call there.

Confirmed NOT to try without testing first: evaluation operands use a structured reference ({"task":"job","variable":"x"}), not a plain $var string — this is a different field shape than every confirmed Enable Query example, and hasn't been tested. Don't assume this decorator applies there. For this exact scenario, evaluation's own operand object has a native query sibling key instead — see "Operand can drill into a nested field via an inline query key" earlier in this section; that's the confirmed way to skip a separate query task feeding an evaluation operand.

Worked example: wiring runCode + task query together

A complete walkthrough of bringing multiple upstream tasks' data into a runCode script, using task query to unwrap each one, and reading the result back out — confirmed live end-to-end. Real example (two NetBox list calls combined into one summary): helpers/assets/runcode-taskquery-reference.json.

1. Bring variables into runCode via its data field. Each key in incoming.data becomes one entry your script receives on stdin. The value is a reference expression, same syntax as any other task field:

"data": {
  "devicelist": "$var.8850.response",
  "interfacelist": "$var.4e04.response"
}

This hands the entire response output of tasks 8850 and 4e04 to the script, under the keys devicelist/interfacelist. No query needed yet — this alone is enough if the script itself can navigate whatever shape arrives (see step 4).

2. Design a task query only if you want to unwrap a nested field before the script sees it. Append #/<json-pointer-path> to the reference string, and add a matching entry to the task's decorators array — both parts are required and must agree:

"data": {
  "devicelist": "$var.8850.response#/body"
},
"decorators": [
  { "type": "query", "pointer": "/incoming/data/devicelist", "displayPath": ".body" }
]
  • The #/<path> suffix goes on the value string itself, /-delimited (JSON-Pointer style).
  • The decorator's pointer is /incoming/data/<key> (locates which incoming field has a query), and displayPath is the Studio-UI-facing dot-path (.-delimited, 1:1 with the #/... segments).
  • This query is evaluated once, before the script ever runs. If the path doesn't exist on the real resolved object, the whole task — and job — fails immediately with "Query failed for task incoming variables: <key>". The script never gets a chance to handle a missing/wrong path defensively; get the path right up front.

Don't guess the path — verify the real shape first. The platform's own job-detail API does not help here: GET /operations-manager/jobs/{id} always returns the static workflow definition (empty-string outgoing placeholders) regardless of include/dereference params or job completion status — confirmed directly, including via the browser UI's own network call using session-cookie auth, not just a scripted API token. The two ways that do work: (a) open the job in Operations Manager / Studio and read the task's real Output/Response value directly in the UI, or (b) temporarily pass the reference through without a query, and have the script itself print the shape it received (type(), keys, a sample item) so you can see the real structure before committing to a query path.

Confirmed real-world case: a REST/HTTP-style adapter task whose outgoing field is named response returns the full HTTP response object, not the API payload directly — the payload is at response.body (response itself is {body, headers, statusCode, ...}-shaped). This is a different wrapping than the existing documented result.response convention elsewhere in this file (different outgoing field name, different nesting depth) — don't conflate the two. Confirmed on NetBox's dcim_devices_list/dcim_interfaces_list: querying #/results directly failed (that path doesn't exist at the top level); the real path is #/body, landing on {count, next, previous, results: [...]}}.

The query must be the field's entire value — it does not work embedded inside a larger static object. Confirmed by feeding runCode's output into a second, non-runCode task (extras_tags_create) requiring a requestBodyPayload object with name/slug/description:

  • Failed silently: requestBodyPayload set to a static object literal with the query on one of its own keys — {"name": "my-tag", "slug": "my-tag", "description": "$var.3c0b.result#/stdout_json/devices/0/name"} + a decorator pointing at /incoming/requestBodyPayload/description. The query never resolved. Worse, this didn't fail loudly: the literal, unresolved $var...#/... string was sent through as-is, and NetBox — whose description field accepts any free text — happily stored the garbage literal string as if it were real data. The failure only became visible when the same pattern was tried on a strictly-typed field (weight, an integer): NetBox rejected the literal string with "A valid integer is required", which looked like a type/casting problem but was actually a masked query-resolution failure. A task query that "succeeds" into a loosely-typed field is not proof the query itself worked — check a strict field, or inspect the literal value sent, before trusting it.
  • Confirmed working: shape the upstream task's output to already match the downstream field's whole expected structure, then query the entire field in one shot — "requestBodyPayload": "$var.3c0b.result#/stdout_json/tags_payload" (where tags_payload in runCode's own JSON output is already {name, slug, description}-shaped), decorator {"pointer": "/incoming/requestBodyPayload", "displayPath": ".stdout_json.tags_payload"}. Verified end-to-end: real HTTP POST to NetBox, real object received (confirmed via a genuine "tag with this name already exists" conflict on a repeat run — proof the resolved payload's name/slug were real values, not placeholders).
  • This also confirms task query is not runCode-specific — it works the same way on a plain adapter task, as long as the whole-field rule above is followed.

3. Access the variables inside the script. Everything in data arrives as one JSON object on stdin, keyed exactly as configured:

import sys, json
data = json.load(sys.stdin)
devicelist = data['devicelist']    # already whatever step 2's query resolved it to, if a query was used
interfacelist = data['interfacelist']

If a task query already unwrapped a field (e.g., to .body), the script receives that unwrapped value directly — it doesn't need to re-navigate past a level the query already resolved. If no query was used, the script receives the full raw reference and is responsible for navigating it itself (defensive code that handles more than one possible shape is reasonable here, since a script CAN branch/fall back at runtime, unlike a query decorator which fails hard on a wrong path).

4. Return the result. The last line printed to stdout must be json.dumps(...) of whatever the next task (or the workflow's output) should consume — this becomes result.stdout_json. Anything else printed for debugging goes to stderr instead, so it doesn't corrupt the parsed result (see the runCode fields table above).

Task Endpoint Patterns (Standalone Testing)

Some tasks have standalone REST endpoints — faster than creating test workflows:

  • WorkFlowEngine: POST /workflow_engine/{method} (e.g., /workflow_engine/query) — requires job_id (use dummy ObjectId "4321abcdef694aa79dae47ad")
  • MOP: POST /mop/RunCommandTemplate — test command templates directly
  • TemplateBuilder: POST /template_builder/templates/{name}/renderJinja with {"context": {...}} (note: context, not variables)

Most utility tasks (array ops, string ops, forEach, childJob, merge) do NOT have standalone endpoints. Test those by creating a minimal start → task → end workflow and running via jobs/start.


Templates (Jinja2 / TextFSM)

POST /automation-studio/templates
{
  "template": {
    "name": "VLAN_Interface_Config",
    "type": "jinja2",
    "group": "Cisco IOS",
    "command": "configure terminal",
    "description": "Generates VLAN interface config",
    "template": "interface Vlan{{ vlan_id }}\n description {{ description }}\n ip address {{ ip_address }} {{ subnet_mask }}\n no shutdown",
    "data": "{\"vlan_id\": 100, \"description\": \"Management\", \"ip_address\": \"10.0.1.1\", \"subnet_mask\": \"255.255.255.0\"}"
  }
}

Required fields: name, group, command, description, template, data, type

Types: jinja2 (config generation) or textfsm (output parsing)

Test rendering directly:

POST /template_builder/templates/{name}/renderJinja
{"context": {"vlan_id": 100, "description": "Management"}}

Gotchas:

  • group cannot be empty or whitespace-only
  • Use underscores in template names (e.g., IOS_Switchport_Config)
  • data field is a JSON string, not an object
  • Variable syntax is {{ var }} (Jinja2), NOT $var or <!var!>
  • No from_json filter — Ansible's from_json Jinja2 filter does NOT exist in Itential's TemplateBuilder. If you need to parse a JSON string, use a parse task before the template render step, not a filter inside the template
  • renderJinjaTemplate as a workflow task — use TemplateBuilder.renderJinjaTemplate with incoming templateName (string) and variables (object). Output is at result.renderedTemplate (string). Different from the standalone API endpoint which uses context instead of variables

Command Templates (MOP)

MOP manages command templates for running CLI commands with validation rules. MOP is read-only validation only — never use it to push config.

To push config to a device, use itential_cli via AGManager — not MOP. The standard pattern for any config push delivery is:

Pre-Check (RunCommandTemplate child)
  → Push Configuration to Device (renderJinjaTemplate → dry run approval → itential_cli → commit approval → itential_cli)
  → Post-Check (RunCommandTemplate child)
  → runTemplatesDiff (compare pre vs post)

Read the Arista EOS "Push Configuration to Device - IAG" and "Command Template Runner" workflows before building any config push delivery:

jq '[.components[] | select(.type=="workflow") | select(.document.name | test("Push Config|Command Template"))] | .[].document | {name:.name, tasks:.tasks, transitions:.transitions}' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-arista-eos.json

Create a Command Template

POST /mop/createTemplate
{
  "mop": {
    "name": "Port_Turn_Up_Pre_Check",
    "description": "Validates interface and VLAN",
    "os": "",
    "passRule": true,
    "ignoreWarnings": false,
    "commands": [
      {
        "command": "show interface <!interface!>",
        "passRule": true,
        "rules": [
          {
            "rule": "line protocol is",
            "eval": "contains",
            "severity": "error"
          }
        ]
      },
      {
        "command": "show vlan brief",
        "passRule": true,
        "rules": [
          {
            "rule": "<!vlan_id!>",
            "eval": "contains",
            "severity": "error"
          }
        ]
      }
    ]
  }
}

Variable syntax: <!variable_name!> in both commands and rules (NOT {{ }} or $var)

passRule Logic

  • Template-level passRule: true = ALL commands must pass (AND)
  • Template-level passRule: false = ONE command must pass (OR)
  • Command-level = same logic for rules within a command

Rule Evaluation

EvalPurposeExample
containsString exists in output"line protocol is"
!containsString does NOT exist"ERROR"
contains1String exists exactly once"Active"
RegExRegex matches (capital R, E!)"/\\d+\\.\\d+/"
!RegExRegex does NOT match"/ERROR/"
#comparisonExtract + compare two valuesSee below

#comparison: Extract values with regex, compare numerically:

{
  "rule": "/Available: (\\d+)/",
  "ruleB": "/Total: (\\d+)/",
  "eval": "#comparison",
  "evaluator": ">=",
  "severity": "error"
}

Evaluators: =, !=, <, >, <=, >=, % (percentage)

Flags: case: true = case-INSENSITIVE (confusing name), global: true, multiline: true (RegEx only)

Run a Command Template

Standalone:

POST /mop/RunCommandTemplate
{
  "template": "Port_Turn_Up_Pre_Check",
  "variables": {"interface": "GigabitEthernet0/1", "vlan_id": "100"},
  "devices": ["IOS-CAT8KV-1"]
}

In a workflow (MOP.RunCommandTemplate task):

{
  "incoming": {
    "template": "$var.job.templateName",
    "variables": "$var.job.templateVariables",
    "devices": "$var.job.devices"
  },
  "outgoing": {
    "mop_template_results": null
  }
}

Response Shape

{
  "all_pass_flag": true,
  "result": true,
  "name": "Port_Turn_Up_Pre_Check",
  "commands_results": [
    {
      "raw": "show interface <!interface!>",
      "evaluated": "show interface GigabitEthernet0/1",
      "all_pass_flag": true,
      "device": "IOS-CAT8KV-1",
      "response": "...command output...",
      "result": true,
      "rules": [{"rule": "line protocol is", "eval": "contains", "result": true}]
    }
  ]
}

Update a Command Template

POST /mop/updateTemplate/{mopID}

mopID is the template name (URL-encoded). Body is {"mop": {...}} — full replacement, include ALL fields.

Analytic Templates (Pre/Post Comparison)

POST /mop/createAnalyticTemplate
{
  "name": "Interface_Change_Validation",
  "os": "cisco-ios",
  "passRule": true,
  "prepostCommands": [
    {
      "preRawCommand": "show interface GigabitEthernet0/1",
      "postRawCommand": "show interface GigabitEthernet0/1",
      "passRule": true,
      "rules": [
        {
          "type": "matches",
          "preRegex": "/line protocol is (\\w+)/",
          "postRegex": "/line protocol is (\\w+)/",
          "evaluator": "="
        }
      ]
    }
  ]
}

In a workflow (MOP.runAnalyticsTemplate task):

{
  "incoming": {
    "pre": "$var.preCheckTaskId.mop_template_results",
    "post": "$var.postCheckTaskId.mop_template_results",
    "analytic_template_name": "Interface_Change_Validation",
    "variables": {}
  },
  "outgoing": {"analytic_result": null}
}

Testing & Debugging

Start a Job

POST /operations-manager/jobs/start
{
  "workflow": "My Workflow Name",
  "options": {
    "description": "Test run",
    "type": "automation",
    "variables": {"deviceName": "IOS-CAT8KV-1"}
  }
}

Response: {"message": "...", "data": {"_id": "jobId", "status": "running"}}

Check Job Status

GET /operations-manager/jobs/{jobId}

Response wrapped in {message, data, metadata}:

  • data.status — "running", "complete", "error", "canceled"
  • data.variables — all job variables including outputs
  • data.error — array of error objects on failure

Debug Failed Jobs

  1. GET /operations-manager/jobs/{jobId} — check data.status
  2. If "error", read data.error[] — each has task (ID) and message.IAPerror.displayString
  3. Identify the failing task ID, check its metrics.finish_state

Common failures:

SymptomCauseFix
"Method not found" validation errorTask name doesn't existSearch tasks.json
"No available transitions"Missing error transitionAdd "state": "error" transition
$var resolves to literal stringNon-hex task ID or nested objectCheck task IDs, use merge
"Cannot find workflow"childJob ref broken after project moveUpdate workflow field with @projectId: prefix
Schema validation errorWrong/missing fieldsCheck task-schemas.json
Adapter errorWrong app name or adapter downCheck apps.json and GET /health/adapters
"No config found for Adapter: X"app field uses adapter instance name instead of type nameapp/locationType must be the type from apps.json (e.g., EmailOpensource), not instance name (e.g., email). Instance name goes in adapter_id.
Silent data mismatchField type doesn't match schema (string vs array)Check task-schemas.json — pass arrays for array fields, numbers for number fields

Standalone Test Endpoints

Some tasks have REST endpoints for quick testing without creating workflows:

  • query: POST /workflow_engine/query (needs dummy job_id)
  • Jinja2 render: POST /template_builder/templates/{name}/renderJinja with {"context": {...}}
  • MOP: POST /mop/RunCommandTemplate with {"template": "name", "devices": [...], "variables": {...}}

Updating Assets (Edit Locally, PUT to Update)

AssetCreateUpdateDelete
WorkflowPOST /automation-studio/automationsPUT /automation-studio/automations/{id} with {"update": {...}}DELETE /workflow_builder/workflows/delete/{URL-encoded-name} (by name, not ID)
TemplatePOST /automation-studio/templatesPUT /automation-studio/templates/{id} with {"update": {...}}DELETE /automation-studio/templates/{id}
Command TemplatePOST /mop/createTemplatePOST /mop/updateTemplate/{name} with {"mop": {...}} (full replacement)—

Pre-flight validate before every create or update:

POST /automation-studio/workflows/validate
{"workflow": {...}}
→ {"errors": [], "warnings": []}

Empty errors = schema valid. Run this before every POST or PUT.

Workflow rename:

POST /workflow_builder/workflows/rename
{"workflow": {...full doc...}, "newName": "New Workflow Name"}

Renames in-place without recreating. Use instead of appending [Fixed] suffixes.


Workflow Patterns

Error Handling: Try-Catch

In child workflows: catch errors with newVariable to set a status flag:

task --success--> newVariable("taskStatus" = "success") -> workflow_end
task --error--> newVariable("taskStatus" = "error") -> workflow_end

In parent workflows: after childJob, extract and check:

childJob -> query (extract taskStatus from job_details) -> evaluation (== "success"?)
  |-- success -> continue
  |-- failure -> handle error

Error Transitions on Adapter Tasks

Every adapter task needs both success and error transitions. Route errors to an intermediate newVariable task if both need to reach workflow_end:

"transitions": {
  "a1b2": {
    "c3d4": {"type": "standard", "state": "success"},
    "err1": {"type": "standard", "state": "error"}
  },
  "err1": {
    "workflow_end": {"type": "standard", "state": "success"}
  }
}

Manual Tasks (Human-in-the-Loop)

ViewHTML — renders an HTML string in a modal for operator review. Requires specific fields or it becomes a draft workflow:

{
  "name": "ViewHTML", "canvasName": "ViewHTML",
  "location": "Application", "locationType": null, "app": "WorkFlowEngine",
  "type": "manual", "displayName": "Tools",
  "view": "/workflow_engine/task/ViewHTML",
  "taskVersion": 2, "hostApp": "@itential/app-operations_manager",
  "variables": {
    "incoming": {
      "header": "Report Title",
      "body": "$var.job.html_output",
      "variables": "",
      "btn_success": "Acknowledge",
      "btn_failure": ""
    },
    "outgoing": {}
  },
  "actor": "Pronghorn"
}

view, taskVersion: 2, and hostApp are all required — omitting any one causes "Manual Tasks require 'view' key" draft error.

Read a live ViewData example from the Cisco IOS upgrade workflow — it shows makeData → ViewData → success/failure branches in production:

jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "ViewData")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-cisco-ios.json

Three rules that cause draft validation errors if missed:

  1. view is a top-level field (sibling of name, type, app) — NOT inside variables. Missing it → "Manual Tasks require 'view' key with path to task view".
  2. incoming.variables MUST be present (value can be {} if unused). Missing it → "Input: 'variables' is not defined in task model".
  3. displayName must be "Tools" and actor must be null (no actor field) on manual tasks.

Note: production assets include "error": "" and "decorators": [] in the variables block on ViewData tasks — these are added by Studio on export and are harmless. You do not need to add or remove them.

{
  "name": "ViewData",
  "canvasName": "ViewData",
  "location": "Application",
  "app": "WorkFlowEngine",
  "displayName": "Tools",
  "type": "manual",
  "view": "/workflow_engine/task/ViewData",
  "variables": {
    "incoming": {
      "header": "Approval Required",
      "message": "Review and approve.",
      "body": "$var.job.dataToReview",
      "variables": "$var.job.dataToReview",
      "btn_success": "Approve",
      "btn_failure": "Reject"
    },
    "outgoing": {}
  },
  "groups": []
}

ViewHTML (Manual Task — HTML Display)

Use ViewHTML when you need to display formatted HTML to an operator during a workflow — for reports, tables, or styled summaries. Same manual task rules as ViewData apply.

Read a live ViewHTML example:

jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "ViewHTML")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-cisco-ios.json

Key differences from ViewData:

  1. view is /workflow_engine/task/ViewHTML
  2. body is a raw HTML string — use inline CSS (no <style> blocks), <!var!> syntax for variable substitution
  3. incoming.variables is a plain object {"varName": "value"} that populates <!var!> placeholders in the HTML — NOT a $var reference
  4. displayName: "Tools", no actor field (same as ViewData)
{
  "name": "ViewHTML",
  "canvasName": "ViewHTML",
  "location": "Application",
  "app": "WorkFlowEngine",
  "displayName": "Tools",
  "type": "manual",
  "view": "/workflow_engine/task/ViewHTML",
  "variables": {
    "incoming": {
      "header": "Report",
      "body": "<h2>Status: <!status!></h2><p>Device: <!device!></p>",
      "variables": {
        "status": "$var.job.deviceStatus",
        "device": "$var.job.deviceName"
      },
      "btn_success": "Continue",
      "btn_failure": "End"
    },
    "outgoing": {}
  },
  "groups": []
}

autoApprove Pattern

Use an evaluation task to conditionally skip manual approval:

evaluation (autoApprove == true?)
  |-- success -> skip to next task (auto-approved)
  |-- failure -> ViewData (human reviews and approves/rejects)

The workflow accepts an autoApprove boolean input. When true, skips the manual step. Useful for CI/CD pipelines that run unattended vs interactive operator sessions.

Revert Transitions (Retry Loops)

Use "type": "revert" transitions to go backward for retry scenarios:

renderTemplate -> viewConfig (approve/reject)
  |-- success -> pushConfig -> evalSuccess
  |                             |-- success -> end
  |                             |-- failure -> viewError (retry/abort)
  |                                             |-- success (retry) --revert--> renderTemplate
  |                                             |-- failure (abort) -> end
  |-- failure (reject) --revert--> renderTemplate

The revert transition moves execution back to a previous task, allowing the user to fix inputs and retry.

Modular Workflow Design

  • Build each child workflow independently testable via jobs/start
  • Use childJob with data_array + loopType: "parallel" to fan out
  • Check for existing workflows before building new ones
  • Keep all asset JSON locally — edit locally, PUT to update

Network Device Config Pattern

  1. MOP command templates for validation checks only (show commands + rules)
  2. Jinja2 templates to generate configuration
  3. Push config via existing workflow or adapter task — ask the engineer
  4. Test CLI commands on the actual device BEFORE building workflows

Variable Syntax Reference

ContextSyntaxExample
Jinja2 templates{{ var }}interface Vlan{{ vlan_id }}
Command templates (MOP)<!var!>show interface <!interface!>
makeData input<!var!>{"name": "<!name!>"}
Workflow variable refs$var.job.x or $var.taskId.x$var.job.deviceName
childJob variable refs{"task":"job","value":"varName"}{"task":"static","value":["a"]}
merge/evaluation refs{"task":"job","variable":"varName"}{"task":"static","variable":"success"}

childJob uses "value". merge/evaluation use "variable". Do NOT mix them.


API Response Shapes

EndpointShape
POST /operations-manager/jobs/start{message, data: {_id, status}}
GET /operations-manager/jobs/{id}{message, data: {status, variables, error}}
POST /automation-studio/projects{message, data: {_id, name}}
POST /automation-studio/automations{created: {_id, name}, edit: "..."}
POST /automation-studio/templates{created: {_id, name}, edit: "..."}
GET /automation-studio/workflows{items: [...], skip, limit, total}
GET /automation-studio/templates{items: [...], skip, limit, total}

Adapter Response Shapes

Adapters transform upstream API responses. Don't assume the native API's response structure. For example, ServiceNow's Table API returns result.sys_id, but the Itential adapter flattens it to response.id. Always verify by calling the adapter directly or checking openapi.json.

Adapter URI Prefix

genericAdapterRequest auto-prepends the adapter's base_path to uriPath. Don't include /api/v1 in uriPath. Use genericAdapterRequestNoBasePath to bypass.


Gotchas

Pre-flight scan list — read this before every project import and job start. This list is intentionally redundant with the body sections. The repetition is deliberate: scanning a flat list before submitting catches mistakes that are easy to miss when building task by task.

To add a new finding: put the detail in the relevant body section first, then add a one-liner here pointing to it.

Projects

  1. Use POST /projects/import to create projects with all assets atomically — avoids broken childJob refs, project-locking issues, and intermediate state. Pre-compute _id so childJob @projectId: refs can be wired before push.
  2. Avoid create + move pattern — moving assets renames them with @projectId: prefix but does NOT update internal references (childJob workflow fields, template names).
  3. Import format differs from create — OMIT encodingVersion from workflow documents (causes silent component failure). Workflow created_by has NO _id but has firstname, inactive, sso. Project createdBy HAS _id.
  4. Component type is mopCommandTemplate — not mop.
  5. Members PATCH is full replacement — include ALL members or omitted ones are removed.
  6. Import sets the OAuth service account as project owner — not the UI user. PATCH membership immediately after import (Phase 3, not Phase 6) or the spec engineer is locked out.
  7. accessControl in PATCH body is silently ignored — API returns 200 but the field is a no-op. Always use the members array format ([{type, reference, role}]).

Workflows

  1. canvasName must come from tasks.json — some differ from method name: arrayPush→push, stringConcat→concat. Wrong canvasName causes silent $var failures.
  2. Task IDs must be hex [0-9a-f]{1,4} — non-hex causes silent $var failure.
  3. Validation errors = draft workflow that cannot be started. Run POST /automation-studio/workflows/validate before every create or update.
  4. $var inside nested objects doesn't resolve — use merge/makeData/query to build the object first.
  5. stringConcat does not resolve $var inside stringN arrays — values stored as literal strings. Use merge → makeData with <!var!> placeholders instead.
  6. Every adapter/external task needs an error transition — without one, errors cause "Job has no available transitions" and the job gets stuck forever.
  7. JSON can't have duplicate keys — if success and error both go to workflow_end, route error to an intermediate newVariable task first.

Utility Tasks

  1. merge uses "variable", childJob uses "value" — don't mix them. Using "variable" in childJob causes undefined.indexOf() at job start (P6.4.0+).
  2. merge requires at least 2 items — 1 item silently returns null.
  3. childJob actor MUST be "job", task MUST be "", job_details outgoing MUST be null.
  4. childJob variables use {"task","value"} NOT $var — $var strings inside variables cause an indefinite hang at runtime.
  5. evaluation MUST have both success AND failure transitions — missing one silently drops the job.
  6. forEach last body task transition must be empty {} — do NOT connect it back to forEach.
  7. push/pop/shift take variable NAME as a plain string — "myArray" not "$var.job.myArray".
  8. newVariable value with $var stores the literal string — use merge + query to build dynamic values.
  9. makeData variables must be a resolved object — use merge first, then pass $var.taskId.merged_object.
  10. Adapter task result is always an object — never a primitive. When the upstream API returns a simple string (e.g., Infoblox _ref), it's at result.response. Passing raw result in a string context produces [object Object]. 24a. childJob cannot resolve project-scoped workflow names — "@<projectId>: <name>" and the bare name both fail with "Cannot find workflow ...", even from a calling workflow in the same project. Inline the target task(s) instead; there's no known childJob workaround. See ### childJob for how this was confirmed. 24b. A merge reading a childJob's output must use "value", not "variable" — the one exception to "merge uses variable." Using "variable" fails workflow create/update with a generic, task-unattributed "Cannot read properties of undefined (reading 'task')" error. 24c. push's incoming must omit job_id (even though tasks.json lists it) and its outgoing must declare job_variable_value (not an empty {}) — both cause the same generic create/update failure as 24b if wrong. 24d. parse's real fields are text/textObject, not stringToParse/result. 24e. objectToString's replacer is a property whitelist — replacer: [] silently produces {} for every input, no error, no warning. replacer/space are both optional (required: false); if you don't need them, OMIT the keys entirely rather than passing null (which trips a validate-time type warning) or []/0 (which is schema-valid but semantically means "include zero properties" — the empty-whitelist regression). This is a genuine silent-data-loss bug, not cosmetic: the task's own stringified output reads as "{}" with no error while $var.job.<sourceVar> is fully populated the whole time. If you see objectToString "losing" data that a job's final variable dump shows was correct, check replacer first before suspecting a timing/race issue in whatever produced the source object. 24f. $var does not resolve when written as an element inside an inline array literal — "groups": ["$var.job.myGroup"] sends the literal unresolved string, not the resolved value (confirmed via A/B test: ["academy"] worked, ["$var.job.groupVar"] sent the literal 21-character string and the API rejected it as an unknown group name). Same family as "no $var inside nested object values," but applies to plain arrays of primitives too. Fix: build the array with newVariable (value: []) + push (one item at a time), then reference the whole array as a single top-level $var.job.<name> — a bare job-variable reference resolves fine. 24g. InventoryManager.getNodesByInventory's params field is required at runtime even though its live schema (multipleTaskDetails?dereferenceSchemas=true) doesn't mark it required: true. Omitting it fails job start with "Cannot find match for input: \"params\" from model". Always pass "params": {} explicitly for this task, regardless of what the schema implies. 24h. InventoryManager.getInventoryByIdentifier surfaces "not found" as a task-level error transition, not a success with an error-shaped body (confirmed with an isolated probe: HTTP 404 → job status: error, task's outgoing.response stays unset). Branch on the task's own success/error transitions for existence checks — no evaluation task needed to inspect a response field.

Templates

  1. Template group cannot be empty or whitespace-only — causes a silent rejection.
  2. TextFSM templates may contain control characters that break jq — use Python with a control-char strip when parsing them.

MOP

  1. Missing variable = skip = PASS (not fail) — if a variable isn't passed, the rule is skipped and the command auto-passes. Always verify variables are passed correctly.
  2. case: true = case-INsensitive — the name is backwards. Easy to wire the wrong behavior.
  3. Eval types are case-sensitive — "RegEx" not "regex". Wrong casing silently fails.
  4. Empty rules = auto-pass — a command with no rules always passes. Add at least one rule to validate output.
  5. MOP update is full replacement — include ALL fields or omitted ones are lost.
  6. MOP is read-only — never use it to push config. Use itential_cli via AGManager for config push.

General

  1. Adapter app must come from apps.json — NOT tasks.json. Names can differ completely (e.g., ServiceNow vs Servicenow). Wrong app causes "No config found for Adapter" at runtime.
  2. legacyWrapper: false on Operations Manager manual triggers — default true wraps all form values under formData, breaking variable mapping to workflow inputs.
  3. status: complete doesn't mean CLI commands succeeded — always check stdout for actual command output and errors.
  4. Endpoint base paths differ — task catalog at /workflow_builder/tasks/list, schemas at /automation-studio/multipleTaskDetails (NOT /workflow_builder/multipleTaskDetails).
  5. evaluation operator is a closed enum — only contains, !contains, <, <=, >, >=, ==, != exist. Any other string silently returns false with empty outgoing and no error message. Validate against this list before wiring.
  6. contains uses regex, not substring matching — operand_2 is interpreted as a regex pattern. Escape metacharacters ((, ), ., [, ], ?, +, *, |) in literal values: 9\.2\(4\) not 9.2(4). Test with POST /workflow_engine/runEvaluationGroups before wiring.
  7. API PUT does not regenerate incomingRefs for existing task changes — evaluation operand literals resolve to null after PUT. Broader symptom: entire workflow hangs after workflow_start (status: running forever). Fix: open in Studio and save. If still failing, recreate via fresh POST — more PUTs won't fix it.
  8. $var.<taskId>.<out> does not resolve inside nested forEach bodies — use $var.job.<varName> for any variable referenced inside a nested loop body.
  9. Workflow delete endpoint — DELETE /workflow_builder/workflows/delete/{URL-encoded-name} deletes by name, returns 200 with deleted doc. DELETE /automation-studio/automations/{id} does NOT exist (404). Always export the project before deleting anything.
  10. Always use a local venv for Python — python3 -m venv .venv && source .venv/bin/activate before any Python scripts during the build.
  11. Search tasks.json before designing any sub-workflow — a purpose-built platform task may already exist for the intent (filter, inventory, tag, etc.). Server-side is always better than a forEach + evaluation chain.
  12. Prefer server-side filtering over client-side when available — fetching the full collection and filtering in a forEach adds unnecessary iterations. Check for a filtered-fetch task first.
  13. Propose decomposition when a workflow exceeds ~20 tasks — extract inner iteration bodies into reusable child workflows.
  14. DRY check on sibling workflows — if building multiple similarly-named workflows, compare task graphs. Identical graphs → propose one generic workflow, not N clones.
  15. Project component refresh — mode: "copy" creates a new project-scoped UUID that immediately diverges from the standalone. To refresh: DELETE each old component, POST fresh, then update any Operations Manager automation componentId via PATCH /operations-manager/automations/{id}.
  16. renderJinja2 inline template with \n breaks parse — static values store literal \n characters, causing parse to fail with "Expected property name or '}' in JSON at position 1". Fix: write single-line templates.
  17. Task outgoing writes directly to job var — "outgoing": {"result": "$var.job.myVar"} works on any task and is more reliable than a separate newVariable copy step (written at execution time, bypassing incomingRefs cache).
  18. GatewayManager "failed to parse start_time" = device unreachable — this IAG error ("failed to parse start_time for command 0: failed to parse timestamp string ''") means the device is offline, unreachable, or auth failed. The timestamp complaint is misleading — the session never opened. It is NOT a workflow bug. Guard with an evaluation checking whether the response contains a result key; if not, route to a skip handler and continue.
  19. NEVER wire a Configuration Manager remediation task — runAutoRemediation, advancedAutoRemediation, convertChangesToConfig, patchDeviceConfiguration, advancedPatchDeviceConfiguration, patchCMDeviceConfiguration (IAP), ManualRemediation, and ManualRemediationResults are prohibited in every workflow, even when a spec asks for fully automatic remediation. Golden Config detects and reports drift; it never applies fixes to a device. To correct a device, build a normal config-push delivery. (updateNodeConfig is allowed — it authors the GC node template, not a device.)

Helper Templates

Two separate concerns — don't mix them:

  • API wrappers (project, workflow, template, form creation) → use helpers/create/ scaffolds below. These are POST body wrappers — correct structure, required fields.
  • Task JSON inside a workflow → extract from helpers/assets/ using jq (see Guide 1 STOP block). Do NOT use helpers/create/ files for task bodies — they are scaffold stubs, not task examples.

Scaffolds — start from these

Read these first. They have the correct wrapper, required fields, and structure.

When you need to...Read this helperThen POST to
Create a project${CLAUDE_PLUGIN_ROOT}/helpers/create/create-project.jsonPOST /automation-studio/projects
Create a workflow${CLAUDE_PLUGIN_ROOT}/helpers/create/create-workflow.jsonPOST /automation-studio/automations
Create a Jinja2 template${CLAUDE_PLUGIN_ROOT}/helpers/create/create-template-jinja2.jsonPOST /automation-studio/templates
Create a TextFSM template${CLAUDE_PLUGIN_ROOT}/helpers/create/create-template-textfsm.jsonPOST /automation-studio/templates
Create a MOP command template${CLAUDE_PLUGIN_ROOT}/helpers/create/create-command-template.jsonPOST /mop/createTemplate
Update a MOP template${CLAUDE_PLUGIN_ROOT}/helpers/update/update-command-template.jsonPOST /mop/updateTemplate/{name}
Create a JSON form (static dropdowns)${CLAUDE_PLUGIN_ROOT}/helpers/create/create-json-form.jsonPOST /json-forms/forms — see itential-json-forms skill
Create a JSON form (REST-bound or cascading dropdowns)${CLAUDE_PLUGIN_ROOT}/helpers/create/create-json-form-rest-bound.jsonPOST /json-forms/forms — see itential-json-forms skill
Create an Ops Manager automation${CLAUDE_PLUGIN_ROOT}/helpers/create/create-ops-manager-automation.jsonPOST /operations-manager/automations
Create a manual trigger (with form)${CLAUDE_PLUGIN_ROOT}/helpers/create/create-ops-manager-trigger-manual.jsonPOST /operations-manager/triggers — legacyWrapper MUST be false
Create a scheduled trigger${CLAUDE_PLUGIN_ROOT}/helpers/create/create-ops-manager-trigger-schedule.jsonPOST /operations-manager/triggers
Import a project (atomic)${CLAUDE_PLUGIN_ROOT}/helpers/create/import-project.jsonPOST /automation-studio/projects/import
Add assets to a project${CLAUDE_PLUGIN_ROOT}/helpers/operations/add-components-to-project.jsonPOST /projects/{id}/components/add
Update project membership${CLAUDE_PLUGIN_ROOT}/helpers/update/update-project-members.jsonPATCH /projects/{id}

Task templates — extract from asset projects

Do not write task JSON from scratch. For every task type, extract a real example from an asset project and adapt it. Use the jq commands below — they work against the project files in ${CLAUDE_PLUGIN_ROOT}/helpers/assets/.

# Adapter task (ServiceNow, Infoblox, etc.)
jq '[.components[].document.tasks // {} | to_entries[] | select(.value.location == "Adapter")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-servicenow.json

# Application task (WorkFlowEngine — getTime, newVariable, query, evaluation, transformation, merge, makeData)
jq '[.components[].document.tasks // {} | to_entries[] | select(.value.app == "WorkFlowEngine" and .value.name == "TASK_NAME")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/itential-platform-configuration-management.json

# childJob
jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "childJob")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-cisco-ios.json

# evaluation / branching
jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "evaluation")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-cisco-ios.json

# transformation (JST)
jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "transformation")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-netbox.json

# RunCommandTemplate / viewTemplateResults / reattempt / runTemplatesDiff (MOP tasks)
jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "TASK_NAME")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/itential-platform-configuration-management.json

# itential_cli (config push via IAG)
jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "itential_cli")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-arista-eos.json

# ViewData / ViewHTML (manual tasks)
jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "ViewData")] | first | .value' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/vendor-cisco-ios.json

Key fields to verify after extracting (see body sections above for full rules per task type):

Task typeMust-check fields
Adapterapp/locationType from apps.json (not tasks.json), adapter_id, error transition
childJobactor: "job", task: "", variables use {"task","value"} not $var
evaluationevaluation_groups[], all_true_flag, both success AND failure transitions
transformationtr_id, variableMap keys match transformation's incoming schema
ViewData / ViewHTMLview is top-level (not inside variables), displayName: "Tools", no actor, no error/decorators
Manual tasks (any)type: "manual", taskVersion: 2, hostApp required

Reference workflows — read from asset projects

Real, production-tested workflows. Use the jq commands to extract and study them before building.

PatternAsset filejq filter
Adapter workflow: merge → create → query → update + error handlingvendor-servicenow.jsonselect(.document.name | test("Create Change"))
childJob orchestrator + evaluation branchingvendor-cisco-ios.jsonselect(.document.name | test("IOS Upgrade"))
childJob loop with data_array (parallel/sequential)vendor-cisco-ios.jsonselect(.document.name | test("Upgrade|Runner"))
Config push: renderJinja → dry-run ViewData → itential_cli → commitvendor-arista-eos.jsonselect(.document.name | test("Push Config"))
Pre/post MOP check: RunCommandTemplate → viewTemplateResults → evaluation → reattemptitential-platform-configuration-management.jsonselect(.document.name | test("Command Template Runner"))
IPAM CRUD (adapter + transformation + error)vendor-infoblox-nios-ddi.jsonselect(.document.name | test("Create Network|Assign Next"))
ITSM ticket + update (ServiceNow)vendor-servicenow.jsonselect(.document.name | test("Create Incident"))
LCM action workflow (must output instance)lcm/lcm-vxlan-fabric-services-project.jsonselect(.document.name | test("Create|Delete")) — note: this file uses .data.project.components[]
Email/notificationitential-platform-email.jsonselect(.document.name | test("Email"))
Per-item transform loop replaced by runCode + Enable Query (zero/near-zero WorkFlowEngine utility tasks)netbox-inventory-sync-runcode-enablequery.jsonselect(.document.name | test("runCode")) — 2 real workflows: one calling an external script, one using only native app tasks, both with the same runCode device-mapping pattern
# General pattern to read any workflow by name from an asset project:
jq '[.components[] | select(.type=="workflow") | select(.document.name | test("PATTERN"; "i"))] | first | .document | {name:.name, tasks:.tasks, transitions:.transitions}' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/ASSET_FILE.json

# For the LCM project (different wrapper):
jq '[.data.project.components[] | select(.type=="workflow") | select(.document.name | test("PATTERN"; "i"))] | first | .document | {name:.name, tasks:.tasks, transitions:.transitions}' \
  ${CLAUDE_PLUGIN_ROOT}/helpers/assets/lcm/lcm-vxlan-fabric-services-project.json

Keep looking

Skills are one crate of 628,853. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.