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
npx -y skills add itential/builder-skills --skill builder-agentAssembled 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 provides | Approved solution-design.md (all platform data already present in workspace) |
| Agent does | Builds all components per design, tests each piece individually, reports delivery outcomes |
| Engineer action | Reviews delivery and resolves open build questions |
| Deliverable | Deployed assets (workflows, templates, projects) |
| Customer receives | Delivered 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.jsonis 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
.envto 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— NOTapplication/json. Sending JSON returns HTTP 415. - The
/loginendpoint does NOT support OAuth client credentials on SaaS instances — always use/oauth/token. - On success, write
.auth.jsonwith 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.mdtemplate. Fill in Platform URL,Stage: build,Status: activeimmediately.
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
StageandStatusif they changed — mid-build,Stagestaysbuild; only update it totestat 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 Patternsand, 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 fromhelpers/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.jsonReplace
vendor-servicenow.jsonwith 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.jsonapp,locationType→ from apps.json (NOT tasks.json)displayName→ from tasks.jsonlocation→"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.jsonactor→"Pronghorn"for all tasks except childJob (which uses"job")incoming→ each schema key becomes a variable. Wire with$varfor top-level valuesoutgoing→ set tonull(capture later with$var.taskId.outVar)- Add
adapter_idto incoming for adapter tasks (not in schema, always required) - Add
erroranddecoratorsto 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:
- Try creating with minimal fields — the error message lists what's missing (e.g.,
"must have required property 'summary'") - Check
openapi.jsonfor the adapter's endpoint schema - 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}) -
appandlocationTypevalues come from apps.json.name, NOT tasks.json and NOT the adapter instance name (e.g.,EmailOpensourcenotemail) -
adapter_idis the adapter instance name (e.g.,email), NOT the type name -
adapter_idvalues come fromadapters.json.results[].id— NEVER from the spec's adapter identity table. The spec is a design document;adapters.jsonis the source of truth for the target environment. -
canvasNamevalues come from tasks.jsoncanvasNamefield - Every adapter task has
adapter_idin incoming - Every adapter task has an error transition
-
evaluationtasks have both success AND failure transitions -
evaluationoperators are from the closed enum (contains, !contains, <, <=, >, >=, ==, !=) — no others exist -
evaluationoperand_2literal values containing regex metacharacters (.,(,),[,],?,+,*,|) are properly escaped, OR stored in anewVariableconstant-holder task to avoidincomingRefscache 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 forpage/pageSize, etc.) - No
$varreferences 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 addxtoinputSchema.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
querydownstream of achildJobreturns 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 amergetask between childJob and query using{"task": "<childJobId>", "variable": "job_details"}indata_to_merge, then pointobjto$var.<mergeId>.merged_object(see Guide 4) - childJob has
actor: "job", all others haveactor: "Pronghorn" -
workflow_endtransition is empty{} - Canvas layout follows the vertical spacing convention — non-forked sequences on a constant-x spine, fork branches offset to
spine±264and 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_mergecovers every field in the resource model'sschema.requiredarray — missing even one field causes an instance write failure after provisioning (resources are orphaned from LCM). Read the model'sschema.requiredbefore building the merge task:jq '.schema.required' helpers/assets/lcm/<model>.json - ViewData manual tasks:
viewis a top-level field;incoming.variablesis present (even if{});displayName: "Tools", noactorfield - 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.requiredfields beyond what eachdata_arrayelement contains, use the forEach enrichment pattern (forEach → merge → arrayPush) to add shared fields into each element before the childJob loop; setvariables: {}on the childJob - forEach body:
incomingcontains ONLYdata_array(nojob_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_objectintomakeData.incoming.variables— usequeryto extract individual values first -
objectToString: omitreplacer/spacefromincomingif unused — do NOT passreplacer: [](silently whitelists zero properties, output is always{}) ornull(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 ID | Task | Why it's there | Key fields |
|---|---|---|---|
e1a1 | merge | Builds 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. |
a1b2 | createChangeRequest | Adapter 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". |
b2c3 | query | Extracts the change ID from the adapter response. | query: "response.id" — adapters transform responses, don't assume native API shape. |
c3d4 | updateChangeRequest | Second adapter call using the extracted ID. | changeId wired from $var.job.changeId (set by query's outgoing). |
ef01 | newVariable | Error 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 field | Source | Example |
|---|---|---|
name | tasks.json .name | createChangeRequest |
canvasName | tasks.json .canvasName | createChangeRequest (can differ: arrayPush→push) |
app | apps.json .name (adapter type name) | Servicenow, EmailOpensource (NOT email, NOT ServiceNow from tasks.json) |
locationType | Same as app for adapters, null for applications | Servicenow, EmailOpensource |
displayName | tasks.json .displayName | ServiceNow, email |
location | tasks.json .location | Adapter or Application |
type | tasks.json .type — read directly, do not guess (per-task, not per-app) | varies |
actor | "Pronghorn" always, except childJob which uses "job" | Pronghorn |
adapter_id | adapters.json .results[].id (adapter instance name) | servicenow-prod, email — this goes in incoming, NOT in the task-level app field |
| incoming vars | From task schema (multipleTaskDetails) | body, changeId |
| outgoing vars | From task schema, set to null | result |
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 message | Cause | Fix |
|---|---|---|
| "Schema validation failed on must have required property 'X'" | Missing field in adapter body | Add the field to merge task |
| "Method not found" | Wrong task name or app | Check tasks.json and apps.json |
| "No available transitions" | Missing error transition | Add "state": "error" transition |
| "Cannot find workflow" | childJob ref broken after project move | Update 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 task | Add 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:
GET /operations-manager/jobs/{jobId}— find the task indata.tasksby its task ID- Read the task's outgoing variables — that is the real response object
- Use
jqto explore:jq '.data.tasks["a1b2"]' job.json - Wire the
querypath 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'stargetDevicejob variable to the child asdeviceName{"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 toinputSchema.required— same behavior as merge (see### mergesection). 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"causesundefined.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
-
actoris"job"(NOT"Pronghorn") -
taskis""(empty string) -
job_detailsoutgoing isnull - All incoming fields present — even unused ones:
"data_array": "","transformation": "","loopType": "" - Variables use
{"task","value"}NOT$var(single mode) -
variablesis{}when usingdata_array(loop mode) - Child workflow's
inputSchema.requiredmatches what you're passing -
loopType:""(single),"parallel"(simultaneous),"sequential"(one at a time) - If a downstream
queryof 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:
- Accept inputs via
inputSchemathat match what the parent passes - Set output variables via
newVariableor task outgoing →$var.job.x - 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
| Method | Endpoint | Description |
|---|---|---|
| POST | /automation-studio/projects/import | Import a project (preferred — atomic) |
| POST | /automation-studio/projects | Create 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}/export | Export project as JSON |
| POST | /automation-studio/projects/{projectId}/components/add | Add 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):
| Field | Import format | Notes |
|---|---|---|
encodingVersion | OMIT from workflow documents | Causes silent component failure if included |
created_by (workflow) | {username, provenance, firstname, inactive, sso} — NO _id | Different from project-level createdBy |
createdBy (project) | {_id, username, provenance} — HAS _id | Different from workflow-level |
_id (project) | Pre-compute 24-char hex string | So childJob refs can use @{projectId}: |
Workflow name | Clean names — no prefix | Import adds @projectId: automatically |
childJob workflow | Must include @{projectId}: prefix | Pre-wire using the same _id |
reference (workflow) | UUID string | Becomes the workflow's uuid |
reference (MOP) | @{projectId}: Template Name | String reference |
iid (components) | Sequential integers starting at 1 | Incrementing 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
| Problem | Create + move | Import |
|---|---|---|
| childJob refs | Break on move — manual fix needed | Pre-wired with @projectId: — just work |
| Project locking | Race conditions during move | Single atomic call |
| Intermediate state | Workflows exist outside project | Never |
| API calls | Create + create each asset + move + fix refs | One POST |
| Reproducibility | Hard to replay | project-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/addcalled more than once on the same project can corruptiidtracking. Confirmed on a project created via emptyPOST /automation-studio/projects(notimport): a firstcomponents/addcall (e.g. 5 components) gets assigned sequentialiids, but the project's owncomponentIidIndexcan come back reporting a value lower than the actual maxiidjust used. A secondcomponents/addcall trusts that stale index as its starting point and assigns newiids that collide with ones from the first call — two different components end up sharing the sameiid, silently corrupting anything that references components byiid(notablyfolders[].children[].iidgroupings).Diagnostic sign: after adding components in more than one call,
GETthe project and checkcomponents[].iidfor duplicates across differenttypes.
PATCHdoes not fix this. Sending a correctedcomponents/foldersarray with de-duplicated, hand-assignediids viaPATCH /automation-studio/projects/{projectId}returns200, but the platform recalculates/discards your customiidvalues server-side — aGETright 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/addcall (confirmed reliable — produces clean sequentialiids), or (b) rebuild the project from scratch viaprojects/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
| Operation | Endpoint |
|---|---|
| Set | PUT /automation-studio/projects/{id}/thumbnail — body: {"imageData": "<data-URI>", "backgroundColor": "<hex>"} |
| Get | GET /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 dropdownscreate-json-form-rest-bound.json— REST-bound or cascading dropdowns
Operations Manager (Automations & Triggers)
| Method | Endpoint | Description |
|---|---|---|
| POST | /operations-manager/automations | Create an automation |
| GET | /operations-manager/automations | List automations |
| POST | /operations-manager/triggers | Create a trigger |
| PATCH | /operations-manager/triggers/{id} | Update a trigger |
| GET | /operations-manager/triggers | List 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:
| Task | Best asset to check |
|---|---|
| ServiceNow adapter tasks | vendor-servicenow.json |
| Infoblox / DNS / IPAM tasks | vendor-infoblox-nios-ddi.json |
| NetBox tasks | vendor-netbox.json |
| itential_cli, RunCommandTemplate, MOP tasks | itential-platform-configuration-management.json, vendor-cisco-ios.json |
| transformation (JST) | vendor-netbox.json, itential-platform-data-manipulation.json |
| childJob, evaluation, query, newVariable | any vendor project |
| LCM action workflow tasks | helpers/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 field | Maps 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:
- Search asset projects (above) — if found, use the wired example directly
- Check if
{use-case}/task-schemas.jsonexists — search it next - Only call
multipleTaskDetailsfor tasks not found in either place - 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)
| Rule | Value |
|---|---|
| Sequential tasks (y-delta) | +108px |
| Fork branch offset from spine (x-delta) | ±264px |
| Spine x | a 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 - 264and the other atspine + 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
nodeLocationvalues 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
| Field | Application Tasks | Adapter Tasks |
|---|---|---|
name | Method name from tasks.json | Method name from tasks.json |
canvasName | From tasks.json canvasName field (may differ from name: arrayPush→push) | Same |
location | "Application" | "Adapter" |
locationType | null | Same as app |
app | App 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" |
displayName | App name | May 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.
| Field | Type | Meaning |
|---|---|---|
groups (plural) | string[] | GBAC. Each entry is a group's MongoDB _id (24-char hex). Empty [] means no task-level restriction. |
group (singular, optional) | string | Canvas 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_idandname)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 ofname/app/type) — gates access to a single manual task. - Top-level workflow
groups(sibling oftasks/transitionsat 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
groupsaccepts a$varjob-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 forwardrevert— 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:
| Wiring | Works? | Why |
|---|---|---|
"deviceName": "$var.job.x" | Yes | Direct top-level value |
"variables": {"key": "$var.job.x"} | NO | Nested inside object |
"body": {"data": "$var.job.x"} | NO | Nested — 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:
| Scenario | Result | Fix |
|---|---|---|
| New tasks added via PUT | incomingRefs generated — task-to-task $var refs resolve immediately | None needed |
| Existing task field values changed via PUT | incomingRefs NOT regenerated — literals/changed taskRefs resolve to null | Open in Studio → Save |
POST /workflow_builder/workflows/save | Does NOT regenerate incomingRefs either | Open in Studio → Save |
Evaluation silently returns false after PUT | Stale operand cache | Constant-holder workaround below, or Studio save |
Workflow hangs at workflow_start (status: running forever) after PUT | Any task's incomingRefs stale | Recreate 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 toinputSchema.required. The platform scans everydata_to_mergeentry in merge tasks (and everyvariablesentry in childJob) for{task:"job"}references and automatically adds that variable name toinputSchema.required. This means using{task:"job", variable:"changeId"}for a variable that was produced internally by a query task will prompt operators to supplychangeIdas 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 source Correct ref form User workflow input {"task": "job", "variable": "x"}queryoutput{"task": "queryTaskId", "variable": "return_data"}mergeoutput{"task": "mergeTaskId", "variable": "merged_object"}newVariableoutput{"task": "newVarTaskId", "variable": "value"}makeDataoutput{"task": "makeDataTaskId", "variable": "output"}parseoutput{"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:
actorMUST be"job"— not"Pronghorn"taskMUST be""(empty string)outgoing.job_detailsMUST benull— 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)
-
incomingmust only containdata_array— do NOT includejob_idor any other field. Addingjob_idcauses errors at runtime. -
$var.<taskId>.<output>does NOT resolve inside the loop body — string references like$var.n01.current_itemsilently resolve tonullinside 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. -
Loop body tasks cannot transition to tasks outside the loop — no error transitions from loop body tasks to external error handlers. The
forEachtask itself handles exit viastate: "error"on the forEach transition. Handle errors within the loop body, then let the forEach's error transition route out. -
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.variablescannot use$varreferences to a merge that sources childJob output. When amergetask'sdata_to_mergecontains a childJob reference (e.g.,{"task": "childJobId", "variable": "job_details"}), the platform cannot compile$var.<mergeId>.merged_objectas ataskRefformakeData.incoming.variables— it is stored as a literal static string. Template substitution then operates on the literal string and emits unresolved placeholders.
query.incoming.objdoes NOT have this limitation — it resolves$var.<mergeId>.merged_objectcorrectly even when the merge references childJob output.Fix: extract individual values from the childJob-sourced merge using
querytasks, 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'svariables.
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
| Category | Examples |
|---|---|
| String | stringConcat, replace, split, toLowerCase, toUpperCase, trim, substring |
| Array | arrayConcat, arrayPush, sort, join, arraySlice, map, reverse |
| Object | assign, keys, values, objectHasOwnProperty, setObjectKey |
| Time | getTime, addDuration, convertTimezone, calculateTimeDiff |
| Parse/Transform | parse, transformation, stringify |
| Tools | restCall, 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 ofquery+mergewhen 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 ofmerge→renderJinja2→querychains when the template needs access to existing job variables. OutputsrenderedTemplateaccessible 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
pythonorpython3on theiagctlcommand line. Gateway does not enforce a minimum version — runpython --versionon the Gateway 5 host to check. - Confirm that the
gateway:coderole 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"}):
| Field | Required | Notes |
|---|---|---|
clusterId | yes | Gateway5 cluster ID to execute on |
language | yes | Enum — currently only "python" |
code | yes | The Python source as a plain string |
data | yes | Object passed to the script as JSON on stdin |
safety.timeout | yes | Seconds, 1-1000 |
packages | no | Array 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-zeroreturn_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; checkresult.error. This is distinct from a Gateway connectivity failure (see above), where the task itself errors and noresultfields 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
decoratorsarray (always present, usually[]) gets one entry:pointeris JSON-Pointer-style pointing at the field (/incoming/<fieldName>);displayPathis 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
pointeris/incoming/data/<key>(locates which incoming field has a query), anddisplayPathis 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:
requestBodyPayloadset 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 — whosedescriptionfield 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"(wheretags_payloadinrunCode'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'sname/slugwere 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) — requiresjob_id(use dummy ObjectId"4321abcdef694aa79dae47ad") - MOP:
POST /mop/RunCommandTemplate— test command templates directly - TemplateBuilder:
POST /template_builder/templates/{name}/renderJinjawith{"context": {...}}(note:context, notvariables)
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:
groupcannot be empty or whitespace-only- Use underscores in template names (e.g.,
IOS_Switchport_Config) datafield is a JSON string, not an object- Variable syntax is
{{ var }}(Jinja2), NOT$varor<!var!> - No
from_jsonfilter — Ansible'sfrom_jsonJinja2 filter does NOT exist in Itential's TemplateBuilder. If you need to parse a JSON string, use aparsetask before the template render step, not a filter inside the template renderJinjaTemplateas a workflow task — useTemplateBuilder.renderJinjaTemplatewith incomingtemplateName(string) andvariables(object). Output is atresult.renderedTemplate(string). Different from the standalone API endpoint which usescontextinstead ofvariables
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
| Eval | Purpose | Example |
|---|---|---|
contains | String exists in output | "line protocol is" |
!contains | String does NOT exist | "ERROR" |
contains1 | String exists exactly once | "Active" |
RegEx | Regex matches (capital R, E!) | "/\\d+\\.\\d+/" |
!RegEx | Regex does NOT match | "/ERROR/" |
#comparison | Extract + compare two values | See 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 outputsdata.error— array of error objects on failure
Debug Failed Jobs
GET /operations-manager/jobs/{jobId}— checkdata.status- If
"error", readdata.error[]— each hastask(ID) andmessage.IAPerror.displayString - Identify the failing task ID, check its
metrics.finish_state
Common failures:
| Symptom | Cause | Fix |
|---|---|---|
| "Method not found" validation error | Task name doesn't exist | Search tasks.json |
| "No available transitions" | Missing error transition | Add "state": "error" transition |
$var resolves to literal string | Non-hex task ID or nested object | Check task IDs, use merge |
| "Cannot find workflow" | childJob ref broken after project move | Update workflow field with @projectId: prefix |
| Schema validation error | Wrong/missing fields | Check task-schemas.json |
| Adapter error | Wrong app name or adapter down | Check apps.json and GET /health/adapters |
| "No config found for Adapter: X" | app field uses adapter instance name instead of type name | app/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 mismatch | Field 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 dummyjob_id) - Jinja2 render:
POST /template_builder/templates/{name}/renderJinjawith{"context": {...}} - MOP:
POST /mop/RunCommandTemplatewith{"template": "name", "devices": [...], "variables": {...}}
Updating Assets (Edit Locally, PUT to Update)
| Asset | Create | Update | Delete |
|---|---|---|---|
| Workflow | POST /automation-studio/automations | PUT /automation-studio/automations/{id} with {"update": {...}} | DELETE /workflow_builder/workflows/delete/{URL-encoded-name} (by name, not ID) |
| Template | POST /automation-studio/templates | PUT /automation-studio/templates/{id} with {"update": {...}} | DELETE /automation-studio/templates/{id} |
| Command Template | POST /mop/createTemplate | POST /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:
viewis a top-level field (sibling ofname,type,app) — NOT insidevariables. Missing it →"Manual Tasks require 'view' key with path to task view".incoming.variablesMUST be present (value can be{}if unused). Missing it →"Input: 'variables' is not defined in task model".displayNamemust be"Tools"andactormust benull(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:
viewis/workflow_engine/task/ViewHTMLbodyis a raw HTML string — use inline CSS (no<style>blocks),<!var!>syntax for variable substitutionincoming.variablesis a plain object{"varName": "value"}that populates<!var!>placeholders in the HTML — NOT a$varreferencedisplayName: "Tools", noactorfield (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
childJobwithdata_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
- MOP command templates for validation checks only (show commands + rules)
- Jinja2 templates to generate configuration
- Push config via existing workflow or adapter task — ask the engineer
- Test CLI commands on the actual device BEFORE building workflows
Variable Syntax Reference
| Context | Syntax | Example |
|---|---|---|
| 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
| Endpoint | Shape |
|---|---|
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
- Use
POST /projects/importto create projects with all assets atomically — avoids broken childJob refs, project-locking issues, and intermediate state. Pre-compute_idso childJob@projectId:refs can be wired before push. - Avoid create + move pattern — moving assets renames them with
@projectId:prefix but does NOT update internal references (childJobworkflowfields, template names). - Import format differs from create — OMIT
encodingVersionfrom workflow documents (causes silent component failure). Workflowcreated_byhas NO_idbut hasfirstname,inactive,sso. ProjectcreatedByHAS_id. - Component type is
mopCommandTemplate— notmop. - Members PATCH is full replacement — include ALL members or omitted ones are removed.
- 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.
accessControlin PATCH body is silently ignored — API returns 200 but the field is a no-op. Always use themembersarray format ([{type, reference, role}]).
Workflows
canvasNamemust come fromtasks.json— some differ from method name:arrayPush→push,stringConcat→concat. WrongcanvasNamecauses silent$varfailures.- Task IDs must be hex
[0-9a-f]{1,4}— non-hex causes silent$varfailure. - Validation errors = draft workflow that cannot be started. Run
POST /automation-studio/workflows/validatebefore every create or update. $varinside nested objects doesn't resolve — use merge/makeData/query to build the object first.stringConcatdoes not resolve$varinsidestringNarrays — values stored as literal strings. Usemerge→makeDatawith<!var!>placeholders instead.- Every adapter/external task needs an error transition — without one, errors cause "Job has no available transitions" and the job gets stuck forever.
- JSON can't have duplicate keys — if success and error both go to
workflow_end, route error to an intermediatenewVariabletask first.
Utility Tasks
- merge uses
"variable", childJob uses"value"— don't mix them. Using"variable"in childJob causesundefined.indexOf()at job start (P6.4.0+). - merge requires at least 2 items — 1 item silently returns null.
- childJob
actorMUST be"job",taskMUST be"",job_detailsoutgoing MUST benull. - childJob
variablesuse{"task","value"}NOT$var—$varstrings inside variables cause an indefinite hang at runtime. evaluationMUST have both success AND failure transitions — missing one silently drops the job.forEachlast body task transition must be empty{}— do NOT connect it back to forEach.push/pop/shifttake variable NAME as a plain string —"myArray"not"$var.job.myArray".newVariablevalue with$varstores the literal string — use merge + query to build dynamic values.makeDatavariablesmust be a resolved object — use merge first, then pass$var.taskId.merged_object.- Adapter task
resultis always an object — never a primitive. When the upstream API returns a simple string (e.g., Infoblox_ref), it's atresult.response. Passing rawresultin a string context produces[object Object]. 24a.childJobcannot 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### childJobfor how this was confirmed. 24b. Amergereading achildJob'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 omitjob_id(even thoughtasks.jsonlists it) and its outgoing must declarejob_variable_value(not an empty{}) — both cause the same generic create/update failure as 24b if wrong. 24d.parse's real fields aretext/textObject, notstringToParse/result. 24e.objectToString'sreplaceris a property whitelist —replacer: []silently produces{}for every input, no error, no warning.replacer/spaceare both optional (required: false); if you don't need them, OMIT the keys entirely rather than passingnull(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 ownstringifiedoutput reads as"{}"with no error while$var.job.<sourceVar>is fully populated the whole time. If you seeobjectToString"losing" data that a job's final variable dump shows was correct, checkreplacerfirst before suspecting a timing/race issue in whatever produced the source object. 24f.$vardoes 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$varinside nested object values," but applies to plain arrays of primitives too. Fix: build the array withnewVariable(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'sparamsfield is required at runtime even though its live schema (multipleTaskDetails?dereferenceSchemas=true) doesn't mark itrequired: 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.getInventoryByIdentifiersurfaces "not found" as a task-levelerrortransition, not asuccesswith an error-shaped body (confirmed with an isolated probe: HTTP 404 → jobstatus: error, task'soutgoing.responsestays unset). Branch on the task's ownsuccess/errortransitions for existence checks — noevaluationtask needed to inspect a response field.
Templates
- Template
groupcannot be empty or whitespace-only — causes a silent rejection. - TextFSM templates may contain control characters that break jq — use Python with a control-char strip when parsing them.
MOP
- 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.
case: true= case-INsensitive — the name is backwards. Easy to wire the wrong behavior.- Eval types are case-sensitive —
"RegEx"not"regex". Wrong casing silently fails. - Empty rules = auto-pass — a command with no rules always passes. Add at least one rule to validate output.
- MOP update is full replacement — include ALL fields or omitted ones are lost.
- MOP is read-only — never use it to push config. Use
itential_clivia AGManager for config push.
General
- Adapter
appmust come fromapps.json— NOTtasks.json. Names can differ completely (e.g.,ServiceNowvsServicenow). Wrongappcauses "No config found for Adapter" at runtime. legacyWrapper: falseon Operations Manager manual triggers — defaulttruewraps all form values underformData, breaking variable mapping to workflow inputs.status: completedoesn't mean CLI commands succeeded — always checkstdoutfor actual command output and errors.- Endpoint base paths differ — task catalog at
/workflow_builder/tasks/list, schemas at/automation-studio/multipleTaskDetails(NOT/workflow_builder/multipleTaskDetails). evaluationoperator is a closed enum — onlycontains, !contains, <, <=, >, >=, ==, !=exist. Any other string silently returnsfalsewith empty outgoing and no error message. Validate against this list before wiring.containsuses regex, not substring matching —operand_2is interpreted as a regex pattern. Escape metacharacters ((,),.,[,],?,+,*,|) in literal values:9\.2\(4\)not9.2(4). Test withPOST /workflow_engine/runEvaluationGroupsbefore wiring.- API PUT does not regenerate
incomingRefsfor existing task changes — evaluation operand literals resolve tonullafter PUT. Broader symptom: entire workflow hangs afterworkflow_start(status: running forever). Fix: open in Studio and save. If still failing, recreate via fresh POST — more PUTs won't fix it. $var.<taskId>.<out>does not resolve inside nested forEach bodies — use$var.job.<varName>for any variable referenced inside a nested loop body.- 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. - Always use a local venv for Python —
python3 -m venv .venv && source .venv/bin/activatebefore any Python scripts during the build. - Search
tasks.jsonbefore 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. - 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.
- Propose decomposition when a workflow exceeds ~20 tasks — extract inner iteration bodies into reusable child workflows.
- DRY check on sibling workflows — if building multiple similarly-named workflows, compare task graphs. Identical graphs → propose one generic workflow, not N clones.
- 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 automationcomponentIdviaPATCH /operations-manager/automations/{id}. renderJinja2inline template with\nbreaksparse— static values store literal\ncharacters, causingparseto fail with "Expected property name or '}' in JSON at position 1". Fix: write single-line templates.- Task outgoing writes directly to job var —
"outgoing": {"result": "$var.job.myVar"}works on any task and is more reliable than a separatenewVariablecopy step (written at execution time, bypassing incomingRefs cache). - 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 anevaluationchecking whether the response contains aresultkey; if not, route to a skip handler and continue. - NEVER wire a Configuration Manager remediation task —
runAutoRemediation,advancedAutoRemediation,convertChangesToConfig,patchDeviceConfiguration,advancedPatchDeviceConfiguration,patchCMDeviceConfiguration(IAP),ManualRemediation, andManualRemediationResultsare 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. (updateNodeConfigis 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 usehelpers/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 helper | Then POST to |
|---|---|---|
| Create a project | ${CLAUDE_PLUGIN_ROOT}/helpers/create/create-project.json | POST /automation-studio/projects |
| Create a workflow | ${CLAUDE_PLUGIN_ROOT}/helpers/create/create-workflow.json | POST /automation-studio/automations |
| Create a Jinja2 template | ${CLAUDE_PLUGIN_ROOT}/helpers/create/create-template-jinja2.json | POST /automation-studio/templates |
| Create a TextFSM template | ${CLAUDE_PLUGIN_ROOT}/helpers/create/create-template-textfsm.json | POST /automation-studio/templates |
| Create a MOP command template | ${CLAUDE_PLUGIN_ROOT}/helpers/create/create-command-template.json | POST /mop/createTemplate |
| Update a MOP template | ${CLAUDE_PLUGIN_ROOT}/helpers/update/update-command-template.json | POST /mop/updateTemplate/{name} |
| Create a JSON form (static dropdowns) | ${CLAUDE_PLUGIN_ROOT}/helpers/create/create-json-form.json | POST /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.json | POST /json-forms/forms — see itential-json-forms skill |
| Create an Ops Manager automation | ${CLAUDE_PLUGIN_ROOT}/helpers/create/create-ops-manager-automation.json | POST /operations-manager/automations |
| Create a manual trigger (with form) | ${CLAUDE_PLUGIN_ROOT}/helpers/create/create-ops-manager-trigger-manual.json | POST /operations-manager/triggers — legacyWrapper MUST be false |
| Create a scheduled trigger | ${CLAUDE_PLUGIN_ROOT}/helpers/create/create-ops-manager-trigger-schedule.json | POST /operations-manager/triggers |
| Import a project (atomic) | ${CLAUDE_PLUGIN_ROOT}/helpers/create/import-project.json | POST /automation-studio/projects/import |
| Add assets to a project | ${CLAUDE_PLUGIN_ROOT}/helpers/operations/add-components-to-project.json | POST /projects/{id}/components/add |
| Update project membership | ${CLAUDE_PLUGIN_ROOT}/helpers/update/update-project-members.json | PATCH /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 type | Must-check fields |
|---|---|
| Adapter | app/locationType from apps.json (not tasks.json), adapter_id, error transition |
| childJob | actor: "job", task: "", variables use {"task","value"} not $var |
| evaluation | evaluation_groups[], all_true_flag, both success AND failure transitions |
| transformation | tr_id, variableMap keys match transformation's incoming schema |
| ViewData / ViewHTML | view 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.
| Pattern | Asset file | jq filter |
|---|---|---|
| Adapter workflow: merge → create → query → update + error handling | vendor-servicenow.json | select(.document.name | test("Create Change")) |
| childJob orchestrator + evaluation branching | vendor-cisco-ios.json | select(.document.name | test("IOS Upgrade")) |
| childJob loop with data_array (parallel/sequential) | vendor-cisco-ios.json | select(.document.name | test("Upgrade|Runner")) |
| Config push: renderJinja → dry-run ViewData → itential_cli → commit | vendor-arista-eos.json | select(.document.name | test("Push Config")) |
| Pre/post MOP check: RunCommandTemplate → viewTemplateResults → evaluation → reattempt | itential-platform-configuration-management.json | select(.document.name | test("Command Template Runner")) |
| IPAM CRUD (adapter + transformation + error) | vendor-infoblox-nios-ddi.json | select(.document.name | test("Create Network|Assign Next")) |
| ITSM ticket + update (ServiceNow) | vendor-servicenow.json | select(.document.name | test("Create Incident")) |
LCM action workflow (must output instance) | lcm/lcm-vxlan-fabric-services-project.json | select(.document.name | test("Create|Delete")) — note: this file uses .data.project.components[] |
| Email/notification | itential-platform-email.json | select(.document.name | test("Email")) |
Per-item transform loop replaced by runCode + Enable Query (zero/near-zero WorkFlowEngine utility tasks) | netbox-inventory-sync-runcode-enablequery.json | select(.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